fix(lua): address resolver and scanner review findings

This commit is contained in:
taoxin 2026-08-24 10:19:27 +08:00
parent 16f3641ca5
commit f192855ba4
7 changed files with 101 additions and 20 deletions

View file

@ -2,7 +2,7 @@
* Lua import resolution config (legacy Phase A path).
*
* `require("a.b.c")` → module path `a/b/c`, resolved via suffixResolve
* (tries .lua then /init.lua — see EXTENSIONS in utils.ts). Mirrors
* (tries .lua then /init.lua — see LUA_EXTENSIONS in utils.ts). Mirrors
* rubyRequireStrategy but splits on `.` (Lua's module separator) and strips
* surrounding quotes (the @import.source capture is a string literal).
*
@ -15,13 +15,19 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js';
import { suffixResolve } from '../utils.js';
import { LUA_EXTENSIONS, suffixResolve } from '../utils.js';
export const luaRequireStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => {
const cleaned = rawImportPath.replace(/^["']|["']$/g, '');
const pathParts = cleaned.split('.').filter(Boolean);
if (pathParts.length === 0) return null;
const resolved = suffixResolve(pathParts, ctx.normalizedFileList, ctx.allFileList, ctx.index);
const resolved = suffixResolve(
pathParts,
ctx.normalizedFileList,
ctx.allFileList,
ctx.index,
LUA_EXTENSIONS,
);
return resolved ? { kind: 'files', files: [resolved] } : null;
};

View file

@ -22,9 +22,6 @@ export const EXTENSIONS = [
// Python
'.py',
'/__init__.py',
// Lua — require("a.b.c") resolves to a/b/c.lua or a/b/c/init.lua
'.lua',
'/init.lua',
// Java
'.java',
// Kotlin
@ -57,6 +54,9 @@ export const EXTENSIONS = [
'.rb',
];
/** Lua module extensions used only by Lua import resolvers. */
export const LUA_EXTENSIONS = ['', '.lua', '/init.lua'] as const;
/**
* Try to match a path (with extensions) against the known file set.
* Returns the matched file path or null.
@ -64,8 +64,9 @@ export const EXTENSIONS = [
export function tryResolveWithExtensions(
basePath: string,
allFiles: ReadonlySet<string>,
extensions: readonly string[] = EXTENSIONS,
): string | null {
for (const ext of EXTENSIONS) {
for (const ext of extensions) {
const candidate = basePath + ext;
if (allFiles.has(candidate)) return candidate;
}
@ -392,11 +393,12 @@ export function suffixResolve(
normalizedFileList: readonly string[],
allFileList: readonly string[],
index?: SuffixIndex,
extensions: readonly string[] = EXTENSIONS,
): string | null {
if (index) {
for (let i = 0; i < pathParts.length; i++) {
const suffix = pathParts.slice(i).join('/');
for (const ext of EXTENSIONS) {
for (const ext of extensions) {
const suffixWithExt = suffix + ext;
const result = index.get(suffixWithExt) || index.getInsensitive(suffixWithExt);
if (result) return result;
@ -408,7 +410,7 @@ export function suffixResolve(
// Fallback: linear scan (for backward compatibility)
for (let i = 0; i < pathParts.length; i++) {
const suffix = pathParts.slice(i).join('/');
for (const ext of EXTENSIONS) {
for (const ext of extensions) {
const suffixWithExt = suffix + ext;
const suffixPattern = '/' + suffixWithExt;
const matchIdx = normalizedFileList.findIndex(

View file

@ -14,7 +14,12 @@
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import { buildSuffixIndex, suffixResolve, type SuffixIndex } from '../../import-resolvers/utils.js';
import {
buildSuffixIndex,
LUA_EXTENSIONS,
suffixResolve,
type SuffixIndex,
} from '../../import-resolvers/utils.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { luaProvider } from '../lua.js';
import { emitLuaHeritageEdges } from './heritage.js';
@ -43,7 +48,7 @@ const luaScopeResolver: ScopeResolver = {
return undefined;
},
// require("a.b.c") → module path a/b/c (+ EXTENSIONS: .lua / /init.lua).
// require("a.b.c") → module path a/b/c (+ Lua extensions: .lua / /init.lua).
// targetRaw arrives quote-stripped (interpretLuaImport); Lua's module
// separator is `.`, so split on it before joining to a path.
resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => {
@ -60,11 +65,11 @@ const luaScopeResolver: ScopeResolver = {
// Once the suffix index exists, suffixResolve only consults the index;
// passing a spread copy here would still traverse the entire workspace on
// every require() despite the cache (#2909 contract).
return suffixResolve(parts, [], [], _cachedIndex ?? undefined);
return suffixResolve(parts, [], [], _cachedIndex ?? undefined, LUA_EXTENSIONS);
},
// Lua: default local-first-then-imports merge (no language-specific precedence).
mergeBindings: (existing) => [...existing],
mergeBindings: (existing, incoming) => [...existing, ...incoming],
// Lua varargs (...) + optional params make static arity checks unreliable —
// 'unknown' (no signal) is the safe minimal choice.

View file

@ -19,8 +19,10 @@ import {
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
import { SupportedLanguages, type BindingRef, type ScopeId } from 'gitnexus-shared';
import { emitLuaScopeCaptures } from '../../../src/core/ingestion/languages/lua/index.js';
import { collectLuaCaptureSideChannel } from '../../../src/core/ingestion/languages/lua/capture-side-channel.js';
import { luaScopeResolver } from '../../../src/core/ingestion/languages/lua/scope-resolver.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
@ -30,6 +32,32 @@ function writeFixtureRepo(root: string, files: Record<string, string>): void {
}
}
describe('Lua scope resolver binding merge', () => {
it('retains imported bindings when layering them onto existing bindings', () => {
const local = {
def: { nodeId: 'local', filePath: 'main.lua', type: 'Variable', qualifiedName: 'local' },
origin: 'local',
} satisfies BindingRef;
const imported = {
def: { nodeId: 'imported', filePath: 'lib.lua', type: 'Variable', qualifiedName: 'imported' },
origin: 'import',
} satisfies BindingRef;
expect(luaScopeResolver.mergeBindings([local], [imported], 'scope:main' as ScopeId)).toEqual([
local,
imported,
]);
expect(luaScopeResolver.language).toBe(SupportedLanguages.Lua);
});
});
describe('Lua scope resolver import extensions', () => {
it('prefers the Lua module when another language has the same module stem', () => {
const files = new Set(['main.lua', 'foo.ts', 'foo.lua']);
expect(luaScopeResolver.resolveImportTarget('foo', 'main.lua', files)).toBe('foo.lua');
});
});
// ---------------------------------------------------------------------------
// require("lib.util") + member call util.answer() across files
// ---------------------------------------------------------------------------

View file

@ -88,6 +88,12 @@ describe('TypeScript ESM .js extension resolution', () => {
expect(result).toBe('src/utils.ts');
});
it('does not resolve a JavaScript extensionless import to a Lua file', () => {
const ctx = makeCtx(['src/index.js', 'src/utils.lua']);
const result = resolve('src/index.js', './utils', SupportedLanguages.JavaScript, ctx);
expect(result).toBeNull();
});
it('does NOT apply ESM fallback for non-TS/JS languages', () => {
const ctx = makeCtx(['src/main.py', 'src/utils.ts']);
const result = resolve('src/main.py', './utils.js', SupportedLanguages.Python, ctx);

View file

@ -170,13 +170,27 @@ describe('parser-loader ABI load-smoke (#1922)', () => {
}
it('repeatedly creates and parses Lua parsers without stale scanner state', () => {
const grammar = getLanguageGrammar(SupportedLanguages.Lua);
let grammar: unknown;
try {
grammar = getLanguageGrammar(SupportedLanguages.Lua);
} catch (err) {
// Lua is a user-skippable vendored grammar. Its dedicated smoke must
// have the same unavailable-is-okay behavior as the matrix above.
expect(err).toBeInstanceOf(Error);
return;
}
const longEquals = '='.repeat(300);
for (let i = 0; i < 16; i += 1) {
const parser = new Parser();
parser.setLanguage(grammar as Parameters<Parser['setLanguage']>[0]);
const snippet = i % 2 === 0 ? 'local x = "lua"\n' : '-- comment\nlocal x = [[lua]]\n';
const snippet =
i % 2 === 0
? 'local x = "lua"\n'
: `-- comment\nlocal x = [${longEquals}[lua]${longEquals}]\n`;
const tree = parser.parse(snippet);
expect(tree.rootNode.type).toBe('chunk');
expect(tree.rootNode.hasError).toBe(false);
}
});
});

View file

@ -48,6 +48,12 @@ struct ScannerState
unsigned int depth;
};
// Keep the scanner state portable across parser checkpoints. The previous
// format stored depth in one byte, which truncated long-bracket delimiters
// with more than 255 '=' characters. Length 2 remains readable for old
// checkpoints; new checkpoints use a full 32-bit little-endian depth.
#define LUA_SCANNER_SERIALIZED_LENGTH 5
void *tree_sitter_lua_external_scanner_create()
{
return calloc(1, sizeof(struct ScannerState));
@ -62,17 +68,31 @@ unsigned int tree_sitter_lua_external_scanner_serialize(void *payload, char *buf
{
struct ScannerState *state = payload;
buffer[0] = state->started;
buffer[1] = state->depth;
return 2;
buffer[1] = (char)(state->depth & 0xffu);
buffer[2] = (char)((state->depth >> 8) & 0xffu);
buffer[3] = (char)((state->depth >> 16) & 0xffu);
buffer[4] = (char)((state->depth >> 24) & 0xffu);
return LUA_SCANNER_SERIALIZED_LENGTH;
}
void tree_sitter_lua_external_scanner_deserialize(void *payload, const char *buffer, unsigned int length)
{
struct ScannerState *state = payload;
state->started = 0;
state->depth = 0;
if (length == 2)
{
struct ScannerState *state = payload;
state->started = buffer[0];
state->depth = buffer[1];
state->started = (enum StartedToken)(unsigned char)buffer[0];
state->depth = (unsigned int)(unsigned char)buffer[1];
}
else if (length >= LUA_SCANNER_SERIALIZED_LENGTH)
{
state->started = (enum StartedToken)(unsigned char)buffer[0];
state->depth = (unsigned int)(unsigned char)buffer[1] |
((unsigned int)(unsigned char)buffer[2] << 8) |
((unsigned int)(unsigned char)buffer[3] << 16) |
((unsigned int)(unsigned char)buffer[4] << 24);
}
}