Skip to content

Commit 75978e2

Browse files
committed
fix: recognize Lua function nodes in complexity metrics computation (docs check acknowledged)
codegraph complexity --file <f> --health -T --json returned functions:[] for every Lua file even though symbol extraction (codegraph where) already worked correctly. Root cause: Lua had zero entries in COMPLEXITY_RULES / HALSTEAD_RULES (src/ast-analysis/rules/index.ts) and the native mirror (lang_rules / halstead_rules in crates/codegraph-core/src/ast_analysis/ complexity.rs) — not a wrong node-type mapping, but a completely missing per-language configuration on both engines, so the complexity visitor never ran for Lua at all. Adds complexityLua/halsteadLua (src/ast-analysis/rules/b3.ts, wired into COMPLEXITY_RULES/HALSTEAD_RULES in rules/index.ts) and the matching LUA_RULES/LUA_HALSTEAD (crates/codegraph-core/src/ast_analysis/ complexity.rs, wired into lang_rules/halstead_rules), derived from the real tree-sitter-lua grammar shape (verified by parsing probe snippets with both the WASM grammar and the native tree-sitter-lua crate): elseif_statement/ else_statement are flat siblings of if_statement via repeated `alternative:` fields (same shape as Python's elif_clause/else_clause), and Lua's single generic binary_expression node covers arithmetic/comparison/logical operators alike, filtered by operator token. function_declaration matches the same node type the existing extractor and dataflowLua rules already use, so complexity results attach to the same definitions `where` reports. Also adds a Lua entry to the LOC comment-prefix maps (`--`) on both engines, which were defaulting to C-style prefixes and undercounting comment lines. Verified against all 5 Lua files in the repo (tracer + 4 fixtures) via both engines. Filed #1922 (WASM-only parity gap for files where every function has a dot-qualified name, e.g. Lua's `M.foo` module pattern) and #1923 (the same missing-complexity-config gap affects 24 other languages) as separate follow-ups rather than expanding this fix. Fixes #1782
1 parent d250f8d commit 75978e2

5 files changed

Lines changed: 360 additions & 5 deletions

File tree

crates/codegraph-core/src/ast_analysis/complexity.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,44 @@ pub static BASH_RULES: LangRules = LangRules {
427427
switch_like_nodes: &["case_statement"],
428428
};
429429

430+
// Lua's `if_statement` is flat, not nested: `elseif`/`else` are separate node
431+
// kinds (`elseif_statement`, `else_statement`) attached to the *same*
432+
// `if_statement` via repeated `alternative:` fields — confirmed by parsing
433+
// `if a then .. elseif b then .. else .. end` with tree-sitter-lua and
434+
// inspecting the S-expression. Structurally identical to Python's
435+
// elif_clause/else_clause (Pattern B), not JS's nested else_clause>if_statement
436+
// (Pattern A) or Go's alternative-holds-nested-if (Pattern C) — so
437+
// else_via_alternative: false, and neither elseif_statement nor else_statement
438+
// is in nesting_nodes (they're siblings of the primary if, not separately
439+
// nested branches). Mirrors `complexityLua` in `src/ast-analysis/rules/b3.ts`.
440+
//
441+
// binary_expression is Lua's single generic binary-op node (arithmetic,
442+
// comparison, concat, AND logical `and`/`or`) — same shared-type pattern as
443+
// Ruby's `binary` node; handle_logical_op only fires when `node.child(1)` (the
444+
// operator token) is in logical_operators, so comparisons/arithmetic sharing
445+
// the same node kind are correctly ignored.
446+
pub static LUA_RULES: LangRules = LangRules {
447+
branch_nodes: &[
448+
"if_statement",
449+
"elseif_statement",
450+
"else_statement",
451+
"for_statement",
452+
"while_statement",
453+
"repeat_statement",
454+
],
455+
case_nodes: &[],
456+
logical_operators: &["and", "or"],
457+
logical_node_types: &["binary_expression"],
458+
optional_chain_type: None,
459+
nesting_nodes: &["if_statement", "for_statement", "while_statement", "repeat_statement"],
460+
function_nodes: &["function_declaration"],
461+
if_node_type: Some("if_statement"),
462+
else_node_type: Some("else_statement"),
463+
elif_node_type: Some("elseif_statement"),
464+
else_via_alternative: false,
465+
switch_like_nodes: &[],
466+
};
467+
430468
/// Look up complexity rules by language ID (matches `COMPLEXITY_RULES` keys in JS).
431469
pub fn lang_rules(lang_id: &str) -> Option<&'static LangRules> {
432470
match lang_id {
@@ -444,6 +482,7 @@ pub fn lang_rules(lang_id: &str) -> Option<&'static LangRules> {
444482
"swift" => Some(&SWIFT_RULES),
445483
"scala" => Some(&SCALA_RULES),
446484
"bash" => Some(&BASH_RULES),
485+
"lua" => Some(&LUA_RULES),
447486
_ => None,
448487
}
449488
}
@@ -1022,6 +1061,34 @@ pub static BASH_HALSTEAD: HalsteadRules = HalsteadRules {
10221061
skip_types: &[],
10231062
};
10241063

