diff --git a/crates/codegraph-core/src/ast_analysis/complexity.rs b/crates/codegraph-core/src/ast_analysis/complexity.rs index 9827b091f..38711b970 100644 --- a/crates/codegraph-core/src/ast_analysis/complexity.rs +++ b/crates/codegraph-core/src/ast_analysis/complexity.rs @@ -427,6 +427,44 @@ pub static BASH_RULES: LangRules = LangRules { switch_like_nodes: &["case_statement"], }; +// Lua's `if_statement` is flat, not nested: `elseif`/`else` are separate node +// kinds (`elseif_statement`, `else_statement`) attached to the *same* +// `if_statement` via repeated `alternative:` fields — confirmed by parsing +// `if a then .. elseif b then .. else .. end` with tree-sitter-lua and +// inspecting the S-expression. Structurally identical to Python's +// elif_clause/else_clause (Pattern B), not JS's nested else_clause>if_statement +// (Pattern A) or Go's alternative-holds-nested-if (Pattern C) — so +// else_via_alternative: false, and neither elseif_statement nor else_statement +// is in nesting_nodes (they're siblings of the primary if, not separately +// nested branches). Mirrors `complexityLua` in `src/ast-analysis/rules/b3.ts`. +// +// binary_expression is Lua's single generic binary-op node (arithmetic, +// comparison, concat, AND logical `and`/`or`) — same shared-type pattern as +// Ruby's `binary` node; handle_logical_op only fires when `node.child(1)` (the +// operator token) is in logical_operators, so comparisons/arithmetic sharing +// the same node kind are correctly ignored. +pub static LUA_RULES: LangRules = LangRules { + branch_nodes: &[ + "if_statement", + "elseif_statement", + "else_statement", + "for_statement", + "while_statement", + "repeat_statement", + ], + case_nodes: &[], + logical_operators: &["and", "or"], + logical_node_types: &["binary_expression"], + optional_chain_type: None, + nesting_nodes: &["if_statement", "for_statement", "while_statement", "repeat_statement"], + function_nodes: &["function_declaration"], + if_node_type: Some("if_statement"), + else_node_type: Some("else_statement"), + elif_node_type: Some("elseif_statement"), + else_via_alternative: false, + switch_like_nodes: &[], +}; + /// Look up complexity rules by language ID (matches `COMPLEXITY_RULES` keys in JS). pub fn lang_rules(lang_id: &str) -> Option<&'static LangRules> { match lang_id { @@ -444,6 +482,7 @@ pub fn lang_rules(lang_id: &str) -> Option<&'static LangRules> { "swift" => Some(&SWIFT_RULES), "scala" => Some(&SCALA_RULES), "bash" => Some(&BASH_RULES), + "lua" => Some(&LUA_RULES), _ => None, } } @@ -1022,6 +1061,34 @@ pub static BASH_HALSTEAD: HalsteadRules = HalsteadRules { skip_types: &[], }; +// Member/method access (`dot_index_expression` `.`, `method_index_expression` +// `:`) and invocation (`function_call`) are wrapper nodes without a dedicated +// "call happened" token, so they're counted as compound operators — mirrors +// Python's ["call", "subscript", "attribute"]. The '.'/':' separator tokens +// are ALSO in operator_leaf_types (matching Python counting both "attribute" +// and "."), so member access contributes two distinct operator kinds, +// consistent with the other languages above. Mirrors `halsteadLua` in +// `src/ast-analysis/rules/b3.ts`. +pub static LUA_HALSTEAD: HalsteadRules = HalsteadRules { + operator_leaf_types: &[ + "+", "-", "*", "/", "//", "%", "^", "#", "..", + "==", "~=", "<=", ">=", "<", ">", "=", + "and", "or", "not", + "&", "|", "~", "<<", ">>", + ".", ",", ":", "::", ";", + "if", "then", "else", "elseif", "end", + "for", "while", "do", "repeat", "until", + "function", "local", "return", "break", "goto", "in", + ], + operand_leaf_types: &[ + "identifier", "number", "string_content", "true", "false", "nil", "...", + ], + compound_operators: &[ + "function_call", "bracket_index_expression", "dot_index_expression", "method_index_expression", + ], + skip_types: &[], +}; + /// Look up Halstead rules by language ID. pub fn halstead_rules(lang_id: &str) -> Option<&'static HalsteadRules> { match lang_id { @@ -1039,6 +1106,7 @@ pub fn halstead_rules(lang_id: &str) -> Option<&'static HalsteadRules> { "swift" => Some(&SWIFT_HALSTEAD), "scala" => Some(&SCALA_HALSTEAD), "bash" => Some(&BASH_HALSTEAD), + "lua" => Some(&LUA_HALSTEAD), _ => None, } } @@ -1056,6 +1124,7 @@ pub fn comment_prefixes(lang_id: &str) -> &'static [&'static str] { "swift" => &["//", "/*"], "scala" => &["//", "/*"], "bash" => &["#"], + "lua" => &["--"], _ => &["//", "/*", "*", "*/"], } } @@ -1604,4 +1673,104 @@ mod tests { assert_eq!(m.cognitive, 1); assert_eq!(m.cyclomatic, 2); } + + // ─── Lua tests (issue #1782) ──────────────────────────────────────────── + + fn compute_lua(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_lua::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &LUA_RULES).expect("no function found"); + compute_function_complexity(&func, &LUA_RULES) + } + + #[test] + fn lua_empty_function() { + let m = compute_lua("local function f() end"); + assert_eq!(m.cognitive, 0); + assert_eq!(m.cyclomatic, 1); + assert_eq!(m.max_nesting, 0); + } + + #[test] + fn lua_single_if() { + let m = compute_lua("local function f(x)\n if x > 0 then\n return 1\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_if_elseif_else() { + // elseif_statement/else_statement are siblings of if_statement via + // repeated `alternative:` fields (Pattern B, like Python's elif/else). + let m = compute_lua( + "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", + ); + // if: +1 cog, +1 cyc; elseif: +1 cog, +1 cyc; else: +1 cog + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_numeric_for_loop() { + let m = compute_lua("local function f()\n for i = 1, 10 do\n print(i)\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_while_loop() { + let m = compute_lua("local function f(n)\n while n > 0 do\n n = n - 1\n end\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_repeat_until_loop() { + let m = compute_lua("local function f(n)\n repeat\n n = n - 1\n until n <= 0\nend"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_logical_operators() { + let m = compute_lua("local function f(a, b)\n if a and b then\n return 1\n end\nend"); + // if: +1 cog, +1 cyc; and: +1 cog, +1 cyc + assert_eq!(m.cognitive, 2); + assert_eq!(m.cyclomatic, 3); + } + + #[test] + fn lua_method_declaration() { + // Greptile follow-up to #1782: colon-syntax method declarations + // (`function Obj:method(x)`) have a `method_index_expression` name + // field but are still `function_declaration` nodes, so `function_nodes` + // already covers them — this pins that native/TS parity explicitly. + // Mirrors the TS test 'method declaration (colon syntax) is + // recognized as a function'. + let m = compute_lua( + "local Obj = {}\nfunction Obj:method(x)\n if x > 0 then\n return x\n end\nend", + ); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn lua_nested_if() { + let m = compute_lua( + "local function f(x, y)\n if x > 0 then\n if y > 0 then\n return 1\n end\n end\nend", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 2); + } } diff --git a/src/ast-analysis/metrics.ts b/src/ast-analysis/metrics.ts index 4f94bb444..e4def246c 100644 --- a/src/ast-analysis/metrics.ts +++ b/src/ast-analysis/metrics.ts @@ -71,6 +71,7 @@ const COMMENT_PREFIXES = new Map([ ['python', ['#']], ['ruby', ['#']], ['php', ['//', '#', '/*', '*', '*/']], + ['lua', ['--']], ]); /** diff --git a/src/ast-analysis/rules/b3.ts b/src/ast-analysis/rules/b3.ts index 165f3d617..edc0c5b9b 100644 --- a/src/ast-analysis/rules/b3.ts +++ b/src/ast-analysis/rules/b3.ts @@ -1,4 +1,4 @@ -import type { DataflowRulesConfig } from '../../types.js'; +import type { ComplexityRules, DataflowRulesConfig, HalsteadRules } from '../../types.js'; import { makeDataflowRules } from '../shared.js'; // ─── Lua ────────────────────────────────────────────────────────────────────── @@ -30,6 +30,118 @@ export const dataflowLua: DataflowRulesConfig = makeDataflowRules({ memberPropertyField: 'field', }); +// Lua's `if_statement` is flat, not nested: `elseif`/`else` are separate node +// types (`elseif_statement`, `else_statement`) attached to the *same* +// `if_statement` via repeated `alternative:` fields — confirmed by parsing +// `if a then .. elseif b then .. else .. end` and inspecting the S-expression: +// `(if_statement condition: (...) consequence: (...) alternative: (elseif_statement ...) alternative: (else_statement ...))`. +// This is structurally identical to Python's elif_clause/else_clause pattern +// (Pattern B), not JS's nested else_clause>if_statement (Pattern A) or Go's +// alternative-field-holds-nested-if (Pattern C) — so elseViaAlternative: false +// and neither elseif_statement nor else_statement is in nestingNodes (matching +// how Python's elif_clause/else_clause are siblings of the primary if, not +// separately-nested branches). +// +// binary_expression is Lua's single generic binary-op node (arithmetic, +// comparison, concat, AND logical `and`/`or`) — same shared-type pattern as +// Ruby's `binary` node. classifyLogicalOp/handleLogicalOperator only acts when +// `node.child(1)` (the operator token) is in logicalOperators, so comparisons +// and arithmetic on the same node type are correctly ignored. +export const complexityLua: ComplexityRules = { + branchNodes: new Set([ + 'if_statement', + 'elseif_statement', + 'else_statement', + 'for_statement', + 'while_statement', + 'repeat_statement', + ]), + caseNodes: new Set([]), + logicalOperators: new Set(['and', 'or']), + logicalNodeType: 'binary_expression', + optionalChainType: null, + nestingNodes: new Set(['if_statement', 'for_statement', 'while_statement', 'repeat_statement']), + functionNodes: new Set(['function_declaration']), + ifNodeType: 'if_statement', + elseNodeType: 'else_statement', + elifNodeType: 'elseif_statement', + elseViaAlternative: false, + switchLikeNodes: new Set([]), +}; + +// Member/method access (`dot_index_expression` `.`, `method_index_expression` +// `:`) and invocation (`function_call`) are wrapper nodes without a dedicated +// "call happened" token, so they're counted as compound operators — mirrors +// Python's ['call', 'subscript', 'attribute']. The '.'/':' separator tokens are +// ALSO in operatorLeafTypes (matching Python counting both 'attribute' and +// '.'), so member access contributes two distinct operator kinds, consistent +// with the existing per-language precedent. +export const halsteadLua: HalsteadRules = { + operatorLeafTypes: new Set([ + '+', + '-', + '*', + '/', + '//', + '%', + '^', + '#', + '..', + '==', + '~=', + '<=', + '>=', + '<', + '>', + '=', + 'and', + 'or', + 'not', + '&', + '|', + '~', + '<<', + '>>', + '.', + ',', + ':', + '::', + ';', + 'if', + 'then', + 'else', + 'elseif', + 'end', + 'for', + 'while', + 'do', + 'repeat', + 'until', + 'function', + 'local', + 'return', + 'break', + 'goto', + 'in', + ]), + operandLeafTypes: new Set([ + 'identifier', + 'number', + 'string_content', + 'true', + 'false', + 'nil', + '...', + ]), + compoundOperators: new Set([ + 'function_call', + 'bracket_index_expression', + 'dot_index_expression', + 'method_index_expression', + ]), + skipTypes: new Set([]), +}; + // ─── R ──────────────────────────────────────────────────────────────────────── // // R functions are defined as: `name <- function_definition` (binary_operator with `<-`). diff --git a/src/ast-analysis/rules/index.ts b/src/ast-analysis/rules/index.ts index 298ce6fb0..392967427 100644 --- a/src/ast-analysis/rules/index.ts +++ b/src/ast-analysis/rules/index.ts @@ -31,6 +31,7 @@ export const COMPLEXITY_RULES: Map = new Map([ ['csharp', csharp.complexity], ['ruby', ruby.complexity], ['php', php.complexity], + ['lua', b3.complexityLua], ]); // ─── Halstead Rules ─────────────────────────────────────────────────────── @@ -46,6 +47,7 @@ export const HALSTEAD_RULES: Map = new Map([ ['csharp', csharp.halstead], ['ruby', ruby.halstead], ['php', php.halstead], + ['lua', b3.halsteadLua], ]); // ─── CFG Rules ──────────────────────────────────────────────────────────── diff --git a/tests/unit/complexity.test.ts b/tests/unit/complexity.test.ts index 1d671ddc3..52a41211f 100644 --- a/tests/unit/complexity.test.ts +++ b/tests/unit/complexity.test.ts @@ -255,8 +255,8 @@ describe('COMPLEXITY_RULES', () => { expect(COMPLEXITY_RULES.has('tsx')).toBe(true); }); - it('supports all 10 languages, not hcl', () => { - for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php']) { + it('supports all 11 languages, not hcl', () => { + for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php', 'lua']) { expect(COMPLEXITY_RULES.has(lang)).toBe(true); } expect(COMPLEXITY_RULES.has('hcl')).toBe(false); @@ -347,8 +347,8 @@ describe('HALSTEAD_RULES', () => { expect(HALSTEAD_RULES.has('tsx')).toBe(true); }); - it('supports all 10 languages, not hcl', () => { - for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php']) { + it('supports all 11 languages, not hcl', () => { + for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php', 'lua']) { expect(HALSTEAD_RULES.has(lang)).toBe(true); } expect(HALSTEAD_RULES.has('hcl')).toBe(false); @@ -949,6 +949,93 @@ describe('PHP complexity', () => { }); }); +// ─── Lua (#1782) ───────────────────────────────────────────────────────── + +describe('Lua complexity', () => { + const { analyze, halstead, loc } = makeHelpers('lua', sharedParsers()); + + it('simple function', () => { + const r = analyze('local function add(a, b)\n return a + b\nend\n'); + expect(r).toEqual({ cognitive: 0, cyclomatic: 1, maxNesting: 0 }); + }); + + it('single if', () => { + const r = analyze( + 'local function check(x)\n if x > 0 then\n return true\n end\n return false\nend\n', + ); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('if/elseif/else chain', () => { + // elseif_statement/else_statement are flat siblings of if_statement + // (repeated `alternative:` fields), not nested — same shape as Python's + // elif_clause/else_clause. + const r = analyze( + 'local function classify(x)\n if x > 0 then\n return "pos"\n elseif x < 0 then\n return "neg"\n else\n return "zero"\n end\nend\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 1 }); + }); + + it('nested if', () => { + const r = analyze( + 'local function nested(x, y)\n if x > 0 then\n if y > 0 then\n return true\n end\n end\n return false\nend\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('numeric for loop with condition', () => { + const r = analyze( + 'local function search(arr, t)\n for i = 1, #arr do\n if arr[i] == t then\n return true\n end\n end\n return false\nend\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('generic for-in loop', () => { + const r = analyze( + 'local function search(t, target)\n for k, v in pairs(t) do\n if v == target then\n return k\n end\n end\nend\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('while loop', () => { + const r = analyze('local function countdown(n)\n while n > 0 do\n n = n - 1\n end\nend\n'); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('repeat/until loop', () => { + const r = analyze( + 'local function countdown(n)\n repeat\n n = n - 1\n until n <= 0\nend\n', + ); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('logical operators', () => { + const r = analyze( + 'local function check(a, b)\n if a and b then\n return true\n end\nend\n', + ); + expect(r.cognitive).toBe(2); + expect(r.cyclomatic).toBe(3); + }); + + it('method declaration (colon syntax) is recognized as a function', () => { + const r = analyze( + 'local Obj = {}\nfunction Obj:method(x)\n if x > 0 then\n return x\n end\nend\n', + ); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('halstead: positive volume', () => { + const h = halstead('local function add(a, b)\n return a + b\nend\n'); + expect(h).not.toBeNull(); + expect(h.volume).toBeGreaterThan(0); + }); + + it('LOC: -- comments detected', () => { + const l = loc('local function f()\n -- comment\n return 1\nend\n'); + expect(l.commentLines).toBeGreaterThanOrEqual(1); + }); +}); + // ─── Parity: standalone DFS vs visitor-based computeAllMetrics ────────── describe('DFS vs visitor parity', () => {