Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Lua and Luau function expressions assigned to locals, table members, or keyed table fields are now indexed as callable nodes. Calls from `local f = function() ... end`, `M.f = function() ... end`, and callback tables such as `M.handlers = { onClick = function() ... end }` are attributed to the named function or method instead of collapsing onto the file node, so callers and impact no longer omit these handlers. Re-index after upgrading. (#1616)

- Erlang functions that share a name but differ in arity are now separate symbols with the language's own `module:fun/arity` identity, so the everyday `f/1` delegating to `f/2` shows as a real call edge instead of a self-loop, each arity keeps its own `-spec` and source span, `-export([f/1])` marks exactly that arity as public, and asking `codegraph_explore` for a symbol the way Erlang spells it — `cowboy_req:header/3` — returns that definition. Re-index Erlang projects after upgrading. Thanks @Dshuishui. (#1610) (Erlang)

- Erlang behaviour dispatch no longer miscounts a call site's arity when an argument is a binary literal like `<<1,2,3>>` — the commas inside were counted as argument separators, which silently dropped (or could mislink) the dispatch edge to the behaviour callback. (#1358) (Erlang)
Expand Down
52 changes: 52 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8486,6 +8486,58 @@ function M:send(data) return self end
const send = methods.find((m) => m.name === 'send');
expect(send?.qualifiedName).toBe('M::send');
});

it('should name function expressions from local, member, and table-field bindings', () => {
const code = `
local function helper() return 1 end
local localFn = function() return helper() end
local M = {
callbacks = {
onStart = function() return helper() end,
["onStop"] = function() return helper() end,
[DYNAMIC] = function() return helper() end,
},
}
M.assignedFn = function() return helper() end
M["bracketFn"] = function() return helper() end
localFn()
`;
const result = extractFromSource('handlers.lua', code);
const localFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'localFn');
const assignedFn = result.nodes.find(
(n) => n.kind === 'method' && n.qualifiedName === 'M::assignedFn'
);
const onStart = result.nodes.find(
(n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStart'
);
const onStop = result.nodes.find(
(n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStop'
);
const bracketFn = result.nodes.find(
(n) => n.kind === 'method' && n.qualifiedName === 'M::bracketFn'
);

expect(localFn).toBeDefined();
expect(assignedFn).toBeDefined();
expect(onStart).toBeDefined();
expect(onStop).toBeDefined();
expect(bracketFn).toBeDefined();
expect(result.nodes.some((n) => n.name === 'DYNAMIC')).toBe(false);
expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'localFn')).toBe(false);

for (const callable of [localFn, assignedFn, onStart, onStop, bracketFn]) {
expect(
result.unresolvedReferences.some(
(r) => r.fromNodeId === callable!.id && r.referenceKind === 'calls' && r.referenceName === 'helper'
)
).toBe(true);
}
expect(
result.unresolvedReferences.some(
(r) => r.referenceKind === 'calls' && r.referenceName === 'localFn'
)
).toBe(true);
});
});

describe('Variable extraction', () => {
Expand Down
11 changes: 10 additions & 1 deletion __tests__/fixtures/kernel-parity/torture.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ local function localFn(...)
return select("#", ...)
end

-- doc for anonAssigned (variable, initializer invisible)
-- doc for anonAssigned (function named from its local binding)
local anonAssigned = function(v)
return hidden(v)
end
Expand Down Expand Up @@ -68,6 +68,15 @@ M.assigned = function(z)
return topFn(z)
end

M.callbacks = {
on_start = function()
return topFn(17)
end,
["on_stop"] = function()
return topFn(18)
end,
}

M.handlers = { on_start = topFn, on_stop = localFn, skipped = missing }
local tbl = { cb = topFn, [1] = localFn, nested = { deep_cb = topFn } }

Expand Down
16 changes: 16 additions & 0 deletions __tests__/kernel-lua-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ describe.skipIf(!kernelBuilt)('kernel Lua/Luau extraction parity', () => {
// lua functions carry NO isExported (undefined — not false).
const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'topFn');
expect(fn?.isExported).toBeUndefined();
expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'anonAssigned')).toBe(true);
expect(result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M::assigned')).toBe(true);
expect(
result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::on_start')
).toBe(true);
expect(
result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::on_stop')
).toBe(true);
for (const qualifiedName of ['M::assigned', 'M.callbacks::on_start', 'M.callbacks::on_stop']) {
const callable = result.nodes.find((n) => n.qualifiedName === qualifiedName)!;
expect(
refs.some(
(r) => r.fromNodeId === callable.id && r.referenceKind === 'calls' && r.referenceName === 'topFn'
)
).toBe(true);
}
// variables DO carry isExported === false.
const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'core');
expect(v?.isExported).toBe(false);
Expand Down
26 changes: 26 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2001,6 +2001,32 @@ func main() {
});
});

