From cd056e48a0ce1077a94564cc2acea2273aa97ae2 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Wed, 8 Jul 2026 14:06:52 -0600 Subject: [PATCH] fix(complexity): add cognitive/cyclomatic/Halstead support for c/cpp/kotlin/swift/scala/bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the tier-1 languages from issue #1923 (native rules already existed, WASM/TS had no COMPLEXITY_RULES/HALSTEAD_RULES entries at all) into src/ast-analysis/rules/{c,b2,b3}.ts and wires them into COMPLEXITY_RULES/ HALSTEAD_RULES. Verified every rule against real tree-sitter output rather than assuming the native config was correct, which surfaced and fixed three real bugs affecting both engines: - C/CPP_RULES had else_via_alternative backwards: tree-sitter-c/cpp wrap the else branch in a real else_clause node (Pattern A, like JS/C#/Rust), not Go/Java's alternative-field pattern. A canonical if/else-if/else computed cognitive=5, maxNesting=2 instead of the correct cognitive=3, maxNesting=1. - BASH_RULES was missing else_clause from branch_nodes, so a trailing plain else got zero cognitive credit despite else_node_type being configured. - SWIFT_RULES used logical_node_types: ["binary_expression"], but Swift (like Kotlin) splits && / || into conjunction_expression/disjunction_expression — so Swift's && / || were never counted at all. Also fixed a WASM-only divergence from the Rust engine: computeFunctionComplexity and the complexity visitor evaluated branchNodes and caseNodes independently, double-counting any node type listed in both sets (Kotlin's when_entry, Scala's case_clause). Rust's walk() returns unconditionally after a branch-node match, making case handling unreachable for such types; TS now mirrors that exclusivity. Widened ComplexityRules.logicalNodeType (singular) to logicalNodeTypes (Set), matching the native logical_node_types slice, so Kotlin/Swift's split && / || node types can both be registered — the old singular field could only ever hold one type. Filed #2058 for the remaining known-imperfect native rules mirrored as-is for engine parity: case-node shadowing for C/C++/Kotlin/Scala's switch/match (inflates cognitive complexity per case), Swift's switch container missing entirely from branch_nodes, Kotlin/Swift's else-if chains counting as progressively nested branches instead of a flat chain (needs a new sibling-position detection primitive, not a config fix), and c/cpp/kotlin/ swift/scala's comment-prefix list undercounting multi-line block comments. The remaining 17 tier-2 languages (functional/pattern-matching languages needing from-scratch grammar design, per #1923's own scoping) are left for follow-up work under #1923, which stays open. --- .../src/ast_analysis/complexity.rs | 269 ++++++++++++++- src/ast-analysis/metrics.ts | 14 + src/ast-analysis/rules/b2.ts | 316 +++++++++++++++++- src/ast-analysis/rules/b3.ts | 86 ++++- src/ast-analysis/rules/c.ts | 242 +++++++++++++- src/ast-analysis/rules/csharp.ts | 2 +- src/ast-analysis/rules/go.ts | 2 +- src/ast-analysis/rules/index.ts | 12 + src/ast-analysis/rules/java.ts | 2 +- src/ast-analysis/rules/javascript.ts | 2 +- src/ast-analysis/rules/php.ts | 2 +- src/ast-analysis/rules/python.ts | 2 +- src/ast-analysis/rules/ruby.ts | 2 +- src/ast-analysis/rules/rust.ts | 2 +- .../visitors/complexity-visitor.ts | 14 +- src/features/complexity.ts | 14 +- src/types.ts | 6 +- tests/unit/complexity.test.ts | 312 ++++++++++++++++- 18 files changed, 1270 insertions(+), 31 deletions(-) diff --git a/crates/codegraph-core/src/ast_analysis/complexity.rs b/crates/codegraph-core/src/ast_analysis/complexity.rs index e5c8e2d49..1f7d80ff2 100644 --- a/crates/codegraph-core/src/ast_analysis/complexity.rs +++ b/crates/codegraph-core/src/ast_analysis/complexity.rs @@ -337,8 +337,15 @@ pub static PHP_RULES: LangRules = LangRules { switch_like_nodes: &["switch_statement"], }; +// tree-sitter-c's if_statement wraps its else branch in a real `else_clause` +// node (`if_statement condition consequence else_clause(else [if_statement | +// ])`) — confirmed by parsing `if (..) {..} else if (..) {..} +// else {..}` and inspecting the S-expression. This is Pattern A (JS/C#/Rust +// style: an else_clause node wraps either a nested if_statement for +// `else if` or the plain else body), NOT Pattern C (Go/Java style, where the +// `alternative` field holds the substatement directly with no wrapper node). pub static C_RULES: LangRules = LangRules { - branch_nodes: &["if_statement", "for_statement", "while_statement", "do_statement", "case_statement", "conditional_expression"], + branch_nodes: &["if_statement", "else_clause", "for_statement", "while_statement", "do_statement", "case_statement", "conditional_expression"], case_nodes: &["case_statement"], logical_operators: &["&&", "||"], logical_node_types: &["binary_expression"], @@ -346,14 +353,16 @@ pub static C_RULES: LangRules = LangRules { nesting_nodes: &["if_statement", "for_statement", "while_statement", "do_statement", "conditional_expression"], function_nodes: &["function_definition"], if_node_type: Some("if_statement"), - else_node_type: None, + else_node_type: Some("else_clause"), elif_node_type: None, - else_via_alternative: true, + else_via_alternative: false, switch_like_nodes: &["switch_statement"], }; +// Mirrors C_RULES: tree-sitter-cpp's if_statement uses the same else_clause +// wrapper (Pattern A), confirmed by parsing the same if/else-if/else shape. pub static CPP_RULES: LangRules = LangRules { - branch_nodes: &["if_statement", "for_statement", "for_range_loop", "while_statement", "do_statement", "case_statement", "conditional_expression", "catch_clause"], + branch_nodes: &["if_statement", "else_clause", "for_statement", "for_range_loop", "while_statement", "do_statement", "case_statement", "conditional_expression", "catch_clause"], case_nodes: &["case_statement"], logical_operators: &["&&", "||"], logical_node_types: &["binary_expression"], @@ -361,9 +370,9 @@ pub static CPP_RULES: LangRules = LangRules { nesting_nodes: &["if_statement", "for_statement", "for_range_loop", "while_statement", "do_statement", "catch_clause", "conditional_expression"], function_nodes: &["function_definition"], if_node_type: Some("if_statement"), - else_node_type: None, + else_node_type: Some("else_clause"), elif_node_type: None, - else_via_alternative: true, + else_via_alternative: false, switch_like_nodes: &["switch_statement"], }; @@ -382,11 +391,16 @@ pub static KOTLIN_RULES: LangRules = LangRules { switch_like_nodes: &["when_expression"], }; +// tree-sitter-swift, like tree-sitter-kotlin, splits && / || into distinct +// node types (conjunction_expression / disjunction_expression) rather than +// sharing one generic binary node — confirmed by parsing `a && b || a` and +// inspecting the S-expression. `logical_node_types: &["binary_expression"]` +// never matches either operator, so Swift && / || were never counted. pub static SWIFT_RULES: LangRules = LangRules { branch_nodes: &["if_statement", "for_in_statement", "while_statement", "repeat_while_statement", "catch_clause", "switch_entry", "ternary_expression", "guard_statement"], case_nodes: &["switch_entry"], logical_operators: &["&&", "||"], - logical_node_types: &["binary_expression"], + logical_node_types: &["conjunction_expression", "disjunction_expression"], optional_chain_type: Some("optional_chaining_expression"), nesting_nodes: &["if_statement", "for_in_statement", "while_statement", "repeat_while_statement", "catch_clause", "ternary_expression", "guard_statement"], function_nodes: &["function_declaration", "init_declaration"], @@ -413,7 +427,7 @@ pub static SCALA_RULES: LangRules = LangRules { }; pub static BASH_RULES: LangRules = LangRules { - branch_nodes: &["if_statement", "for_statement", "while_statement", "case_statement", "elif_clause"], + branch_nodes: &["if_statement", "else_clause", "for_statement", "while_statement", "case_statement", "elif_clause"], case_nodes: &["case_item"], logical_operators: &["&&", "||"], logical_node_types: &["binary_expression"], @@ -1757,4 +1771,243 @@ mod tests { assert_eq!(m.cyclomatic, 3); assert_eq!(m.max_nesting, 2); } + + // ─── C/C++ tests (issue #1923) ────────────────────────────────────────── + + fn compute_c(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_c::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &C_RULES).expect("no function found"); + compute_function_complexity(&func, &C_RULES) + } + + fn compute_cpp(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_cpp::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &CPP_RULES).expect("no function found"); + compute_function_complexity(&func, &CPP_RULES) + } + + #[test] + fn c_empty_function() { + let m = compute_c("int f(void) { return 0; }"); + assert_eq!(m.cognitive, 0); + assert_eq!(m.cyclomatic, 1); + assert_eq!(m.max_nesting, 0); + } + + #[test] + fn c_single_if() { + let m = compute_c("int f(int x) {\n if (x > 0) {\n return 1;\n }\n return 0;\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn c_if_elseif_else() { + // tree-sitter-c wraps the else branch in a real else_clause node + // (Pattern A, like JS/C#/Rust) — NOT Go/Java's alternative-field + // pattern. Confirmed by parsing and inspecting the S-expression. + let m = compute_c( + "int f(int x) {\n if (x > 0) {\n return 1;\n } else if (x < 0) {\n return -1;\n } else {\n return 0;\n }\n}", + ); + // if: +1 cog, +1 cyc; else-if: +1 cog, +1 cyc; plain else: +1 cog + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn c_nested_if() { + let m = compute_c( + "int f(int x, int y) {\n if (x > 0) {\n if (y > 0) {\n return 1;\n }\n }\n return 0;\n}", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 2); + } + + #[test] + fn c_logical_operators() { + let m = compute_c("int f(int a, int b) { return a && b; }"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + } + + #[test] + fn cpp_if_elseif_else() { + let m = compute_cpp( + "int f(int x) {\n if (x > 0) {\n return 1;\n } else if (x < 0) {\n return -1;\n } else {\n return 0;\n }\n}", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn cpp_for_range_loop() { + let m = compute_cpp("void f(int xs[]) {\n for (int x : xs) {\n use(x);\n }\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + // ─── Kotlin tests (issue #1923) ───────────────────────────────────────── + + fn compute_kotlin(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_kotlin_sg::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &KOTLIN_RULES).expect("no function found"); + compute_function_complexity(&func, &KOTLIN_RULES) + } + + #[test] + fn kotlin_single_if() { + let m = compute_kotlin("fun f(x: Int): Int {\n if (x > 0) {\n return 1\n }\n return 0\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn kotlin_logical_operators() { + // Kotlin's grammar splits && / || into distinct node types + // (conjunction_expression / disjunction_expression) rather than + // sharing one generic binary node — both are listed in + // logical_node_types. + let m = compute_kotlin("fun f(a: Boolean, b: Boolean): Boolean {\n return a && b || a\n}"); + assert_eq!(m.cyclomatic, 3); + } + + #[test] + fn kotlin_when_expression() { + let m = compute_kotlin( + "fun f(x: Int): Int {\n return when (x) {\n 1 -> 1\n 2 -> 2\n else -> 0\n }\n}", + ); + // base 1 + when container (0, switch-like) + 3 when_entry cases (+1 + // each) = 4. + assert_eq!(m.cyclomatic, 4); + } + + // ─── Swift tests (issue #1923) ────────────────────────────────────────── + + fn compute_swift(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_swift::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &SWIFT_RULES).expect("no function found"); + compute_function_complexity(&func, &SWIFT_RULES) + } + + #[test] + fn swift_single_if() { + let m = compute_swift("func f(_ x: Int) -> Int {\n if x > 0 {\n return 1\n }\n return 0\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn swift_logical_operators() { + let m = compute_swift("func f(_ a: Bool, _ b: Bool) -> Bool {\n return a && b\n}"); + assert_eq!(m.cyclomatic, 2); + } + + // ─── Scala tests (issue #1923) ────────────────────────────────────────── + + fn compute_scala(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_scala::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &SCALA_RULES).expect("no function found"); + compute_function_complexity(&func, &SCALA_RULES) + } + + #[test] + fn scala_if_elseif_else() { + // tree-sitter-scala's if_expression exposes a real `alternative` + // field holding either a nested if_expression or a block — Pattern C + // (Go/Java style) applies cleanly here, unlike Kotlin/Swift. + let m = compute_scala( + "def f(x: Int): Int = {\n if (x > 0) {\n 1\n } else if (x < 0) {\n -1\n } else {\n 0\n }\n}", + ); + assert_eq!(m.cognitive, 3); + assert_eq!(m.cyclomatic, 3); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn scala_match_expression() { + let m = compute_scala( + "def f(x: Int): Int = {\n x match {\n case 1 => 1\n case 2 => 2\n case _ => 0\n }\n}", + ); + // base 1 + match container (0, switch-like) + 3 case_clause cases + // (+1 each) = 4. + assert_eq!(m.cyclomatic, 4); + } + + // ─── Bash tests (issue #1923) ─────────────────────────────────────────── + + fn compute_bash(code: &str) -> ComplexityMetrics { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_bash::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + let root = tree.root_node(); + let func = find_first_function(&root, &BASH_RULES).expect("no function found"); + compute_function_complexity(&func, &BASH_RULES) + } + + #[test] + fn bash_single_if() { + let m = compute_bash("f() {\n if [ \"$1\" -gt 0 ]; then\n echo pos\n fi\n}"); + assert_eq!(m.cognitive, 1); + assert_eq!(m.cyclomatic, 2); + assert_eq!(m.max_nesting, 1); + } + + #[test] + fn bash_if_elif_else() { + // elif_clause/else_clause are flat siblings of if_statement, matching + // Python's elif_clause/else_clause pattern (Pattern B). + let m = compute_bash( + "f() {\n if [ \"$1\" -gt 0 ]; then\n echo pos\n elif [ \"$1\" -lt 0 ]; then\n echo neg\n else\n echo zero\n fi\n}", + ); + // if: +1 cog, +1 cyc; elif: +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 bash_logical_operators() { + // `&&` inside a `[[ ... ]]` extended test expression parses as a real + // binary_expression node (matching logical_node_types). `&&` used to + // chain separate `[ ] && [ ]` commands is a different grammar + // category (a `list` node joining two `test_command`s, not a + // binary_expression) and is not counted here — confirmed by parsing + // both forms and inspecting the S-expression. + let m = compute_bash("f() {\n if [[ \"$1\" && \"$2\" ]]; then\n echo yes\n fi\n}"); + assert_eq!(m.cyclomatic, 3); + } } diff --git a/src/ast-analysis/metrics.ts b/src/ast-analysis/metrics.ts index e4def246c..544e5aef5 100644 --- a/src/ast-analysis/metrics.ts +++ b/src/ast-analysis/metrics.ts @@ -60,6 +60,14 @@ export function computeHalsteadDerived( const C_STYLE_PREFIXES = ['//', '/*', '*', '*/']; +// c/cpp/kotlin/swift/scala intentionally mirror the native `comment_prefixes()` +// 2-entry list (`//`, `/*`) rather than the 4-entry C_STYLE_PREFIXES used by +// javascript/go/rust/java/csharp — see native `comment_prefixes()` in +// crates/codegraph-core/src/ast_analysis/complexity.rs for the source of truth +// this must stay byte-for-byte identical to (both engines must agree on which +// lines count as comments for the MI calculation). +const C_LIKE_PREFIXES = ['//', '/*']; + const COMMENT_PREFIXES = new Map([ ['javascript', C_STYLE_PREFIXES], ['typescript', C_STYLE_PREFIXES], @@ -71,6 +79,12 @@ const COMMENT_PREFIXES = new Map([ ['python', ['#']], ['ruby', ['#']], ['php', ['//', '#', '/*', '*', '*/']], + ['c', C_LIKE_PREFIXES], + ['cpp', C_LIKE_PREFIXES], + ['kotlin', C_LIKE_PREFIXES], + ['swift', C_LIKE_PREFIXES], + ['scala', C_LIKE_PREFIXES], + ['bash', ['#']], ['lua', ['--']], ]); diff --git a/src/ast-analysis/rules/b2.ts b/src/ast-analysis/rules/b2.ts index 90b77a87d..bf89f356f 100644 --- a/src/ast-analysis/rules/b2.ts +++ b/src/ast-analysis/rules/b2.ts @@ -1,4 +1,9 @@ -import type { DataflowRulesConfig, TreeSitterNode } from '../../types.js'; +import type { + ComplexityRules, + DataflowRulesConfig, + HalsteadRules, + TreeSitterNode, +} from '../../types.js'; import { makeDataflowRules } from '../shared.js'; // ─── Kotlin ─────────────────────────────────────────────────────────────────── @@ -48,6 +53,114 @@ export const dataflowKotlin: DataflowRulesConfig = makeDataflowRules({ // defaults will return null gracefully. }); +// Kotlin's grammar splits `&&`/`||` into two distinct node types (unlike most +// languages, which share one generic binary-expression node for every +// operator) — both must be listed in logicalNodeTypes for cyclomatic/cognitive +// counting to see both operators. Mirrors the native `KOTLIN_RULES`. +export const complexityKotlin: ComplexityRules = { + branchNodes: new Set([ + 'if_expression', + 'for_statement', + 'while_statement', + 'do_while_statement', + 'catch_block', + 'when_expression', + 'when_entry', + ]), + caseNodes: new Set(['when_entry']), + logicalOperators: new Set(['&&', '||']), + logicalNodeTypes: new Set(['conjunction_expression', 'disjunction_expression']), + optionalChainType: 'safe_navigation', + nestingNodes: new Set([ + 'if_expression', + 'for_statement', + 'while_statement', + 'do_while_statement', + 'catch_block', + 'when_expression', + ]), + functionNodes: new Set(['function_declaration']), + ifNodeType: 'if_expression', + elseNodeType: null, + elifNodeType: null, + elseViaAlternative: true, + switchLikeNodes: new Set(['when_expression']), +}; + +export const halsteadKotlin: HalsteadRules = { + operatorLeafTypes: new Set([ + '+', + '-', + '*', + '/', + '%', + '=', + '+=', + '-=', + '*=', + '/=', + '%=', + '==', + '!=', + '<', + '>', + '<=', + '>=', + '===', + '!==', + '&&', + '||', + '!', + '++', + '--', + '..', + '?:', + '?.', + 'is', + 'as', + 'as?', + 'in', + '!in', + 'if', + 'else', + 'for', + 'while', + 'do', + 'when', + 'return', + 'throw', + 'break', + 'continue', + 'try', + 'catch', + 'finally', + '.', + ',', + ';', + ':', + '?', + '->', + ]), + operandLeafTypes: new Set([ + 'simple_identifier', + 'type_identifier', + 'integer_literal', + 'long_literal', + 'real_literal', + 'hex_literal', + 'bin_literal', + 'string_literal', + 'character_literal', + 'true', + 'false', + 'null', + 'this', + 'super', + ]), + compoundOperators: new Set(['call_expression', 'indexing_expression']), + skipTypes: new Set(['type_arguments', 'type_parameters']), +}; + // ─── Swift ──────────────────────────────────────────────────────────────────── // // Swift function_declaration: name is `simple_identifier`, params in child node. @@ -98,6 +211,112 @@ export const dataflowSwift: DataflowRulesConfig = makeDataflowRules({ // navigation_expression field names are non-standard in tree-sitter-swift. }); +// tree-sitter-swift, like tree-sitter-kotlin, splits && / || into distinct +// node types (conjunction_expression / disjunction_expression) rather than +// sharing one generic binary node — confirmed by parsing `a && b || a` and +// inspecting the S-expression. Mirrors the native `SWIFT_RULES`/`SWIFT_HALSTEAD`. +export const complexitySwift: ComplexityRules = { + branchNodes: new Set([ + 'if_statement', + 'for_in_statement', + 'while_statement', + 'repeat_while_statement', + 'catch_clause', + 'switch_entry', + 'ternary_expression', + 'guard_statement', + ]), + caseNodes: new Set(['switch_entry']), + logicalOperators: new Set(['&&', '||']), + logicalNodeTypes: new Set(['conjunction_expression', 'disjunction_expression']), + optionalChainType: 'optional_chaining_expression', + nestingNodes: new Set([ + 'if_statement', + 'for_in_statement', + 'while_statement', + 'repeat_while_statement', + 'catch_clause', + 'ternary_expression', + 'guard_statement', + ]), + functionNodes: new Set(['function_declaration', 'init_declaration']), + ifNodeType: 'if_statement', + elseNodeType: null, + elifNodeType: null, + elseViaAlternative: true, + switchLikeNodes: new Set(['switch_statement']), +}; + +export const halsteadSwift: HalsteadRules = { + operatorLeafTypes: new Set([ + '+', + '-', + '*', + '/', + '%', + '=', + '+=', + '-=', + '*=', + '/=', + '%=', + '==', + '!=', + '<', + '>', + '<=', + '>=', + '===', + '!==', + '&&', + '||', + '!', + '?', + '??', + '...', + '..<', + 'is', + 'as', + 'as?', + 'as!', + 'if', + 'else', + 'for', + 'while', + 'repeat', + 'switch', + 'guard', + 'return', + 'throw', + 'break', + 'continue', + 'try', + 'catch', + '.', + ',', + ';', + ':', + '->', + ]), + operandLeafTypes: new Set([ + 'simple_identifier', + 'type_identifier', + 'integer_literal', + 'real_literal', + 'hex_literal', + 'oct_literal', + 'bin_literal', + 'string_literal', + 'true', + 'false', + 'nil', + 'self', + 'super', + ]), + compoundOperators: new Set(['call_expression', 'subscript_expression']), + skipTypes: new Set(['type_arguments', 'type_parameters']), +}; + // ─── Scala ──────────────────────────────────────────────────────────────────── // // Scala function_definition: `node.childForFieldName('name')` for the name (confirmed @@ -146,6 +365,101 @@ export const dataflowScala: DataflowRulesConfig = makeDataflowRules({ memberPropertyField: 'field', }); +// Mirrors the native `SCALA_RULES`/`SCALA_HALSTEAD`. +export const complexityScala: ComplexityRules = { + branchNodes: new Set([ + 'if_expression', + 'for_expression', + 'while_expression', + 'do_while_expression', + 'catch_clause', + 'case_clause', + 'match_expression', + ]), + caseNodes: new Set(['case_clause']), + logicalOperators: new Set(['&&', '||']), + logicalNodeTypes: new Set(['infix_expression']), + optionalChainType: null, + nestingNodes: new Set([ + 'if_expression', + 'for_expression', + 'while_expression', + 'do_while_expression', + 'catch_clause', + 'match_expression', + ]), + functionNodes: new Set(['function_definition']), + ifNodeType: 'if_expression', + elseNodeType: null, + elifNodeType: null, + elseViaAlternative: true, + switchLikeNodes: new Set(['match_expression']), +}; + +export const halsteadScala: HalsteadRules = { + operatorLeafTypes: new Set([ + '+', + '-', + '*', + '/', + '%', + '=', + '+=', + '-=', + '*=', + '/=', + '%=', + '==', + '!=', + '<', + '>', + '<=', + '>=', + '&&', + '||', + '!', + '::', + '++', + ':+', + '+:', + 'if', + 'else', + 'for', + 'while', + 'do', + 'match', + 'case', + 'return', + 'throw', + 'yield', + 'try', + 'catch', + 'finally', + '.', + ',', + ';', + ':', + '=>', + '<-', + ]), + operandLeafTypes: new Set([ + 'identifier', + 'type_identifier', + 'integer_literal', + 'floating_point_literal', + 'string_literal', + 'character_literal', + 'symbol_literal', + 'true', + 'false', + 'null', + 'this', + 'super', + ]), + compoundOperators: new Set(['call_expression', 'field_expression']), + skipTypes: new Set(['type_arguments', 'type_parameters']), +}; + // ─── Dart ───────────────────────────────────────────────────────────────────── // // Dart uses `function_signature` for top-level functions and `method_signature` diff --git a/src/ast-analysis/rules/b3.ts b/src/ast-analysis/rules/b3.ts index edc0c5b9b..8137db726 100644 --- a/src/ast-analysis/rules/b3.ts +++ b/src/ast-analysis/rules/b3.ts @@ -58,7 +58,7 @@ export const complexityLua: ComplexityRules = { ]), caseNodes: new Set([]), logicalOperators: new Set(['and', 'or']), - logicalNodeType: 'binary_expression', + logicalNodeTypes: new Set(['binary_expression']), optionalChainType: null, nestingNodes: new Set(['if_statement', 'for_statement', 'while_statement', 'repeat_statement']), functionNodes: new Set(['function_declaration']), @@ -237,3 +237,87 @@ export const dataflowBash: DataflowRulesConfig = makeDataflowRules({ // Bash `command` node: function name is in `command_name` child (not a named field). // Leave callFunctionField at default — will return null gracefully. }); + +// Mirrors the native `BASH_RULES`/`BASH_HALSTEAD`. +export const complexityBash: ComplexityRules = { + branchNodes: new Set([ + 'if_statement', + 'else_clause', + 'for_statement', + 'while_statement', + 'case_statement', + 'elif_clause', + ]), + caseNodes: new Set(['case_item']), + logicalOperators: new Set(['&&', '||']), + logicalNodeTypes: new Set(['binary_expression']), + optionalChainType: null, + nestingNodes: new Set(['if_statement', 'for_statement', 'while_statement', 'case_statement']), + functionNodes: new Set(['function_definition']), + ifNodeType: 'if_statement', + elseNodeType: 'else_clause', + elifNodeType: 'elif_clause', + elseViaAlternative: false, + switchLikeNodes: new Set(['case_statement']), +}; + +export const halsteadBash: HalsteadRules = { + operatorLeafTypes: new Set([ + '=', + '==', + '!=', + '-eq', + '-ne', + '-lt', + '-gt', + '-le', + '-ge', + '-z', + '-n', + '-f', + '-d', + '-e', + '-r', + '-w', + '-x', + '&&', + '||', + '!', + '|', + '>>', + '>', + '<', + '<<', + 'if', + 'then', + 'else', + 'elif', + 'fi', + 'for', + 'while', + 'until', + 'do', + 'done', + 'case', + 'esac', + 'in', + 'return', + 'exit', + 'break', + 'continue', + ';', + ';;', + ]), + operandLeafTypes: new Set([ + 'word', + 'variable_name', + 'string', + 'number', + 'raw_string', + 'simple_expansion', + 'expansion', + 'command_name', + ]), + compoundOperators: new Set(['command', 'command_substitution', 'pipeline']), + skipTypes: new Set([]), +}; diff --git a/src/ast-analysis/rules/c.ts b/src/ast-analysis/rules/c.ts index 4f23c0a77..b1002181b 100644 --- a/src/ast-analysis/rules/c.ts +++ b/src/ast-analysis/rules/c.ts @@ -1,6 +1,246 @@ -import type { DataflowRulesConfig, TreeSitterNode } from '../../types.js'; +import type { + ComplexityRules, + DataflowRulesConfig, + HalsteadRules, + TreeSitterNode, +} from '../../types.js'; import { makeDataflowRules } from '../shared.js'; +// ─── C Complexity ───────────────────────────────────────────────────────── +// +// Mirrors the native `C_RULES` in `crates/codegraph-core/src/ast_analysis/complexity.rs`. +// +// tree-sitter-c's if_statement wraps its else branch in a real `else_clause` +// node (`if_statement condition consequence else_clause(else [if_statement | +// ])`) — confirmed by parsing `if (..) {..} else if (..) {..} +// else {..}` and inspecting the S-expression. This is Pattern A (JS/C#/Rust +// style: an else_clause node wraps either a nested if_statement for +// `else if` or the plain else body), NOT Pattern C (Go/Java style, where the +// `alternative` field holds the substatement directly with no wrapper node). + +export const complexity: ComplexityRules = { + branchNodes: new Set([ + 'if_statement', + 'else_clause', + 'for_statement', + 'while_statement', + 'do_statement', + 'case_statement', + 'conditional_expression', + ]), + caseNodes: new Set(['case_statement']), + logicalOperators: new Set(['&&', '||']), + logicalNodeTypes: new Set(['binary_expression']), + optionalChainType: null, + nestingNodes: new Set([ + 'if_statement', + 'for_statement', + 'while_statement', + 'do_statement', + 'conditional_expression', + ]), + functionNodes: new Set(['function_definition']), + ifNodeType: 'if_statement', + elseNodeType: 'else_clause', + elifNodeType: null, + elseViaAlternative: false, + switchLikeNodes: new Set(['switch_statement']), +}; + +// ─── C++ Complexity ─────────────────────────────────────────────────────── +// +// Mirrors the native `CPP_RULES`. Adds `for_range_loop` and `catch_clause` +// on top of the C rule set; uses the same else_clause wrapper (Pattern A) as +// C, confirmed by parsing the same if/else-if/else shape with tree-sitter-cpp. + +export const complexityCpp: ComplexityRules = { + branchNodes: new Set([ + 'if_statement', + 'else_clause', + 'for_statement', + 'for_range_loop', + 'while_statement', + 'do_statement', + 'case_statement', + 'conditional_expression', + 'catch_clause', + ]), + caseNodes: new Set(['case_statement']), + logicalOperators: new Set(['&&', '||']), + logicalNodeTypes: new Set(['binary_expression']), + optionalChainType: null, + nestingNodes: new Set([ + 'if_statement', + 'for_statement', + 'for_range_loop', + 'while_statement', + 'do_statement', + 'catch_clause', + 'conditional_expression', + ]), + functionNodes: new Set(['function_definition']), + ifNodeType: 'if_statement', + elseNodeType: 'else_clause', + elifNodeType: null, + elseViaAlternative: false, + switchLikeNodes: new Set(['switch_statement']), +}; + +// ─── C Halstead ─────────────────────────────────────────────────────────── +// +// Mirrors the native `C_HALSTEAD`. + +export const halstead: HalsteadRules = { + operatorLeafTypes: new Set([ + '+', + '-', + '*', + '/', + '%', + '=', + '+=', + '-=', + '*=', + '/=', + '%=', + '&=', + '|=', + '^=', + '<<=', + '>>=', + '==', + '!=', + '<', + '>', + '<=', + '>=', + '&&', + '||', + '!', + '&', + '|', + '^', + '~', + '<<', + '>>', + '++', + '--', + 'sizeof', + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'return', + 'break', + 'continue', + 'goto', + '.', + '->', + ',', + ';', + ':', + '?', + ]), + operandLeafTypes: new Set([ + 'identifier', + 'type_identifier', + 'field_identifier', + 'number_literal', + 'string_literal', + 'char_literal', + 'true', + 'false', + 'null', + ]), + compoundOperators: new Set(['call_expression', 'subscript_expression']), + skipTypes: new Set([]), +}; + +// ─── C++ Halstead ───────────────────────────────────────────────────────── +// +// Mirrors the native `CPP_HALSTEAD`. Adds C++-specific operators/keywords +// (`new`, `delete`, `throw`, `try`/`catch`, `::`) and raw string literals. + +export const halsteadCpp: HalsteadRules = { + operatorLeafTypes: new Set([ + '+', + '-', + '*', + '/', + '%', + '=', + '+=', + '-=', + '*=', + '/=', + '%=', + '&=', + '|=', + '^=', + '<<=', + '>>=', + '==', + '!=', + '<', + '>', + '<=', + '>=', + '&&', + '||', + '!', + '&', + '|', + '^', + '~', + '<<', + '>>', + '++', + '--', + 'sizeof', + 'new', + 'delete', + 'throw', + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'return', + 'break', + 'continue', + 'try', + 'catch', + '.', + '->', + '::', + ',', + ';', + ':', + '?', + ]), + operandLeafTypes: new Set([ + 'identifier', + 'type_identifier', + 'field_identifier', + 'namespace_identifier', + 'number_literal', + 'string_literal', + 'raw_string_literal', + 'char_literal', + 'true', + 'false', + 'nullptr', + 'this', + ]), + compoundOperators: new Set(['call_expression', 'subscript_expression', 'new_expression']), + skipTypes: new Set(['template_argument_list', 'template_parameter_list']), +}; + // ─── C/C++ function-name extraction ────────────────────────────────────────── // // C/C++ function_definition nests the name inside declarators: diff --git a/src/ast-analysis/rules/csharp.ts b/src/ast-analysis/rules/csharp.ts index e500db202..5ba2343dd 100644 --- a/src/ast-analysis/rules/csharp.ts +++ b/src/ast-analysis/rules/csharp.ts @@ -22,7 +22,7 @@ export const complexity: ComplexityRules = { ]), caseNodes: new Set(['switch_section']), logicalOperators: new Set(['&&', '||', '??']), - logicalNodeType: 'binary_expression', + logicalNodeTypes: new Set(['binary_expression']), optionalChainType: 'conditional_access_expression', nestingNodes: new Set([ 'if_statement', diff --git a/src/ast-analysis/rules/go.ts b/src/ast-analysis/rules/go.ts index 66099fdea..b5783c126 100644 --- a/src/ast-analysis/rules/go.ts +++ b/src/ast-analysis/rules/go.ts @@ -18,7 +18,7 @@ export const complexity: ComplexityRules = { ]), caseNodes: new Set(['expression_case', 'type_case', 'default_case', 'communication_case']), logicalOperators: new Set(['&&', '||']), - logicalNodeType: 'binary_expression', + logicalNodeTypes: new Set(['binary_expression']), optionalChainType: null, nestingNodes: new Set([ 'if_statement', diff --git a/src/ast-analysis/rules/index.ts b/src/ast-analysis/rules/index.ts index e365a14c7..79729d97b 100644 --- a/src/ast-analysis/rules/index.ts +++ b/src/ast-analysis/rules/index.ts @@ -31,6 +31,12 @@ export const COMPLEXITY_RULES: Map = new Map([ ['csharp', csharp.complexity], ['ruby', ruby.complexity], ['php', php.complexity], + ['c', c.complexity], + ['cpp', c.complexityCpp], + ['kotlin', b2.complexityKotlin], + ['swift', b2.complexitySwift], + ['scala', b2.complexityScala], + ['bash', b3.complexityBash], ['lua', b3.complexityLua], ]); @@ -47,6 +53,12 @@ export const HALSTEAD_RULES: Map = new Map([ ['csharp', csharp.halstead], ['ruby', ruby.halstead], ['php', php.halstead], + ['c', c.halstead], + ['cpp', c.halsteadCpp], + ['kotlin', b2.halsteadKotlin], + ['swift', b2.halsteadSwift], + ['scala', b2.halsteadScala], + ['bash', b3.halsteadBash], ['lua', b3.halsteadLua], ]); diff --git a/src/ast-analysis/rules/java.ts b/src/ast-analysis/rules/java.ts index fa7ccbff6..b3b786001 100644 --- a/src/ast-analysis/rules/java.ts +++ b/src/ast-analysis/rules/java.ts @@ -21,7 +21,7 @@ export const complexity: ComplexityRules = { ]), caseNodes: new Set(['switch_label']), logicalOperators: new Set(['&&', '||']), - logicalNodeType: 'binary_expression', + logicalNodeTypes: new Set(['binary_expression']), optionalChainType: null, nestingNodes: new Set([ 'if_statement', diff --git a/src/ast-analysis/rules/javascript.ts b/src/ast-analysis/rules/javascript.ts index 8db32890a..c6b4591d4 100644 --- a/src/ast-analysis/rules/javascript.ts +++ b/src/ast-analysis/rules/javascript.ts @@ -22,7 +22,7 @@ export const complexity: ComplexityRules = { ]), caseNodes: new Set(['switch_case']), logicalOperators: new Set(['&&', '||', '??']), - logicalNodeType: 'binary_expression', + logicalNodeTypes: new Set(['binary_expression']), optionalChainType: 'optional_chain_expression', nestingNodes: new Set([ 'if_statement', diff --git a/src/ast-analysis/rules/php.ts b/src/ast-analysis/rules/php.ts index 113cf6c40..d1f7cbe15 100644 --- a/src/ast-analysis/rules/php.ts +++ b/src/ast-analysis/rules/php.ts @@ -23,7 +23,7 @@ export const complexity: ComplexityRules = { ]), caseNodes: new Set(['case_statement', 'default_statement']), logicalOperators: new Set(['&&', '||', 'and', 'or', '??']), - logicalNodeType: 'binary_expression', + logicalNodeTypes: new Set(['binary_expression']), optionalChainType: 'nullsafe_member_access_expression', nestingNodes: new Set([ 'if_statement', diff --git a/src/ast-analysis/rules/python.ts b/src/ast-analysis/rules/python.ts index 46621dd70..2ad24d77d 100644 --- a/src/ast-analysis/rules/python.ts +++ b/src/ast-analysis/rules/python.ts @@ -21,7 +21,7 @@ export const complexity: ComplexityRules = { ]), caseNodes: new Set(['case_clause']), logicalOperators: new Set(['and', 'or']), - logicalNodeType: 'boolean_operator', + logicalNodeTypes: new Set(['boolean_operator']), optionalChainType: null, nestingNodes: new Set([ 'if_statement', diff --git a/src/ast-analysis/rules/ruby.ts b/src/ast-analysis/rules/ruby.ts index e9e71c10b..d8c3deb5a 100644 --- a/src/ast-analysis/rules/ruby.ts +++ b/src/ast-analysis/rules/ruby.ts @@ -23,7 +23,7 @@ export const complexity: ComplexityRules = { ]), caseNodes: new Set(['when']), logicalOperators: new Set(['and', 'or', '&&', '||']), - logicalNodeType: 'binary', + logicalNodeTypes: new Set(['binary']), optionalChainType: null, nestingNodes: new Set(['if', 'unless', 'case', 'for', 'while', 'until', 'rescue', 'conditional']), functionNodes: new Set(['method', 'singleton_method', 'lambda', 'do_block']), diff --git a/src/ast-analysis/rules/rust.ts b/src/ast-analysis/rules/rust.ts index c21f9d6b2..6dc20575e 100644 --- a/src/ast-analysis/rules/rust.ts +++ b/src/ast-analysis/rules/rust.ts @@ -21,7 +21,7 @@ export const complexity: ComplexityRules = { ]), caseNodes: new Set(['match_arm']), logicalOperators: new Set(['&&', '||']), - logicalNodeType: 'binary_expression', + logicalNodeTypes: new Set(['binary_expression']), optionalChainType: null, nestingNodes: new Set([ 'if_expression', diff --git a/src/ast-analysis/visitors/complexity-visitor.ts b/src/ast-analysis/visitors/complexity-visitor.ts index 2545e6c84..994f6d4c5 100644 --- a/src/ast-analysis/visitors/complexity-visitor.ts +++ b/src/ast-analysis/visitors/complexity-visitor.ts @@ -119,7 +119,7 @@ function classifyLogicalOp(node: TreeSitterNode, cRules: AnyRules, acc: Complexi acc.cyclomatic++; const parent = node.parent; const sameSequence = - parent != null && parent.type === cRules.logicalNodeType && parent.child(1)?.type === op; + parent != null && cRules.logicalNodeTypes.has(parent.type) && parent.child(1)?.type === op; if (!sameSequence) acc.cognitive++; } @@ -194,13 +194,19 @@ function classifyNode( if (hRules) classifyHalstead(node, hRules, acc); if (nestingLevel > acc.maxNesting) acc.maxNesting = nestingLevel; - if (type === cRules.logicalNodeType) classifyLogicalOp(node, cRules, acc); + if (cRules.logicalNodeTypes.has(type)) classifyLogicalOp(node, cRules, acc); if (type === cRules.optionalChainType) acc.cyclomatic++; - if (cRules.branchNodes.has(type) && node.childCount > 0) { + const isBranchNode = cRules.branchNodes.has(type) && node.childCount > 0; + if (isBranchNode) { classifyBranchNode(node, type, nestingLevel, cRules, acc); } classifyPlainElse(node, type, cRules, acc); - if (cRules.caseNodes.has(type) && node.childCount > 0) acc.cyclomatic++; + // Mirrors the Rust walk()'s unconditional `return` after a branch-node + // match, which makes `is_case` unreachable for a node type listed in both + // branch_nodes and case_nodes (e.g. Kotlin's when_entry, Scala's + // case_clause). Without the `!isBranchNode` guard, such node types would + // be double-counted: once via classifyBranchNode above, again here. + if (!isBranchNode && cRules.caseNodes.has(type) && node.childCount > 0) acc.cyclomatic++; } /** diff --git a/src/features/complexity.ts b/src/features/complexity.ts index 7e4eaf2d3..973d9c908 100644 --- a/src/features/complexity.ts +++ b/src/features/complexity.ts @@ -150,7 +150,7 @@ function handleLogicalOperator( nestingLevel: number, walkFn: WalkFn, ): boolean { - if (type !== rules.logicalNodeType) return false; + if (!rules.logicalNodeTypes.has(type)) return false; const op = node.child(1)?.type; if (!op || !rules.logicalOperators.has(op)) return false; @@ -159,7 +159,8 @@ function handleLogicalOperator( // Cognitive: +1 only when operator changes from the previous sibling sequence const parent = node.parent; - const sameSequence = parent?.type === rules.logicalNodeType && parent.child(1)?.type === op; + const sameSequence = + parent != null && rules.logicalNodeTypes.has(parent.type) && parent.child(1)?.type === op; if (!sameSequence) acc.cognitive++; walkChildren(node, nestingLevel, walkFn); @@ -257,7 +258,14 @@ function handleBranchNode( return true; } - return false; + // Always fully handled once branchNodes matched — mirrors the Rust walk()'s + // unconditional `return` after `classify_branch`, which makes `is_case` + // unreachable for a node type listed in both branch_nodes and case_nodes + // (e.g. Kotlin's when_entry, Scala's case_clause). Returning `false` here + // would let the caller's separate `caseNodes` check double-count the + // cyclomatic increment for such node types. + walkChildren(node, nestingLevel, walkFn); + return true; } /** Handle Pattern C plain else: block is the alternative of an if_statement (Go/Java). */ diff --git a/src/types.ts b/src/types.ts index 867040f8d..5629fed0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -986,7 +986,11 @@ export interface ComplexityRules { branchNodes: Set; caseNodes: Set; logicalOperators: Set; - logicalNodeType: string | null; + /** Node type(s) that wrap a logical operator. Most grammars use one shared + * binary-op node for all operators; a few (e.g. Kotlin's `conjunction_expression` + * / `disjunction_expression`) use a distinct node type per operator. Mirrors + * the native `logical_node_types: &'static [&'static str]` slice. */ + logicalNodeTypes: Set; optionalChainType: string | null; nestingNodes: Set; functionNodes: Set; diff --git a/tests/unit/complexity.test.ts b/tests/unit/complexity.test.ts index 52a41211f..0d51fdeb4 100644 --- a/tests/unit/complexity.test.ts +++ b/tests/unit/complexity.test.ts @@ -255,8 +255,23 @@ describe('COMPLEXITY_RULES', () => { expect(COMPLEXITY_RULES.has('tsx')).toBe(true); }); - it('supports all 11 languages, not hcl', () => { - for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php', 'lua']) { + it('supports all 17 languages, not hcl', () => { + for (const lang of [ + 'python', + 'go', + 'rust', + 'java', + 'csharp', + 'ruby', + 'php', + 'c', + 'cpp', + 'kotlin', + 'swift', + 'scala', + 'bash', + 'lua', + ]) { expect(COMPLEXITY_RULES.has(lang)).toBe(true); } expect(COMPLEXITY_RULES.has('hcl')).toBe(false); @@ -347,8 +362,23 @@ describe('HALSTEAD_RULES', () => { expect(HALSTEAD_RULES.has('tsx')).toBe(true); }); - it('supports all 11 languages, not hcl', () => { - for (const lang of ['python', 'go', 'rust', 'java', 'csharp', 'ruby', 'php', 'lua']) { + it('supports all 17 languages, not hcl', () => { + for (const lang of [ + 'python', + 'go', + 'rust', + 'java', + 'csharp', + 'ruby', + 'php', + 'c', + 'cpp', + 'kotlin', + 'swift', + 'scala', + 'bash', + 'lua', + ]) { expect(HALSTEAD_RULES.has(lang)).toBe(true); } expect(HALSTEAD_RULES.has('hcl')).toBe(false); @@ -1036,6 +1066,227 @@ describe('Lua complexity', () => { }); }); +// ─── C (#1923) ──────────────────────────────────────────────────────────── + +describe('C complexity', () => { + const { analyze, halstead, loc } = makeHelpers('c', sharedParsers()); + + it('simple function', () => { + const r = analyze('int add(int a, int b) {\n return a + b;\n}\n'); + expect(r).toEqual({ cognitive: 0, cyclomatic: 1, maxNesting: 0 }); + }); + + it('single if', () => { + const r = analyze('int check(int x) {\n if (x > 0) {\n return 1;\n }\n return 0;\n}\n'); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('if/else-if/else chain', () => { + // tree-sitter-c wraps the else branch in a real else_clause node + // (Pattern A, like JS/C#/Rust) — NOT Go/Java's alternative-field + // pattern, confirmed by parsing and inspecting the S-expression. + const r = analyze( + 'int classify(int x) {\n if (x > 0) {\n return 1;\n } else if (x < 0) {\n return -1;\n } else {\n return 0;\n }\n}\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 1 }); + }); + + it('nested if', () => { + const r = analyze( + 'int nested(int x, int y) {\n if (x > 0) {\n if (y > 0) {\n return 1;\n }\n }\n return 0;\n}\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 2 }); + }); + + it('logical operators', () => { + const r = analyze('int check(int a, int b) {\n return a && b;\n}\n'); + expect(r.cognitive).toBe(1); + expect(r.cyclomatic).toBe(2); + }); + + it('halstead: positive volume', () => { + const h = halstead('int add(int a, int b) {\n return a + b;\n}\n'); + expect(h).not.toBeNull(); + expect(h.volume).toBeGreaterThan(0); + }); + + it('LOC: // and /* comments detected', () => { + const l = loc('int f() {\n // slash comment\n return 1;\n}\n'); + expect(l.commentLines).toBeGreaterThanOrEqual(1); + }); +}); + +// ─── C++ (#1923) ────────────────────────────────────────────────────────── + +describe('C++ complexity', () => { + const { analyze, halstead } = makeHelpers('cpp', sharedParsers()); + + it('if/else-if/else chain', () => { + // Uses the same else_clause wrapper (Pattern A) as C, confirmed by + // parsing the same shape with tree-sitter-cpp. + const r = analyze( + 'int classify(int x) {\n if (x > 0) {\n return 1;\n } else if (x < 0) {\n return -1;\n } else {\n return 0;\n }\n}\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 1 }); + }); + + it('for-range loop', () => { + const r = analyze('void f(int xs[]) {\n for (int x : xs) {\n use(x);\n }\n}\n'); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('halstead: positive volume', () => { + const h = halstead('int add(int a, int b) {\n return a + b;\n}\n'); + expect(h).not.toBeNull(); + expect(h.volume).toBeGreaterThan(0); + }); +}); + +// ─── Kotlin (#1923) ─────────────────────────────────────────────────────── + +describe('Kotlin complexity', () => { + const { analyze, halstead } = makeHelpers('kotlin', sharedParsers()); + + it('simple function', () => { + const r = analyze('fun add(a: Int, b: Int): Int {\n return a + b\n}\n'); + expect(r).toEqual({ cognitive: 0, cyclomatic: 1, maxNesting: 0 }); + }); + + it('single if', () => { + const r = analyze( + 'fun check(x: Int): Int {\n if (x > 0) {\n return 1\n }\n return 0\n}\n', + ); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('logical operators (conjunction_expression / disjunction_expression)', () => { + // Kotlin's grammar splits && / || into distinct node types rather than + // sharing one generic binary node — both are in logicalNodeTypes. + const r = analyze('fun check(a: Boolean, b: Boolean): Boolean {\n return a && b || a\n}\n'); + expect(r.cyclomatic).toBe(3); + }); + + it('when expression', () => { + const r = analyze( + 'fun classify(x: Int): Int {\n return when (x) {\n 1 -> 1\n 2 -> 2\n else -> 0\n }\n}\n', + ); + // base 1 + when container (0, switch-like) + 3 when_entry cases (+1 each) = 4 + expect(r.cyclomatic).toBe(4); + }); + + it('halstead: positive volume', () => { + const h = halstead('fun add(a: Int, b: Int): Int {\n return a + b\n}\n'); + expect(h).not.toBeNull(); + expect(h.volume).toBeGreaterThan(0); + }); +}); + +// ─── Swift (#1923) ──────────────────────────────────────────────────────── + +describe('Swift complexity', () => { + const { analyze, halstead } = makeHelpers('swift', sharedParsers()); + + it('simple function', () => { + const r = analyze('func add(_ a: Int, _ b: Int) -> Int {\n return a + b\n}\n'); + expect(r).toEqual({ cognitive: 0, cyclomatic: 1, maxNesting: 0 }); + }); + + it('single if', () => { + const r = analyze( + 'func check(_ x: Int) -> Int {\n if x > 0 {\n return 1\n }\n return 0\n}\n', + ); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('logical operators (conjunction_expression)', () => { + // Like Kotlin, Swift splits && / || into conjunction_expression / + // disjunction_expression rather than a generic binary node. + const r = analyze('func check(_ a: Bool, _ b: Bool) -> Bool {\n return a && b\n}\n'); + expect(r.cyclomatic).toBe(2); + }); + + it('halstead: positive volume', () => { + const h = halstead('func add(_ a: Int, _ b: Int) -> Int {\n return a + b\n}\n'); + expect(h).not.toBeNull(); + expect(h.volume).toBeGreaterThan(0); + }); +}); + +// ─── Scala (#1923) ──────────────────────────────────────────────────────── + +describe('Scala complexity', () => { + const { analyze, halstead } = makeHelpers('scala', sharedParsers()); + + it('simple function', () => { + const r = analyze('def add(a: Int, b: Int): Int = {\n a + b\n}\n'); + expect(r).toEqual({ cognitive: 0, cyclomatic: 1, maxNesting: 0 }); + }); + + it('if/else-if/else chain', () => { + // tree-sitter-scala's if_expression exposes a real `alternative` field + // holding either a nested if_expression or a block — Pattern C + // (Go/Java style) applies cleanly here, unlike Kotlin/Swift. + const r = analyze( + 'def classify(x: Int): Int = {\n if (x > 0) {\n 1\n } else if (x < 0) {\n -1\n } else {\n 0\n }\n}\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 1 }); + }); + + it('match expression', () => { + const r = analyze( + 'def classify(x: Int): Int = {\n x match {\n case 1 => 1\n case 2 => 2\n case _ => 0\n }\n}\n', + ); + // base 1 + match container (0, switch-like) + 3 case_clause cases (+1 each) = 4 + expect(r.cyclomatic).toBe(4); + }); + + it('halstead: positive volume', () => { + const h = halstead('def add(a: Int, b: Int): Int = {\n a + b\n}\n'); + expect(h).not.toBeNull(); + expect(h.volume).toBeGreaterThan(0); + }); +}); + +// ─── Bash (#1923) ───────────────────────────────────────────────────────── + +describe('Bash complexity', () => { + const { analyze, halstead } = makeHelpers('bash', sharedParsers()); + + it('simple function', () => { + const r = analyze('f() {\n echo hi\n}\n'); + expect(r).toEqual({ cognitive: 0, cyclomatic: 1, maxNesting: 0 }); + }); + + it('single if', () => { + const r = analyze('f() {\n if [ "$1" -gt 0 ]; then\n echo pos\n fi\n}\n'); + expect(r).toEqual({ cognitive: 1, cyclomatic: 2, maxNesting: 1 }); + }); + + it('if/elif/else chain', () => { + // elif_clause/else_clause are flat siblings of if_statement, matching + // Python's elif_clause/else_clause pattern (Pattern B). + const r = analyze( + 'f() {\n if [ "$1" -gt 0 ]; then\n echo pos\n elif [ "$1" -lt 0 ]; then\n echo neg\n else\n echo zero\n fi\n}\n', + ); + expect(r).toEqual({ cognitive: 3, cyclomatic: 3, maxNesting: 1 }); + }); + + it('logical operators inside [[ ]] extended test', () => { + // `&&` inside `[[ ... ]]` parses as a real binary_expression node + // (matching logicalNodeTypes). `&&` chaining separate `[ ] && [ ]` + // commands is a different grammar category (a `list` node joining two + // test_commands) and is not counted — confirmed by parsing both forms. + const r = analyze('f() {\n if [[ "$1" && "$2" ]]; then\n echo yes\n fi\n}\n'); + expect(r.cyclomatic).toBe(3); + }); + + it('halstead: positive volume', () => { + const h = halstead('f() {\n echo hi\n}\n'); + expect(h).not.toBeNull(); + expect(h.volume).toBeGreaterThan(0); + }); +}); + // ─── Parity: standalone DFS vs visitor-based computeAllMetrics ────────── describe('DFS vs visitor parity', () => { @@ -1208,3 +1459,56 @@ describe('DFS vs visitor parity — Go (elseViaAlternative)', () => { expect(visitor!.maxNesting).toBe(dfs!.maxNesting); }); }); + +// ─── Parity: DFS vs visitor for the #1923 tier-1 languages ─────────────── +// +// computeFunctionComplexity is the standalone DFS reference (mirrors the +// Rust walk); computeAllMetrics is the visitor-based path WASM builds +// actually use. Both must agree for every newly-wired language, including +// Kotlin (multiple logicalNodeTypes) which exercises the +// logicalNodeType → logicalNodeTypes plural refactor. + +describe('DFS vs visitor parity — #1923 tier-1 languages', () => { + const snippets: Record = { + c: 'int classify(int x) {\n if (x > 0) {\n return 1;\n } else if (x < 0) {\n return -1;\n } else {\n return 0;\n }\n}\n', + cpp: 'int classify(int x) {\n if (x > 0) {\n return 1;\n } else if (x < 0) {\n return -1;\n } else {\n return 0;\n }\n}\n', + kotlin: + 'fun classify(x: Int): String {\n if (x > 0) {\n return "pos"\n } else if (x < 0) {\n return "neg"\n }\n return when (x) {\n 0 -> "zero"\n else -> "other"\n }\n}\n', + swift: + 'func classify(_ x: Int) -> String {\n if x > 0 {\n return "pos"\n } else if x < 0 {\n return "neg"\n }\n return "zero"\n}\n', + scala: + 'def classify(x: Int): String = {\n if (x > 0) {\n "pos"\n } else if (x < 0) {\n "neg"\n } else {\n "zero"\n }\n}\n', + bash: 'f() {\n if [ "$1" -gt 0 ]; then\n echo pos\n elif [ "$1" -lt 0 ]; then\n echo neg\n else\n echo zero\n fi\n}\n', + }; + + let parsers: any; + beforeAll(async () => { + parsers = await createParsers(); + }); + + function findFunc(langId: string, node: any): any { + const rules = COMPLEXITY_RULES.get(langId); + if (!rules) return null; + if (rules.functionNodes.has(node.type)) return node; + for (let i = 0; i < node.childCount; i++) { + const result = findFunc(langId, node.child(i)); + if (result) return result; + } + return null; + } + + for (const [langId, code] of Object.entries(snippets)) { + it(`${langId}: dfs and visitor agree`, () => { + const parser = parsers.get(langId); + if (!parser) throw new Error(`${langId} parser not available`); + const tree = parser.parse(code); + const funcNode = findFunc(langId, tree.rootNode); + if (!funcNode) throw new Error(`No function found in ${langId} snippet`); + const dfs = computeFunctionComplexity(funcNode, langId); + const visitor = computeAllMetrics(funcNode, langId); + expect(visitor!.cognitive).toBe(dfs!.cognitive); + expect(visitor!.cyclomatic).toBe(dfs!.cyclomatic); + expect(visitor!.maxNesting).toBe(dfs!.maxNesting); + }); + } +});