fix(lua): address latest PR review findings

This commit is contained in:
taoxin 2026-08-24 16:44:03 +08:00
parent 94fce75003
commit a5611a3f86
8 changed files with 108 additions and 12 deletions

View file

@ -11,8 +11,8 @@
* ruby, rust, php, kotlin, swift, dart
* - experimental: vue (embedded-language / SFC complexity),
* cobol (regex-provider path),
* lua (definition-only legacy DAG path; scope-resolution
* hooks pending Phase B)
* lua (scope-resolution provider is active; broader
* language-contract coverage is still experimental)
* - quarantined: (none)
*/

View file

@ -55,7 +55,7 @@ export const EXTENSIONS = [
];
/** Lua module extensions used only by Lua import resolvers. */
export const LUA_EXTENSIONS = ['', '.lua', '/init.lua'] as const;
export const LUA_EXTENSIONS = ['.lua', '/init.lua'] as const;
/**
* Try to match a path (with extensions) against the known file set.

View file

@ -19,14 +19,21 @@
*/
import type { CaptureMatch, ParsedImport } from 'gitnexus-shared';
function stripLuaString(s: string): string {
const long = s.match(/^\[(=*)\[([\s\S]*)\]\1\]$/);
return long ? long[2] : stripQuotes(s);
}
function stripQuotes(s: string): string {
return s.replace(/^["']|["']$/g, '');
const quoted = s.match(/^(?:(["'])([\s\S]*)\1|\[(=*)\]([\s\S]*)\]\3\])$/);
if (!quoted) return s;
return quoted[2] ?? quoted[4] ?? '';
}
export function interpretLuaImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source']?.text;
if (source === undefined) return null;
const targetRaw = stripQuotes(source);
const targetRaw = stripLuaString(source);
if (!targetRaw) return null;
const localName = captures['@import.localName']?.text;
if (localName) {

View file

@ -155,6 +155,23 @@ export const inferCallForm = (callNode: SyntaxNode, nameNode: SyntaxNode): CallF
return 'member';
}
// Lua tree-sitter represents `obj:method()` / `obj.field()` as a `call`
// whose `function` child is a `variable` carrying `table` plus `method` or
// `field`; unlike the other grammars, the captured name is not wrapped in a
// member-access node.
if (callNode.type === 'call') {
const callee = callNode.childForFieldName('function');
const table = callNode.childForFieldName('table') ?? callee?.childForFieldName('table');
const member =
callNode.childForFieldName('method') ??
callNode.childForFieldName('field') ??
callee?.childForFieldName('method') ??
callee?.childForFieldName('field');
if (callee?.type === 'variable' && table && member) {
return 'member';
}
}
// 5. Scoped calls (Rust Foo::new(), C++ ns::func()): treat as free
// The receiver is a type, not an instance — handled differently in Phase 3
if (nameParent && SCOPED_CALL_NODE_TYPES.has(nameParent.type)) {
@ -226,6 +243,14 @@ export const extractReceiverName = (nameNode: SyntaxNode): string | undefined =>
receiver = parent.childForFieldName('receiver');
}
// Lua: the receiver is the `table` field of the call's `function` variable.
if (!receiver && callNode.type === 'call') {
const callee = callNode.childForFieldName('function');
if (callee?.type === 'variable') {
receiver = callNode.childForFieldName('table') ?? callee.childForFieldName('table');
}
}
// PHP scoped_call_expression (parent::method(), self::method()):
// nameNode's direct parent IS the scoped_call_expression (name is a direct child)
if (
@ -274,6 +299,12 @@ export const extractReceiverName = (nameNode: SyntaxNode): string | undefined =>
if (!receiver) return undefined;
// Lua colon calls wrap a simple receiver in a `variable` node.
if (receiver.type === 'variable') {
const name = receiver.childForFieldName('name');
if (name?.type === 'identifier') return name.text;
}
// Only capture simple identifiers — refuse complex expressions
if (SIMPLE_RECEIVER_TYPES.has(receiver.type)) {
return receiver.text;
@ -328,6 +359,12 @@ export const extractReceiverNode = (nameNode: SyntaxNode): SyntaxNode | undefine
receiver = parent.childForFieldName('receiver');
}
// Lua: the receiver is the `table` field of the call's `function` variable.
if (!receiver && callNode.type === 'call') {
const callee = callNode.childForFieldName('function');
if (callee?.type === 'variable') receiver = callNode.childForFieldName('table');
}
if (
!receiver &&
(parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')

View file

@ -23,6 +23,7 @@ import { SupportedLanguages, type BindingRef, type ScopeId } from 'gitnexus-shar
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';
import { interpretLuaImport } from '../../../src/core/ingestion/languages/lua/interpret.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
@ -56,6 +57,22 @@ describe('Lua scope resolver import extensions', () => {
const files = new Set(['main.lua', 'foo.ts', 'foo.lua']);
expect(luaScopeResolver.resolveImportTarget('foo', 'main.lua', files)).toBe('foo.lua');
});
it('does not bind an extensionless collision ahead of the Lua module', () => {
const files = new Set(['main.lua', 'foo', 'foo.lua']);
expect(luaScopeResolver.resolveImportTarget('foo', 'main.lua', files)).toBe('foo.lua');
});
it('unwraps quoted and long-bracket require sources', () => {
const sources = ['"lib.util"', "'lib.util'", '[[lib.util]]', '[=[lib.util]=]'];
for (const source of sources) {
expect(
interpretLuaImport({
'@import.source': { text: source },
} as never),
).toEqual({ kind: 'wildcard', targetRaw: 'lib.util' });
}
});
});
// ---------------------------------------------------------------------------
@ -128,6 +145,23 @@ describe('Lua scope: bare require import', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 60000);
it('resolves a long-bracket require source in the graph', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-long-require-'));
try {
writeFixtureRepo(tmpDir, {
'lib/util.lua': 'return {}\n',
'main.lua': 'require([[lib.util]])\n',
});
const result = await runPipelineFromRepo(tmpDir, () => {});
const imports = getRelationships(result, 'IMPORTS').filter(
(e) => e.sourceFilePath?.includes('main.lua') && e.targetFilePath?.includes('util.lua'),
);
expect(imports).toHaveLength(1);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 60000);
});
// ---------------------------------------------------------------------------

View file

@ -20,6 +20,7 @@ import { getProvider } from '../../src/core/ingestion/languages/index.js';
// Vendored grammar — loaded from vendor/ by absolute path, never node_modules (#2111).
const Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
const Lua = requireVendoredGrammar('tree-sitter-lua');
/**
* Helper: parse code, run the language query, and return all @call captures
@ -97,6 +98,26 @@ describe('inferCallForm', () => {
});
});
describe('Lua', () => {
it('detects colon member calls', () => {
parser.setLanguage(Lua);
const captures = extractCallCaptures(parser, 'util:answer()', SupportedLanguages.Lua);
const match = captures.find((c) => c.calledName === 'answer');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
expect(extractReceiverName(match!.nameNode)).toBe('util');
});
it('detects dot member calls', () => {
parser.setLanguage(Lua);
const captures = extractCallCaptures(parser, 'util.answer()', SupportedLanguages.Lua);
const match = captures.find((c) => c.calledName === 'answer');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
expect(extractReceiverName(match!.nameNode)).toBe('util');
});
});
describe('Java', () => {
it('detects free call (no object)', () => {
parser.setLanguage(Java);

View file

@ -32,7 +32,7 @@ describe('isLanguageAvailable', () => {
if (isGrammarRuntimeSkipped(SupportedLanguages.Lua)) {
expect(available).toBe(false);
} else {
expect(typeof available).toBe('boolean');
expect(available).toBe(true);
}
});

View file

@ -203,13 +203,10 @@ describe('parser-loader ABI load-smoke (#1922)', () => {
return;
}
const crlf = String.fromCharCode(13, 10);
const snippets = [
String.raw`local value = "line\
continued"
`,
String.raw`local value = 'line\
continued'
`,
`local value = "line\\${crlf}continued"${crlf}`,
`local value = 'line\\${crlf}continued'${crlf}`,
];
for (const snippet of snippets) {
const parser = new Parser();