describe('Lua function-expression resolution (#1616)', () => {
it('attributes helper calls to each assigned callable instead of the file node', async () => {
fs.writeFileSync(
path.join(tempDir, 'util.lua'),
`util = {}\nfunction util.helper() return 1 end\nreturn util\n`
);
fs.writeFileSync(
path.join(tempDir, 'handlers.lua'),
`local M = {}\nfunction M.namedFn() return util.helper() end\nM.assignedFn = function() return util.helper() end\nM.callbacks = { onStart = function() return util.helper() end }\nreturn M\n`
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const helper = cg
.getNodesByKind('method')
.find((n) => n.qualifiedName === 'util::helper');
expect(helper).toBeDefined();
const callers = cg.getCallers(helper!.id).map((c) => c.node);
expect(callers.some((n) => n.qualifiedName === 'M::namedFn')).toBe(true);
expect(callers.some((n) => n.qualifiedName === 'M::assignedFn')).toBe(true);
expect(callers.some((n) => n.qualifiedName === 'M.callbacks::onStart')).toBe(true);
expect(callers.some((n) => n.kind === 'file' && n.filePath === 'handlers.lua')).toBe(false);
});
});

describe('Watchdog-safe resolution on collision-heavy repos (#1122)', () => {
// On a large Java-style repo, per-ref resolution cost is unbounded in the
// worst case (a colliding method name whose candidate set misses the LRU
Expand Down
144 changes: 132 additions & 12 deletions codegraph-kernel/src/lua.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ impl<'t> Walker<'t> {
}
// plain path returns false → children re-visited (the
// typeof(require(...)) alias+import pair rides this).
} else if kind == "variable_declaration" {
} else if matches!(kind, "variable_declaration" | "assignment_statement") {
self.extract_variable(node);
// Initializer subtrees are never walked — candidates only.
self.scan_fn_ref_subtree(node, 0);
Expand Down Expand Up @@ -578,21 +578,38 @@ impl<'t> Walker<'t> {
}
None => Vec::new(),
};
let names: Vec<Node<'t>> = match var_list {
let targets: Vec<Node<'t>> = match var_list {
Some(vl) => {
let mut c = vl.walk();
vl.named_children(&mut c).filter(|n| n.kind() == "identifier").collect()
vl.named_children(&mut c).collect()
}
None => Vec::new(),
};
for (i, name_node) in names.iter().enumerate() {
let name = self.text(*name_node);
if name.is_empty() {
for (i, name_node) in targets.iter().enumerate() {
let Some((name, receiver, full_name)) = self.lua_assignment_target(*name_node) else {
continue;
};
let value = values.get(i).copied();
if let Some(value) = value {
if value.kind() == "function_definition" {
self.extract_lua_function_value(
value,
name,
receiver,
docstring.clone(),
);
continue;
}
if value.kind() == "table_constructor" {
self.extract_lua_table_functions(value, full_name);
}
}
// Dotted assignments update table members, not standalone vars.
if receiver.is_some() || node.kind() == "assignment_statement" {
continue;
}
// Positional value pairing; a missing value → NO signature key.
let signature = values.get(i).map(|v| util::init_signature(self.text(*v)));
let name = name.to_string();
let signature = value.map(|v| util::init_signature(self.text(v)));
self.create_node(
"variable",
&name,
Expand All @@ -607,6 +624,110 @@ impl<'t> Walker<'t> {
}
}

fn lua_assignment_target(&self, node: Node<'t>) -> Option<(String, Option<String>, String)> {
if node.kind() == "identifier" {
let name = self.text(node).trim().to_string();
if name.is_empty() {
return None;
}
return Some((name.clone(), None, name));
}
if !matches!(
node.kind(),
"dot_index_expression" | "method_index_expression" | "bracket_index_expression"
) {
return None;
}
let table = node.child_by_field_name("table")?;
let field = node
.child_by_field_name("field")
.or_else(|| node.child_by_field_name("method"))?;
let receiver = self.text(table).trim().to_string();
let name = self.lua_static_field_name(field, node.kind() == "bracket_index_expression");
if receiver.is_empty() || name.is_empty() {
return None;
}
let full_name = format!("{receiver}.{name}");
Some((name, Some(receiver), full_name))
}

fn lua_static_field_name(&self, node: Node<'t>, bracketed: bool) -> String {
if node.kind() == "identifier" {
return if bracketed {
String::new()
} else {
self.text(node).trim().to_string()
};
}
if node.kind() == "string" {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if child.kind() == "string_content" {
return self.text(child).trim().to_string();
}
}
}
String::new()
}

fn extract_lua_function_value(
&mut self,
node: Node<'t>,
name: String,
receiver: Option<String>,
docstring: Option<String>,
) {
let signature = self.signature_of(node);
let (kind, qualified_name_override, is_exported) = match receiver {
Some(receiver) => (
"method",
Some(format!("{receiver}::{name}")),
None,
),
None => ("function", None, self.is_exported_of(node)),
};
let row = self.create_node(
kind,
&name,
node,
Extra {
docstring,
signature,
qualified_name_override,
is_exported,
..Default::default()
},
);
let Some(row) = row else { return };
self.stack.push(Scope { row, kind, name });
if let Some(body) = node.child_by_field_name("body") {
self.visit_body(body);
}
self.stack.pop();
}

fn extract_lua_table_functions(&mut self, table: Node<'t>, receiver: String) {
let mut cursor = table.walk();
let fields: Vec<Node<'t>> = table.named_children(&mut cursor).collect();
for field in fields {
if field.kind() != "field" {
continue;
}
let Some(name_node) = field.child_by_field_name("name") else { continue };
let Some(value) = field.child_by_field_name("value") else { continue };
let bracketed = self.text(field).trim_start().starts_with('[');
let name = self.lua_static_field_name(name_node, bracketed);
if name.is_empty() {
continue;
}
if value.kind() == "function_definition" {
self.extract_lua_function_value(value, name, Some(receiver.clone()), None);
} else if value.kind() == "table_constructor" {
self.extract_lua_table_functions(value, format!("{receiver}.{name}"));
}
}
}

// --- extractTypeAlias (2890; plain path 2967-2991) — luau only --------

/// Returns skipChildren (always false on the plain path).
Expand Down Expand Up @@ -790,13 +911,12 @@ impl<'t> Walker<'t> {
return;
}
// Halt at nested function definitions (their bodies are walked — and
// attributed — by extractFunction). function_definition (anonymous)
// is deliberately NOT in the halt list — the scan descends into
// anonymous initializer bodies, attributing candidates to the file.
// attributed — by extractFunction). Lua function_definition values are
// now extracted from their assignment target and must stop this scan too.
if depth > 0
&& matches!(
node.kind(),
"function_declaration" | "arrow_function" | "function_expression"
"function_declaration" | "function_definition" | "arrow_function" | "function_expression"
| "lambda_literal" | "lambda_expression"
)
{
Expand Down
2 changes: 1 addition & 1 deletion src/extraction/extraction-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 25;
export const EXTRACTION_VERSION = 26;
5 changes: 4 additions & 1 deletion src/extraction/languages/lua.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ export const luaExtractor: LanguageExtractor = {
typeAliasTypes: [],
importTypes: [], // `require` is a function_call — handled in visitNode below
callTypes: ['function_call'],
variableTypes: ['variable_declaration'], // see the `lua` branch in extractVariable
// Top-level assignments can introduce module members just as declarations do:
// `M.run = function() ... end`. The Lua branch in extractVariable ignores
// non-callable member assignments, but extracts function-valued targets.
variableTypes: ['variable_declaration', 'assignment_statement'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
Expand Down
Loading