1064+
// Member/method access (`dot_index_expression` `.`, `method_index_expression`
1065+
// `:`) and invocation (`function_call`) are wrapper nodes without a dedicated
1066+
// "call happened" token, so they're counted as compound operators — mirrors
1067+
// Python's ["call", "subscript", "attribute"]. The '.'/':' separator tokens
1068+
// are ALSO in operator_leaf_types (matching Python counting both "attribute"
1069+
// and "."), so member access contributes two distinct operator kinds,
1070+
// consistent with the other languages above. Mirrors `halsteadLua` in
1071+
// `src/ast-analysis/rules/b3.ts`.
1072+
pub static LUA_HALSTEAD: HalsteadRules = HalsteadRules {
1073+
operator_leaf_types: &[
1074+
"+", "-", "*", "/", "//", "%", "^", "#", "..",
1075+
"==", "~=", "<=", ">=", "<", ">", "=",
1076+
"and", "or", "not",
1077+
"&", "|", "~", "<<", ">>",
1078+
".", ",", ":", "::", ";",
1079+
"if", "then", "else", "elseif", "end",
1080+
"for", "while", "do", "repeat", "until",
1081+
"function", "local", "return", "break", "goto", "in",
1082+
],
1083+
operand_leaf_types: &[
1084+
"identifier", "number", "string_content", "true", "false", "nil", "...",
1085+
],
1086+
compound_operators: &[
1087+
"function_call", "bracket_index_expression", "dot_index_expression", "method_index_expression",
1088+
],
1089+
skip_types: &[],
1090+
};
1091+
10251092
/// Look up Halstead rules by language ID.
10261093
pub fn halstead_rules(lang_id: &str) -> Option<&'static HalsteadRules> {
10271094
match lang_id {
@@ -1039,6 +1106,7 @@ pub fn halstead_rules(lang_id: &str) -> Option<&'static HalsteadRules> {
10391106
"swift" => Some(&SWIFT_HALSTEAD),
10401107
"scala" => Some(&SCALA_HALSTEAD),
10411108
"bash" => Some(&BASH_HALSTEAD),
1109+
"lua" => Some(&LUA_HALSTEAD),
10421110
_ => None,
10431111
}
10441112
}
@@ -1056,6 +1124,7 @@ pub fn comment_prefixes(lang_id: &str) -> &'static [&'static str] {
10561124
"swift" => &["//", "/*"],
10571125
"scala" => &["//", "/*"],
10581126
"bash" => &["#"],
1127+
"lua" => &["--"],
10591128
_ => &["//", "/*", "*", "*/"],
10601129
}
10611130
}
@@ -1604,4 +1673,88 @@ mod tests {
16041673
assert_eq!(m.cognitive, 1);
16051674
assert_eq!(m.cyclomatic, 2);
16061675
}
1676+
1677+
// ─── Lua tests (issue #1782) ────────────────────────────────────────────
1678+
1679+
fn compute_lua(code: &str) -> ComplexityMetrics {
1680+
let mut parser = Parser::new();
1681+
parser
1682+
.set_language(&tree_sitter_lua::LANGUAGE.into())
1683+
.unwrap();
1684+
let tree = parser.parse(code.as_bytes(), None).unwrap();
1685+
let root = tree.root_node();
1686+
let func = find_first_function(&root, &LUA_RULES).expect("no function found");
1687+
compute_function_complexity(&func, &LUA_RULES)
1688+
}
1689+
1690+
#[test]
1691+
fn lua_empty_function() {
1692+
let m = compute_lua("local function f() end");
1693+
assert_eq!(m.cognitive, 0);
1694+
assert_eq!(m.cyclomatic, 1);
1695+
assert_eq!(m.max_nesting, 0);
1696+
}
1697+
1698+
#[test]
1699+
fn lua_single_if() {
1700+
let m = compute_lua("local function f(x)\n if x > 0 then\n return 1\n end\nend");
1701+
assert_eq!(m.cognitive, 1);
1702+
assert_eq!(m.cyclomatic, 2);
1703+
assert_eq!(m.max_nesting, 1);
1704+
}
1705+
1706+
#[test]
1707+
fn lua_if_elseif_else() {
1708+
// elseif_statement/else_statement are siblings of if_statement via
1709+
// repeated `alternative:` fields (Pattern B, like Python's elif/else).
1710+
let m = compute_lua(
1711+
"local function f(x)\n if x > 0 then\n return 1\n elseif x < 0 then\n return -1\n else\n return 0\n end\nend",
1712+
);
1713+
// if: +1 cog, +1 cyc; elseif: +1 cog, +1 cyc; else: +1 cog
1714+
assert_eq!(m.cognitive, 3);
1715+
assert_eq!(m.cyclomatic, 3);
1716+
assert_eq!(m.max_nesting, 1);
1717+
}
1718+
1719+
#[test]
1720+
fn lua_numeric_for_loop() {
1721+
let m = compute_lua("local function f()\n for i = 1, 10 do\n print(i)\n end\nend");
1722+
assert_eq!(m.cognitive, 1);
1723+
assert_eq!(m.cyclomatic, 2);
1724+
assert_eq!(m.max_nesting, 1);
1725+
}
1726+
1727+
#[test]
1728+
fn lua_while_loop() {
1729+
let m = compute_lua("local function f(n)\n while n > 0 do\n n = n - 1\n end\nend");
1730+
assert_eq!(m.cognitive, 1);
1731+
assert_eq!(m.cyclomatic, 2);
1732+
assert_eq!(m.max_nesting, 1);
1733+
}
1734+
1735+
#[test]
1736+
fn lua_repeat_until_loop() {
1737+
let m = compute_lua("local function f(n)\n repeat\n n = n - 1\n until n <= 0\nend");
1738+
assert_eq!(m.cognitive, 1);
1739+
assert_eq!(m.cyclomatic, 2);
1740+
assert_eq!(m.max_nesting, 1);
1741+
}
1742+
1743+
#[test]
1744+
fn lua_logical_operators() {
1745+
let m = compute_lua("local function f(a, b)\n if a and b then\n return 1\n end\nend");
1746+
// if: +1 cog, +1 cyc; and: +1 cog, +1 cyc
1747+
assert_eq!(m.cognitive, 2);
1748+
assert_eq!(m.cyclomatic, 3);
1749+
}
1750+
1751+
#[test]
1752+
fn lua_nested_if() {
1753+
let m = compute_lua(
1754+
"local function f(x, y)\n if x > 0 then\n if y > 0 then\n return 1\n end\n end\nend",
1755+
);
1756+
assert_eq!(m.cognitive, 3);
1757+
assert_eq!(m.cyclomatic, 3);
1758+
assert_eq!(m.max_nesting, 2);
1759+
}
16071760
}

