fix(lua): preserve callable binding positions

This commit is contained in:
taoxin 2026-09-23 16:54:12 +08:00
parent bff8f59153
commit 151bb7865f
4 changed files with 70 additions and 23 deletions

View file

@ -1,13 +1,15 @@
/**
* Optional grammar availability check.
*
* tree-sitter-dart, -proto, -swift, -kotlin, -zig, and -lua are vendored under
* tree-sitter-dart, -proto, -swift, -kotlin, and -zig are vendored under
* vendor/ and loaded from there by absolute path (NEVER copied into
* node_modules — see core/tree-sitter/vendored-grammars.ts / #2111). Each
* ships committed platform prebuilds activated via node-gyp-build. All can
* be skipped via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or
* can silently soft-fail when no prebuild matches the host platform (and a
* source build was unavailable / not attempted).
* ships committed platform prebuilds activated via node-gyp-build.
* tree-sitter-lua is also vendored, but is source-built during install because
* it does not currently ship committed prebuilds. All can be skipped via
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently
* soft-fail when no prebuild matches the host platform (and a source build was
* unavailable / not attempted).
*
* Either path produces the same observable: the .node binding is absent
* at runtime. This helper detects that condition and surfaces a single

View file

@ -166,6 +166,56 @@ function collectLuaImportCaptures(root: Parser.SyntaxNode): readonly CaptureMatc
return out;
}
/**
* Capture callable local bindings by source/destination position.
*
* A tree-sitter query that independently matches `variable_list` and a
* function-valued `expression_list` cross-pairs Lua multi-assignment:
* `local value, callback = 1, function() end` must name only `callback` as a
* closure. Keep the positional pairing in the same structural walk used by
* import capture collection.
*/
function collectLuaCallableBindingCaptures(root: Parser.SyntaxNode): readonly CaptureMatch[] {
const out: CaptureMatch[] = [];
const visit = (node: Parser.SyntaxNode): void => {
if (node.type === 'local_variable_declaration') {
const variables =
node.namedChildren.find((child) => child.type === 'variable_list')?.namedChildren ?? [];
const sources =
node.namedChildren.find((child) => child.type === 'expression_list')?.namedChildren ?? [];
const count = Math.min(variables.length, sources.length);
for (let index = 0; index < count; index++) {
const destination = variables[index];
const source = sources[index];
const name = destination?.childForFieldName('name');
if (
destination?.type !== 'variable' ||
destination.childForFieldName('table') !== null ||
destination.childForFieldName('field') !== null ||
name?.type !== 'identifier' ||
source === undefined
) {
continue;
}
const declarationKind =
source.type === 'function_definition'
? '@declaration.function'
: source.type === 'variable'
? '@declaration.variable'
: undefined;
if (declarationKind === undefined) continue;
out.push({
[declarationKind]: nodeToCapture(declarationKind, node),
'@declaration.name': nodeToCapture('@declaration.name', name),
});
}
}
for (const child of node.namedChildren) visit(child);
};
visit(root);
return out;
}
function collectLuaReturnedNames(root: Parser.SyntaxNode): readonly string[] {
const returnedNames: string[] = [];
for (const node of root.namedChildren) {
@ -367,6 +417,8 @@ export function emitLuaScopeCaptures(
out.push(grouped);
}
out.push(...collectLuaCallableBindingCaptures(tree.rootNode));
// Imports are collected structurally instead of through the generic query:
// the AST lists preserve positional local/RHS pairing and support all Lua
// string-call forms without producing a cross-product of captures.

View file

@ -3,8 +3,8 @@
*
* Captures the structural skeleton the central ScopeExtractor consumes:
* - scopes: (chunk) root + function bodies
* - declarations: functions, local callable/value bindings, and methods
* (function Obj:m())
* - declarations: functions and methods (function Obj:m())
* (local callable/value bindings are captured structurally in captures.ts)
* - imports: Lua module-loading calls — the call + its string arg
* - references: free calls foo() + member calls obj:m() / obj.f()
*
@ -38,20 +38,6 @@ const LUA_SCOPE_QUERY = `
(local_function_definition_statement
name: (identifier) @declaration.name) @declaration.function
;; Local callable bindings need a SymbolDefinition before the shared
;; callable-flow pass can attach alias/formal/argument facts to their cells.
;; Keep ordinary variable aliases as Variable nodes, while closure bindings
;; below are Functions so the anonymous-callable convention can resolve them.
(local_variable_declaration
(variable_list
(variable name: (identifier) @declaration.name))) @declaration.variable
(local_variable_declaration
(variable_list
(variable name: (identifier) @declaration.name))
(expression_list
value: (function_definition))) @declaration.function
;; ── Declarations — methods: function Obj:method() / function Obj.field() ────
;; name is a (variable) with table + method (colon) or table + field (dot).
(function_definition_statement

View file

@ -202,7 +202,7 @@ describe('Lua local callable declarations', () => {
`function target() end
function entry()
local alias = target
local callback = function() end
local value, callback = 1, function() end
callback()
end
`,
@ -218,10 +218,17 @@ end
expect(
captures.some(
(match) =>
match['@declaration.function']?.text === 'local callback = function() end' &&
match['@declaration.function']?.text === 'local value, callback = 1, function() end' &&
match['@declaration.name']?.text === 'callback',
),
).toBe(true);
expect(
captures.some(
(match) =>
match['@declaration.function']?.text === 'local value, callback = 1, function() end' &&
match['@declaration.name']?.text === 'value',
),
).toBe(false);
expect(
captures.some(
(match) =>