src/ast-analysis/metrics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const COMMENT_PREFIXES = new Map<string, string[]>([
7171
['python', ['#']],
7272
['ruby', ['#']],
7373
['php', ['//', '#', '/*', '*', '*/']],
74+
['lua', ['--']],
7475
]);
7576

7677
/**

src/ast-analysis/rules/b3.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DataflowRulesConfig } from '../../types.js';
1+
import type { ComplexityRules, DataflowRulesConfig, HalsteadRules } from '../../types.js';
22
import { makeDataflowRules } from '../shared.js';
33

44
// ─── Lua ──────────────────────────────────────────────────────────────────────
@@ -30,6 +30,118 @@ export const dataflowLua: DataflowRulesConfig = makeDataflowRules({
3030
memberPropertyField: 'field',
3131
});
3232

33+
// Lua's `if_statement` is flat, not nested: `elseif`/`else` are separate node
34+
// types (`elseif_statement`, `else_statement`) attached to the *same*
35+
// `if_statement` via repeated `alternative:` fields — confirmed by parsing
36+
// `if a then .. elseif b then .. else .. end` and inspecting the S-expression:
37+
// `(if_statement condition: (...) consequence: (...) alternative: (elseif_statement ...) alternative: (else_statement ...))`.
38+
// This is structurally identical to Python's elif_clause/else_clause pattern
39+
// (Pattern B), not JS's nested else_clause>if_statement (Pattern A) or Go's
40+
// alternative-field-holds-nested-if (Pattern C) — so elseViaAlternative: false
41+
// and neither elseif_statement nor else_statement is in nestingNodes (matching
42+
// how Python's elif_clause/else_clause are siblings of the primary if, not
43+
// separately-nested branches).
44+
//
45+
// binary_expression is Lua's single generic binary-op node (arithmetic,
46+
// comparison, concat, AND logical `and`/`or`) — same shared-type pattern as
47+
// Ruby's `binary` node. classifyLogicalOp/handleLogicalOperator only acts when
48+
// `node.child(1)` (the operator token) is in logicalOperators, so comparisons
49+
// and arithmetic on the same node type are correctly ignored.
50+
export const complexityLua: ComplexityRules = {
51+
branchNodes: new Set([
52+
'if_statement',
53+
'elseif_statement',
54+
'else_statement',
55+
'for_statement',
56+
'while_statement',
57+
'repeat_statement',
58+
]),
59+
caseNodes: new Set([]),
60+
logicalOperators: new Set(['and', 'or']),
61+
logicalNodeType: 'binary_expression',
62+
optionalChainType: null,
63+
nestingNodes: new Set(['if_statement', 'for_statement', 'while_statement', 'repeat_statement']),
64+
functionNodes: new Set(['function_declaration']),
65+
ifNodeType: 'if_statement',
66+
elseNodeType: 'else_statement',
67+
elifNodeType: 'elseif_statement',
68+
elseViaAlternative: false,
69+
switchLikeNodes: new Set([]),
70+
};
71+
72+
// Member/method access (`dot_index_expression` `.`, `method_index_expression`
73+
// `:`) and invocation (`function_call`) are wrapper nodes without a dedicated
74+
// "call happened" token, so they're counted as compound operators — mirrors
75+
// Python's ['call', 'subscript', 'attribute']. The '.'/':' separator tokens are
76+
// ALSO in operatorLeafTypes (matching Python counting both 'attribute' and
77+
// '.'), so member access contributes two distinct operator kinds, consistent
78+
// with the existing per-language precedent.
79+
export const halsteadLua: HalsteadRules = {
80+
operatorLeafTypes: new Set([
81+
'+',
82+
'-',
83+
'*',
84+
'/',
85+
'//',
86+
'%',
87+
'^',
88+
'#',
89+
'..',
90+
'==',
91+
'~=',
92+
'<=',
93+
'>=',
94+
'<',
95+
'>',
96+
'=',
97+
'and',
98+
'or',
99+
'not',
100+
'&',
101+
'|',
102+
'~',
103+
'<<',
104+
'>>',
105+
'.',
106+
',',
107+
':',
108+
'::',
109+
';',
110+
'if',
111+
'then',
112+
'else',
113+
'elseif',
114+
'end',
115+
'for',
116+
'while',
117+
'do',
118+
'repeat',
119+
'until',
120+
'function',
121+
'local',
122+
'return',
123+
'break',
124+
'goto',
125+
'in',
126+
]),
127+
operandLeafTypes: new Set([
128+
'identifier',
129+
'number',
130+
'string_content',
131+
'true',
132+
'false',
133+
'nil',
134+
'...',
135+
]),
136+
compoundOperators: new Set([
137+
'function_call',
138+
'bracket_index_expression',
139+
'dot_index_expression',
140+
'method_index_expression',
141+
]),
142+
skipTypes: new Set([]),
143+
};
144+
33145
// ─── R ────────────────────────────────────────────────────────────────────────
34146
//
35147
// R functions are defined as: `name <- function_definition` (binary_operator with `<-`).

src/ast-analysis/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const COMPLEXITY_RULES: Map<string, ComplexityRules> = new Map([
3131
['csharp', csharp.complexity],
3232
['ruby', ruby.complexity],
3333
['php', php.complexity],
34+
['lua', b3.complexityLua],
3435
]);
3536

3637
// ─── Halstead Rules ───────────────────────────────────────────────────────
@@ -46,6 +47,7 @@ export const HALSTEAD_RULES: Map<string, HalsteadRules> = new Map([
4647
['csharp', csharp.halstead],
4748
['ruby', ruby.halstead],
4849
['php', php.halstead],
50+
['lua', b3.halsteadLua],
4951
]);
5052

5153
// ─── CFG Rules ────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)