diff --git a/.bca-baseline.toml b/.bca-baseline.toml index d5769746..f4d2cdc5 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -728,14 +728,14 @@ value = 16.0 [[entry]] path = "src/metrics/cognitive.rs" qualified = "tcl_switch_decision_arms" -start_line = 448 +start_line = 454 metric = "nargs" value = 7.0 [[entry]] path = "src/metrics/cognitive.rs" qualified = "tcl_switch_decision_arms" -start_line = 448 +start_line = 454 metric = "nexits" value = 6.0 @@ -938,16 +938,16 @@ value = 8.0 [[entry]] path = "src/spaces/compute.rs" qualified = "compute_per_node" -start_line = 217 +start_line = 218 metric = "halstead.effort" value = 61523.95778223676 [[entry]] path = "src/spaces/compute.rs" qualified = "metrics_inner" -start_line = 386 +start_line = 446 metric = "halstead.effort" -value = 122486.96209573474 +value = 142419.45851755128 [[entry]] path = "src/suppression.rs" @@ -961,7 +961,7 @@ path = "src/tools.rs" qualified = "" start_line = 1 metric = "loc.sloc" -value = 767.0 +value = 779.0 [[entry]] path = "src/tools.rs" diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fffa8a..853f1d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,40 @@ for historical reference. ### Fixed +- The `cognitive` metric's nesting lookup is no longer quadratic in + nesting depth (#1062). It recovered each node's inherited nesting via + `node.parent()`, which is `O(depth)` — tree-sitter stores no parent + pointer — making the lookup `O(nodes × depth)`. The walker now hands + each node its inherited nesting directly, so the lookup is `O(1)`. + Cognitive values are unchanged. On shapes that exercise only this path, + whole-file analysis is now linear: nested parentheses at depths + 8000 / 16000 / 32000 / 64000 take 13 / 23 / 45 / 88 ms, so a 128 KB + file completes in under a tenth of a second. + + The walker also no longer pre-seeds that map with the root. Nothing + read the seed — the lookup already falls back to a default — but it was + the map's only entry whenever `cognitive` is deselected, so a + metric-subset run (`--metrics loc`, `bca check` with a threshold + subset) allocated a hash table per file for one unread entry. The two + grammars whose cognitive impl is a no-op, `preproc` and `ccomment`, now + build no map at all rather than one entry per AST node. + + **Deeply nested code is still superlinear in general** — this fixes one + of several `Node::parent` call sites, and *not* the one users hit most. + `Checker::is_else_if` calls `parent()` (or `previous_sibling()`, which + is implemented via `parent()`) for every `if`, across 13 languages, so + nested and `else if`-chained conditionals remain quadratic: a 4000-deep + C `if` chain takes ~3.3 s at depth 8000 where the equivalent `while` + chain takes 44 ms. Also outstanding: `Node::count_specific_ancestors` + (used by `loc` for C-family, Java, C#, Go, Groovy, Objective-C, and + twice per node by Python's boolean-operator handling), + `cognitive::increment_function_depth` (4000 nested Rust `fn`s, 55 KB → + ~1.9 s), and Elixir's `is_inside_quote_block`, which runs for every + node behind no metric selection and so cannot be deselected (4000 + nested `quote` blocks, 52 KB → ~2.7 s). All tracked in #1062, which + stays open; a shared parent lookup owned by the walker would retire + them together. + - Deeply nested source no longer costs quadratic time in the `tokens` metric (#1052). `Tokens` decided whether a leaf sat inside a comment by walking that leaf's ancestor chain — and `Node::parent` is itself @@ -116,13 +150,19 @@ for historical reference. ### Security -- Removed a remotely-triggerable CPU-exhaustion vector in the `tokens` - metric — a few kilobytes of deeply nested source could pin a core for - minutes against the unauthenticated `bca-web` endpoints, whose parse - deadline frees the client but cannot cancel the blocking task. See the - `tokens` entry under **Fixed** (#1052) for detail and for the residual - superlinear paths that remain (#1062); operators analysing untrusted - input should still bound request concurrency. +- Narrowed a remotely-triggerable CPU-exhaustion vector: a few kilobytes + of deeply nested source could pin a core for minutes against the + unauthenticated `bca-web` endpoints, whose parse deadline frees the + client but cannot cancel the blocking task. Two of the quadratic paths + are gone — the `tokens` ancestor walk (#1052) and the `cognitive` + nesting lookup (#1062); see those entries under **Fixed**. + + **This is not closed.** `Checker::is_else_if` still calls + `Node::parent` per `if` across 13 languages, so deeply nested or + `else if`-chained conditionals — ordinary generated and state-machine + code, not just adversarial input — remain quadratic, along with three + further ancestor walks. All tracked in #1062. Operators analysing + untrusted input must still bound request concurrency and input size. - Cleared the two RUSTSEC advisories behind the OpenSSF Scorecard Vulnerabilities alert: `anyhow` `1.0.102` → `1.0.103` (unsound `Error::downcast_mut()`, RUSTSEC-2026-0190) and `memmap2` `0.9.10` diff --git a/src/macros/mod.rs b/src/macros/mod.rs index 76a05d5f..f5990ff8 100644 --- a/src/macros/mod.rs +++ b/src/macros/mod.rs @@ -46,7 +46,7 @@ macro_rules! implement_metric_trait { _node: &Node<'a>, _code: &'a [u8], _stats: &mut Stats, - _nesting_map: &mut HashMap, + _nesting_map: &mut crate::spaces::NestingMap, ) {} } )+ diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index c0c9a7d7..90103bad 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -22,7 +22,7 @@ clippy::cast_sign_loss )] -use std::collections::HashMap; +use crate::spaces::{Nesting, NestingMap}; use std::fmt; @@ -169,7 +169,7 @@ where node: &Node<'a>, code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ); } @@ -251,14 +251,16 @@ fn increment_branch_extension(stats: &mut Stats) { stats.boolean_seq.reset(); } -fn get_nesting_from_map( - node: &Node, - nesting_map: &HashMap, -) -> (usize, usize, usize) { - node.parent() - .and_then(|parent| nesting_map.get(&parent.id())) - .copied() - .unwrap_or((0, 0, 0)) +/// Returns the [`Nesting`] `node` inherits from its parent. +/// +/// The map is keyed so that a node's own slot holds what it *inherits*: +/// the walker seeds each child's slot from its parent's slot after the +/// parent's `compute` has run (see `propagate_nesting_to_children` in +/// `spaces::compute`). Reading `node.parent()` here instead would cost +/// `O(depth)` per node — `Node::parent` walks down from the root — which +/// made this metric quadratic in nesting depth (#1062). +fn get_nesting_from_map(node: &Node, nesting_map: &NestingMap) -> Nesting { + nesting_map.get(&node.id()).copied().unwrap_or_default() } fn increment_function_depth>(depth: &mut usize, node: &Node, stops: &[T]) { @@ -304,10 +306,14 @@ macro_rules! js_cognitive { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use $lang::*; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfStatement if !Self::is_else_if(node) => { @@ -363,7 +369,10 @@ macro_rules! js_cognitive { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { conditional: nesting, function_depth: depth, lambda }, + ); } }; } @@ -9381,4 +9390,155 @@ end", nested_sum.get(), ); } + + /// Pins that `function_depth` and `lambda` are distinguishable. + /// + /// They are summed symmetrically almost everywhere, so most inputs + /// cannot tell them apart. The one asymmetric operation in the whole + /// cognitive family is the JS `FunctionDeclaration` arm's + /// `lambda = 0`, which clears one field alone. + /// + /// Whether that separates them depends on the *parity* of the + /// ancestor chain between the innermost arrow and the + /// `function_declaration`, because a swap at the write site + /// transposes the pair at every node on the way down. The plain + /// `arrow -> statement_block -> function_declaration` chain has odd + /// parity and stays at the same total either way; a second chained + /// arrow flips it. Hence the doubled arrow here — without it this + /// test passes with the two fields transposed and guards nothing. + #[test] + fn javascript_function_depth_and_lambda_are_distinguishable() { + // expected: the inner `function` resets lambda, so only its own + // function-depth level survives and the `if` costs 1. + check_metrics::( + "const f = () => () => { function inner() { if (a) { } } };", + "nest.js", + |metric| { + assert_eq!(metric.cognitive.cognitive_sum(), 1); + }, + ); + } + + /// The `ArrowFunction` arm's own `lambda += 1`, pinned separately: + /// with no `function_declaration` between the arrow and the `if`, + /// nothing resets lambda, so the arrow's level reaches the `if`. + #[test] + fn javascript_arrow_contributes_lambda_nesting() { + // expected: 1 for the `if`, +1 for the enclosing arrow level. + check_metrics::( + "const f = () => { if (a) { } };", + "arrow.js", + |metric| { + assert_eq!(metric.cognitive.cognitive_sum(), 2); + }, + ); + } + + /// Deeply nested input must stay tractable (#1062). + /// + /// `get_nesting_from_map` used to recover a node's inherited nesting + /// via `node.parent()`, which is `O(depth)` — tree-sitter stores no + /// parent pointer — making the metric `O(nodes × depth)`. The walker + /// now seeds each child's slot from its parent's, so the lookup is + /// `O(1)`. + /// + /// Uses `while`, deliberately, **not** `if`: `Checker::is_else_if` + /// calls `node.parent()` for every `if_statement`, so nested `if`s + /// are still quadratic for reasons this fix does not touch. Anchoring + /// the assertion to that shape would measure the unfixed path — at + /// nesting depth 8000 a release build takes 3.3 s of `if` versus + /// 44 ms of `while`. + /// + /// Asserts a *ratio* rather than a wall-clock budget. The pre-fix code + /// produced identical values, only quadratically, so a value assertion + /// alone would let a regression hang CI instead of failing it — but an + /// absolute budget is calibrated to one machine and this suite also + /// runs under `cargo llvm-cov` and on shared Windows / macOS runners. + /// Doubling the depth costs ~2x when the lookup is `O(1)` and ~4x when + /// it is `O(depth)`, and that ratio is independent of host speed. + /// The wall-clock half of this test is opt-in via + /// `BCA_ASSERT_SCALING=1`. The value assertions above it always run. + /// + /// A timing assertion has now produced a false failure in three + /// different environments: `windows-latest` in CI (10.9 s against an + /// 8 s absolute budget), a local `make pre-commit` running clippy and + /// rustdoc alongside the suite (5.6x), and `cargo llvm-cov`, whose + /// instrumentation skewed even a best-of-three ratio to 3.5x. The + /// `coverage` job runs in CI, so leaving it armed reds the build on a + /// measurement artefact rather than a regression. Complexity guards + /// belong in a benchmark harness (#1068), not the unit suite. + #[test] + fn cognitive_deep_nesting_is_tractable() { + // Restricted to `Cognitive` — which pulls in `Nom` as a declared + // dependency, so this narrows the work rather than isolating it. + fn cognitive_of(source: &str) -> (u64, std::time::Duration) { + let started = std::time::Instant::now(); + let sum = crate::tools::metrics_verbatim( + crate::LANG::C, + source.as_bytes(), + crate::MetricsOptions::default().with_only(&[crate::Metric::Cognitive]), + ) + .cognitive + .cognitive_sum(); + (sum, started.elapsed()) + } + + // Each level adds its own nesting penalty, so cognitive grows as + // 1 + 2 + … + n. Asserting it pins that nesting is still inherited + // correctly at depth, independently of the timing below. + let nested_whiles = |n: usize| -> String { + format!( + "int main(){{ {} 1; {} }}\n", + "while (a) { ".repeat(n), + "} ".repeat(n) + ) + }; + let expected = |n: u64| n * (n + 1) / 2; + + assert_eq!(cognitive_of(&nested_whiles(3)).0, expected(3), "1 + 2 + 3"); + + // Best-of-three per depth. A single pair of timings is not + // robust enough here: the two measurements are sequential, so a + // load spike between them skews the ratio on its own. This test + // did flake once inside `make pre-commit`, which runs clippy, + // rustdoc and the Python stages alongside the suite — 70 ms vs + // 394 ms, a 5.6x reading on code that is linear. Contention is + // bursty, so the minimum of a few runs sheds it while a genuine + // O(depth) regression, which is present in every run, survives. + let best_of_three = |n: usize| -> (u64, std::time::Duration) { + let mut sum = 0; + let mut best = std::time::Duration::MAX; + for _ in 0..3 { + let (s, elapsed) = cognitive_of(&nested_whiles(n)); + sum = s; + best = best.min(elapsed); + } + (sum, best) + }; + + let base = 2_000; + let (shallow_sum, shallow_time) = best_of_three(base); + let (deep_sum, deep_time) = best_of_three(base * 2); + assert_eq!(shallow_sum, expected(base as u64)); + assert_eq!(deep_sum, expected(base as u64 * 2)); + + // Wall clock is only asserted when this test is run explicitly. + // See the `#[ignore]` rationale on the attribute below: three + // separate environments have produced a false reading here, and + // a red build from a timing artefact costs more than this guard + // is worth inside the default suite. + if std::env::var_os("BCA_ASSERT_SCALING").is_none() { + return; + } + + // Guard against a zero denominator on a very fast machine. + let shallow_ns = shallow_time.as_nanos().max(1); + let ratio = deep_time.as_nanos() as f64 / shallow_ns as f64; + assert!( + ratio < 3.0, + "doubling nesting depth cost {ratio:.1}x ({shallow_time:?} -> {deep_time:?}); \ + an O(1) inherited lookup scales ~2x, the O(depth) `Node::parent` \ + lookup this replaced scales ~4x (#1062)" + ); + } } diff --git a/src/metrics/cognitive/bash.rs b/src/metrics/cognitive/bash.rs index 1f97b220..8dc0b457 100644 --- a/src/metrics/cognitive/bash.rs +++ b/src/metrics/cognitive/bash.rs @@ -18,11 +18,15 @@ impl Cognitive for BashCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Bash::*; - let (mut nesting, mut depth, lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { // `WhileStatement` covers both `while` and `until`; `ForStatement` @@ -51,6 +55,13 @@ impl Cognitive for BashCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/c.rs b/src/metrics/cognitive/c.rs index 4d782871..7a7f0275 100644 --- a/src/metrics/cognitive/c.rs +++ b/src/metrics/cognitive/c.rs @@ -18,12 +18,16 @@ impl Cognitive for CCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use C::*; // Macro expansion is not tracked; macros are treated as opaque tokens. - let (mut nesting, mut depth, lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfStatement if !Self::is_else_if(node) => { @@ -60,6 +64,13 @@ impl Cognitive for CCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/cpp.rs b/src/metrics/cognitive/cpp.rs index 03633f95..f86d9b54 100644 --- a/src/metrics/cognitive/cpp.rs +++ b/src/metrics/cognitive/cpp.rs @@ -18,12 +18,16 @@ impl Cognitive for CppCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Cpp::*; // Macro expansion is not tracked; macros are treated as opaque tokens. - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfStatement if !Self::is_else_if(node) => { @@ -70,6 +74,13 @@ impl Cognitive for CppCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/csharp.rs b/src/metrics/cognitive/csharp.rs index e1361d0d..67d0bc22 100644 --- a/src/metrics/cognitive/csharp.rs +++ b/src/metrics/cognitive/csharp.rs @@ -18,11 +18,15 @@ impl Cognitive for CsharpCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Csharp::*; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfStatement if !Self::is_else_if(node) => { @@ -109,6 +113,13 @@ impl Cognitive for CsharpCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/elixir.rs b/src/metrics/cognitive/elixir.rs index 76049a0b..8d1c9276 100644 --- a/src/metrics/cognitive/elixir.rs +++ b/src/metrics/cognitive/elixir.rs @@ -65,11 +65,15 @@ impl Cognitive for ElixirCode { node: &Node<'a>, code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Elixir as E; - let (mut nesting, depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { E::Call => match elixir_call_keyword(node, code) { @@ -129,6 +133,13 @@ impl Cognitive for ElixirCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/go.rs b/src/metrics/cognitive/go.rs index 37be1b95..1e0b6341 100644 --- a/src/metrics/cognitive/go.rs +++ b/src/metrics/cognitive/go.rs @@ -18,11 +18,15 @@ impl Cognitive for GoCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Go as G; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { G::IfStatement if !Self::is_else_if(node) => { @@ -56,6 +60,13 @@ impl Cognitive for GoCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/groovy.rs b/src/metrics/cognitive/groovy.rs index 697166bb..e380e011 100644 --- a/src/metrics/cognitive/groovy.rs +++ b/src/metrics/cognitive/groovy.rs @@ -18,11 +18,15 @@ impl Cognitive for GroovyCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Groovy::*; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfStatement if !Self::is_else_if(node) => { @@ -89,6 +93,13 @@ impl Cognitive for GroovyCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/irules.rs b/src/metrics/cognitive/irules.rs index 557ef366..da8a66f0 100644 --- a/src/metrics/cognitive/irules.rs +++ b/src/metrics/cognitive/irules.rs @@ -18,11 +18,15 @@ impl Cognitive for IrulesCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Irules::*; - let (mut nesting, mut depth, lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { // Defensive guard for parity with sibling impls; iRules' dedicated @@ -72,6 +76,13 @@ impl Cognitive for IrulesCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/java.rs b/src/metrics/cognitive/java.rs index a0260808..327b29c8 100644 --- a/src/metrics/cognitive/java.rs +++ b/src/metrics/cognitive/java.rs @@ -18,11 +18,15 @@ impl Cognitive for JavaCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Java::*; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfStatement if !Self::is_else_if(node) => { @@ -72,6 +76,13 @@ impl Cognitive for JavaCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/kotlin.rs b/src/metrics/cognitive/kotlin.rs index 24aed37d..b5c260a5 100644 --- a/src/metrics/cognitive/kotlin.rs +++ b/src/metrics/cognitive/kotlin.rs @@ -37,11 +37,15 @@ impl Cognitive for KotlinCode { node: &Node<'a>, code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Kotlin::*; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfExpression if !Self::is_else_if(node) => { @@ -88,6 +92,13 @@ impl Cognitive for KotlinCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/lua.rs b/src/metrics/cognitive/lua.rs index cf1841f5..083c5723 100644 --- a/src/metrics/cognitive/lua.rs +++ b/src/metrics/cognitive/lua.rs @@ -18,11 +18,15 @@ impl Cognitive for LuaCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Lua::*; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { // `is_else_if` returns true for `ElseifStatement`, but Lua's @@ -68,6 +72,13 @@ impl Cognitive for LuaCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/mozcpp.rs b/src/metrics/cognitive/mozcpp.rs index 8d4c0e97..b8967c0d 100644 --- a/src/metrics/cognitive/mozcpp.rs +++ b/src/metrics/cognitive/mozcpp.rs @@ -18,12 +18,16 @@ impl Cognitive for MozcppCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Mozcpp::*; // Macro expansion is not tracked; macros are treated as opaque tokens. - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfStatement if !Self::is_else_if(node) => { @@ -70,6 +74,13 @@ impl Cognitive for MozcppCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/objc.rs b/src/metrics/cognitive/objc.rs index 1c0a515c..39a6343b 100644 --- a/src/metrics/cognitive/objc.rs +++ b/src/metrics/cognitive/objc.rs @@ -18,12 +18,16 @@ impl Cognitive for ObjcCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Objc::*; // Macro expansion is not tracked; macros are treated as opaque tokens. - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfStatement if !Self::is_else_if(node) => { @@ -67,6 +71,13 @@ impl Cognitive for ObjcCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/perl.rs b/src/metrics/cognitive/perl.rs index d14bd82b..0bec1ec8 100644 --- a/src/metrics/cognitive/perl.rs +++ b/src/metrics/cognitive/perl.rs @@ -40,11 +40,15 @@ impl Cognitive for PerlCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Perl as P; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { // tree-sitter-perl parses `elsif_clause` as a direct child of @@ -106,6 +110,13 @@ impl Cognitive for PerlCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/php.rs b/src/metrics/cognitive/php.rs index cd930067..622f3aa5 100644 --- a/src/metrics/cognitive/php.rs +++ b/src/metrics/cognitive/php.rs @@ -18,11 +18,15 @@ impl Cognitive for PhpCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Php::*; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { // The two-word `else if` form parses as `else_clause → @@ -101,6 +105,13 @@ impl Cognitive for PhpCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/python.rs b/src/metrics/cognitive/python.rs index c3c27401..6b6125d2 100644 --- a/src/metrics/cognitive/python.rs +++ b/src/metrics/cognitive/python.rs @@ -37,7 +37,7 @@ fn python_comprehension_clause_nesting( nesting: usize, depth: usize, lambda: usize, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) -> usize { use Python::*; let mut for_count = 0; @@ -45,11 +45,25 @@ fn python_comprehension_clause_nesting( for child in node.children() { let kind = child.kind_id(); if kind == ForInClause as u16 { - nesting_map.insert(child.id(), (nesting + for_count, depth, lambda)); + nesting_map.insert( + child.id(), + Nesting { + conditional: nesting + for_count, + function_depth: depth, + lambda, + }, + ); for_count += 1; last_clause_is_if = false; } else if kind == IfClause as u16 { - nesting_map.insert(child.id(), (nesting + for_count, depth, lambda)); + nesting_map.insert( + child.id(), + Nesting { + conditional: nesting + for_count, + function_depth: depth, + lambda, + }, + ); last_clause_is_if = true; } } @@ -87,12 +101,16 @@ impl Cognitive for PythonCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Python::*; // Get nesting of the parent - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { // `else: if x:` chains surface as an `if_statement` wrapped @@ -138,12 +156,13 @@ impl Cognitive for PythonCode { python_comprehension_clause_nesting(node, nesting, depth, lambda, nesting_map); } ForInClause | IfClause => { - // Nesting was precomputed on the comprehension node (visited - // first in pre-order) into this clause's own map slot, so read - // it back instead of re-scanning siblings per clause. - if let Some(&(clause_nesting, _, _)) = nesting_map.get(&node.id()) { - nesting = clause_nesting; - } + // `nesting` already holds this clause's own value: the + // comprehension (visited first in pre-order) precomputed it + // into this clause's map slot, and since #1062 a node reads + // its own slot, so the read at the top of `compute` picks it + // up. Before #1062 a node read its *parent's* slot, so this + // arm had to re-read the slot explicitly; that override is + // now a no-op and has been removed. stats.nesting = nesting + depth + lambda; increment(stats); stats.boolean_seq.reset(); @@ -185,6 +204,13 @@ impl Cognitive for PythonCode { _ => {} } // Add node to nesting map - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/ruby.rs b/src/metrics/cognitive/ruby.rs index fd699c73..5e38cef0 100644 --- a/src/metrics/cognitive/ruby.rs +++ b/src/metrics/cognitive/ruby.rs @@ -30,11 +30,15 @@ impl Cognitive for RubyCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Ruby as R; - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { // Nesting-increasing constructs. tree-sitter-ruby models @@ -133,6 +137,13 @@ impl Cognitive for RubyCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/rust.rs b/src/metrics/cognitive/rust.rs index 9bbc41d0..fd231817 100644 --- a/src/metrics/cognitive/rust.rs +++ b/src/metrics/cognitive/rust.rs @@ -18,11 +18,15 @@ impl Cognitive for RustCode { node: &Node<'a>, _code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Rust::*; // Macro expansion is not tracked; macros are treated as opaque tokens. - let (mut nesting, mut depth, mut lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + mut lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { IfExpression if !Self::is_else_if(node) => { @@ -62,6 +66,13 @@ impl Cognitive for RustCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/cognitive/tcl.rs b/src/metrics/cognitive/tcl.rs index bf12ecfe..ed93754b 100644 --- a/src/metrics/cognitive/tcl.rs +++ b/src/metrics/cognitive/tcl.rs @@ -18,11 +18,15 @@ impl Cognitive for TclCode { node: &Node<'a>, code: &'a [u8], stats: &mut Stats, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { use Tcl::*; - let (mut nesting, mut depth, lambda) = get_nesting_from_map(node, nesting_map); + let Nesting { + conditional: mut nesting, + function_depth: mut depth, + lambda, + } = get_nesting_from_map(node, nesting_map); match node.kind_id().into() { // Guard kept for defensive consistency with sibling impls; Tcl's dedicated @@ -62,6 +66,13 @@ impl Cognitive for TclCode { } _ => {} } - nesting_map.insert(node.id(), (nesting, depth, lambda)); + nesting_map.insert( + node.id(), + Nesting { + conditional: nesting, + function_depth: depth, + lambda, + }, + ); } } diff --git a/src/metrics/loc.rs b/src/metrics/loc.rs index cbd8e1db..43592e42 100644 --- a/src/metrics/loc.rs +++ b/src/metrics/loc.rs @@ -735,7 +735,7 @@ mod typescript; clippy::too_many_lines )] mod tests { - use crate::tools::check_metrics; + use crate::tools::{check_metrics, metrics_verbatim}; use super::*; @@ -9854,29 +9854,12 @@ class A { ); } - /// Analyses `source` **verbatim** in `lang`. + /// Analyses `source` byte-for-byte as Rust. /// - /// `check_metrics` cannot be used for the #1051 cases: it routes through - /// `tools::check_func_space`, which trims trailing newlines and appends - /// one, so a node ending at EOF is unreachable through it and any such - /// test would pass vacuously. `Source` applies no normalisation of its - /// own, so this is the in-tree path that reproduces the bug. - /// - /// Takes `lang` rather than hard-coding one: the untestable-at-EOF class - /// is shared by every `Loc` submodule, not just Rust's. - fn loc_verbatim(lang: crate::LANG, source: &[u8]) -> Stats { - crate::analyze( - crate::Source::new(lang, source), - crate::MetricsOptions::default(), - ) - .expect("verbatim source must analyse") - .metrics - .loc - } - - /// `loc_verbatim` bound to Rust, which is where #1051 lives. + /// Goes through `metrics_verbatim` rather than `check_metrics` + /// because the #1051 cases end at EOF; see that helper for why. fn rust_loc(source: &[u8]) -> Stats { - loc_verbatim(crate::LANG::Rust, source) + metrics_verbatim(crate::LANG::Rust, source, crate::MetricsOptions::default()).loc } /// #1051: a Rust doc comment ending at EOF has no trailing newline for diff --git a/src/metrics/tokens.rs b/src/metrics/tokens.rs index 868d3fba..7075113e 100644 --- a/src/metrics/tokens.rs +++ b/src/metrics/tokens.rs @@ -200,7 +200,7 @@ implement_metric_trait!( clippy::too_many_lines )] mod tests { - use crate::tools::check_metrics; + use crate::tools::{check_metrics, metrics_verbatim}; use super::*; @@ -422,25 +422,17 @@ mod tests { }); } - /// Analyses `source` verbatim and returns its total token count. + /// Total token count for `source`, analysed byte-for-byte. /// - /// Used by the tests below that need to *compare* counts across - /// inputs, which `check_metrics` cannot express: it takes an `F: Fn` - /// that cannot return a value, and it normalises the source before - /// parsing. - /// - /// Restricted to `Tokens` so these tests measure only the metric - /// they guard. With every metric enabled, `cognitive`'s own - /// superlinear nesting lookup (#1062) dominates the deep-nesting - /// case below — a `cognitive` regression would look like a `tokens` - /// one, and a `cognitive` fix would mask a `tokens` regression. + /// Restricted to `Tokens`: with every metric enabled, `cognitive`'s + /// own superlinear nesting lookup (#1062) dominates the deep-nesting + /// case below and would misattribute a regression. fn tokens_of(source: &str) -> u64 { - crate::analyze( - crate::Source::new(crate::LANG::Rust, source.as_bytes()), + metrics_verbatim( + crate::LANG::Rust, + source.as_bytes(), crate::MetricsOptions::default().with_only(&[crate::Metric::Tokens]), ) - .expect("source must analyse") - .metrics .tokens .tokens_sum() } diff --git a/src/ops.rs b/src/ops.rs index 83eb533b..df504de5 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -254,6 +254,8 @@ pub(crate) fn ops_inner( // mirrors (which differ by `State` payload) it is reused directly // rather than duplicated. The `children.drain(..).rev()` ordering // it encapsulates is load-bearing for suppression attribution. + // The returned child slice is only useful to `metrics_inner`, + // which seeds their cognitive nesting; `ops` just walks them. push_children(&mut cursor, &node, new_level, &mut children, &mut stack); } diff --git a/src/spaces.rs b/src/spaces.rs index 7f7975e9..614657ef 100644 --- a/src/spaces.rs +++ b/src/spaces.rs @@ -19,12 +19,111 @@ use std::borrow::Cow; use std::collections::HashMap; +use std::hash::{BuildHasherDefault, Hasher}; use serde::{Deserialize, Serialize}; use std::fmt; use std::path::Path; use std::sync::Arc; +/// Hasher for the node-id keyed maps the walker threads through the +/// metric traits. +/// +/// The keys are tree-sitter node ids: pointer-derived `usize` values we +/// produced ourselves, never attacker-chosen. The default SipHash-1-3 +/// exists to resist hash-flooding from untrusted *keys*, so on this map +/// it buys nothing and costs a full keyed round per probe — on a path +/// that runs several times per AST node. +/// +/// This is FxHash as of `rustc-hash` 2.x, including the `finish` rotate +/// that 2.0 added. It is **not** collision-resistant and must not be +/// used for any map whose keys come from user input. +#[derive(Default)] +pub(crate) struct NodeIdHasher { + hash: u64, +} + +impl NodeIdHasher { + /// FxHash's multiplier. Being *odd* is the load-bearing property: it + /// makes `n * SEED (mod 2^64)` a bijection, so distinct node ids can + /// never produce the same hash. + const SEED: u64 = 0x51_7c_c1_b7_27_22_0a_95; + + /// Rotate applied in `finish`. A multiplicative hash concentrates + /// entropy in the *top* bits, but hashbrown takes the bucket index + /// from the *bottom* ones. Node ids are pointer-aligned, so without + /// this every hash would end in at least three zero bits and only + /// one home bucket in eight would be reachable — cheap on x86-64, + /// where the SSE2 probe group is 16 wide, but measurably worse on + /// aarch64, whose NEON group is 8. Value matches `rustc-hash` 2.x. + const FINISH_ROTATE: u32 = 26; + + #[inline] + fn add(&mut self, word: u64) { + self.hash = (self.hash.rotate_left(5) ^ word).wrapping_mul(Self::SEED); + } +} + +impl Hasher for NodeIdHasher { + #[inline] + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.add(u64::from(*byte)); + } + } + + // The integer writes are overridden so a key type change cannot + // silently fall back to `write`'s byte-at-a-time loop, which would + // cost eight dependent rounds per word and be slower than the + // SipHash this replaced. + #[inline] + fn write_usize(&mut self, n: usize) { + self.add(n as u64); + } + + #[inline] + fn write_u64(&mut self, n: u64) { + self.add(n); + } + + #[inline] + fn write_u32(&mut self, n: u32) { + self.add(u64::from(n)); + } + + #[inline] + fn write_u8(&mut self, n: u8) { + self.add(u64::from(n)); + } + + #[inline] + fn finish(&self) -> u64 { + self.hash.rotate_left(Self::FINISH_ROTATE) + } +} + +/// Per-node nesting state that `Cognitive` inherits down the walk. +/// +/// A struct rather than a `(usize, usize, usize)` because the three are +/// same-typed and summed symmetrically (`conditional + function_depth + +/// lambda`), so a transposition is invisible at the point of use and +/// surfaces only in the arms that read one field alone. Naming them +/// makes the boundary — where the walk reads and writes this — the +/// place a mix-up is visible. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] +pub(crate) struct Nesting { + /// Structural nesting from enclosing control constructs. + pub(crate) conditional: usize, + /// Count of enclosing functions. + pub(crate) function_depth: usize, + /// Count of enclosing lambdas / closures. + pub(crate) lambda: usize, +} + +/// Node-id keyed [`Nesting`] state; see +/// `spaces::compute::propagate_nesting_to_children`. +pub(crate) type NestingMap = HashMap>; + use crate::langs::LANG; use crate::metric_set::{Metric, MetricSet}; use crate::preproc::PreprocResults; diff --git a/src/spaces/compute.rs b/src/spaces/compute.rs index 97ada19c..ed76a2e7 100644 --- a/src/spaces/compute.rs +++ b/src/spaces/compute.rs @@ -221,7 +221,7 @@ fn compute_per_node<'a, T: ParserTrait>( code: &'a [u8], options: MetricsOptions, facts: NodeFacts, - nesting_map: &mut HashMap, + nesting_map: &mut NestingMap, ) { let NodeFacts { func_space, @@ -357,6 +357,63 @@ struct Walk { in_comment: bool, } +/// Seeds each child's `nesting_map` slot from `node`'s own, so +/// `Cognitive` can read the [`Nesting`] it inherits without calling +/// `Node::parent`. +/// +/// A node's slot means two different things either side of its +/// `compute`, and the distinction is load-bearing: +/// +/// - **on entry** it holds what the node inherits — this is what +/// `get_nesting_from_map` reads; +/// - **on exit** each language's `compute` has overwritten it with the +/// post-increment `Nesting` its children should see — this is what this +/// function hands down. +/// +/// So the write at the end of every `Cognitive::compute` must stay at the +/// end. Moving it to the top would make every descendant inherit the +/// pre-increment `Nesting` and silently under-count. +/// +/// `or_insert`, not `insert`: Python's comprehension handling pre-writes +/// its clause children's slots during the *comprehension's* `compute` +/// (the #421 fix, so clause nesting does not depend on sibling traversal +/// order), and those values deliberately differ from the comprehension's +/// own `Nesting`. A blanket overwrite would clobber them — the +/// `python_comprehension_*` tests in `metrics::cognitive` are what catch +/// it. +/// +/// Grammars whose `Cognitive` impl is the macro's no-op (`Preproc`, +/// `Ccomment`) never write a slot, so the root lookup misses and the +/// walk seeds nothing for them at all — no map, no allocation. +fn propagate_nesting_to_children( + node: &Node, + children: &[(Node<'_>, Walk)], + nesting_map: &mut NestingMap, +) { + // Leaves are roughly half of a real AST, so bail before hashing a key + // we would only read to iterate zero children. + if children.is_empty() { + return; + } + // A miss here is a *root-only* path. Every non-root slot is created + // by this function's `or_insert` below, before that node is ever + // popped, so reaching a node with no slot means it had no parent to + // seed it. The root's own slot exists iff the root's `compute` wrote + // one — which the two no-op grammars never do, so for them the walk + // seeds nothing and the map stays empty. + // + // Note this does *not* depend on every real impl writing on every + // path: a real impl that skipped its write would still leave its + // children seeded, and would show up as wrong nesting values, not as + // a missing slot. + let Some(&inherited) = nesting_map.get(&node.id()) else { + return; + }; + for (child, _) in children { + nesting_map.entry(child.id()).or_insert(inherited); + } +} + /// Pushes `node`'s direct children onto the traversal `stack`, each tagged /// with `tag`. /// @@ -369,13 +426,28 @@ struct Walk { /// `Tag` is generic because the two walkers carry different context down /// the tree: `ops` needs only the nesting level, while `metrics_inner` /// also propagates comment membership ([`Walk`], issue #1052). -pub(crate) fn push_children<'a, Tag: Copy>( +/// +/// Returns the children just pushed, as a slice borrowed from `stack` — +/// empty for a leaf, and in reverse source order like the stack itself. +/// +/// Borrowing rather than returning indices is what makes "these are +/// exactly this node's children" a compiler-checked claim: the borrow +/// forbids touching `stack` while the slice is alive, so the slice +/// cannot drift from the pushes it describes. Recording `stack.len()` +/// around the call instead only holds while the two stay adjacent, and +/// nothing enforces that. +pub(crate) fn push_children<'a, 's, Tag: Copy>( cursor: &mut Cursor<'a>, node: &Node<'a>, tag: Tag, children: &mut Vec<(Node<'a>, Tag)>, - stack: &mut Vec<(Node<'a>, Tag)>, -) { + stack: &'s mut Vec<(Node<'a>, Tag)>, +) -> &'s [(Node<'a>, Tag)] { + debug_assert!( + children.is_empty(), + "scratch buffer must be left drained by the previous call" + ); + let first = stack.len(); cursor.reset(node); if cursor.goto_first_child() { loop { @@ -388,6 +460,7 @@ pub(crate) fn push_children<'a, Tag: Copy>( stack.push(child); } } + &stack[first..] } pub(crate) fn metrics_inner( @@ -415,10 +488,13 @@ pub(crate) fn metrics_inner( let mut children = Vec::new(); let mut state_stack: Vec = Vec::new(); let mut last_level = 0; - // Initialize nesting_map used for storing nesting information for cognitive - // Three type of nesting info: conditionals, functions and lambdas - let mut nesting_map = HashMap::::default(); - nesting_map.insert(node.id(), (0, 0, 0)); + // Per-node cognitive nesting, inherited down the walk. Deliberately + // not pre-seeded with the root: `get_nesting_from_map` already falls + // back to `Nesting::default()`, so a seed would change nothing for + // grammars that compute cognitive — while for the two whose impl is + // the macro's no-op it is the one write that would make the walk + // build an entry per node that nothing ever reads. + let mut nesting_map = NestingMap::default(); // Suppression markers are resolved inline during the walk rather // than queued for a post-finalize pass. When we visit a comment @@ -537,7 +613,7 @@ pub(crate) fn metrics_inner( ); } - push_children( + let pushed = push_children( &mut cursor, &node, Walk { @@ -547,6 +623,10 @@ pub(crate) fn metrics_inner( &mut children, &mut stack, ); + + if selected.contains(Metric::Cognitive) { + propagate_nesting_to_children(&node, pushed, &mut nesting_map); + } } finalize::(&mut state_stack, usize::MAX, selected); diff --git a/src/spaces_tests.rs b/src/spaces_tests.rs index a731c77b..d28f2c09 100644 --- a/src/spaces_tests.rs +++ b/src/spaces_tests.rs @@ -1806,3 +1806,91 @@ int helper(int y) { ); }); } + +/// `NodeIdHasher`'s integer writes must all agree for the same value. +/// +/// `HashMap` only ever calls `write_usize`, so the other +/// overrides are unreachable today — they exist so that changing the key +/// type (to `u64`, say, when the node id gains a newtype) cannot silently +/// fall back to `Hasher::write`'s byte-at-a-time default, which costs +/// eight dependent rounds per word and would be slower than the SipHash +/// this replaced. That regression is invisible: results stay correct and +/// only speed changes. This pins the equivalence so the fallback cannot +/// creep back in unnoticed. +#[test] +fn node_id_hasher_integer_writes_agree() { + use std::hash::Hasher; + + use crate::spaces::NodeIdHasher; + + let hash_of = |write: &dyn Fn(&mut NodeIdHasher)| { + let mut h = NodeIdHasher::default(); + write(&mut h); + h.finish() + }; + + for value in [0_u64, 1, 42, 0x0123_4567, u64::from(u32::MAX), u64::MAX] { + let via_u64 = hash_of(&|h| h.write_u64(value)); + let via_usize = hash_of(&|h| h.write_usize(value as usize)); + assert_eq!( + via_u64, via_usize, + "write_u64 and write_usize must agree for {value:#x}" + ); + + if let Ok(narrow) = u32::try_from(value) { + assert_eq!( + hash_of(&|h| h.write_u32(narrow)), + via_u64, + "write_u32 must agree for {value:#x}" + ); + } + if let Ok(narrow) = u8::try_from(value) { + assert_eq!( + hash_of(&|h| h.write_u8(narrow)), + via_u64, + "write_u8 must agree for {value:#x}" + ); + } + } + + // The byte-slice path is the fallback the overrides exist to avoid. + // It must still work, and must NOT coincide with the word writes — + // if it did, the overrides would be pointless and their removal + // would go unnoticed. + let via_bytes = hash_of(&|h| h.write(&7_u64.to_ne_bytes())); + assert_ne!( + via_bytes, + hash_of(&|h| h.write_u64(7)), + "the byte loop folds one round per byte, so it cannot match the \ + single-round word write; if these ever agree, the overrides are \ + no longer doing anything" + ); +} + +/// `finish` must rotate, not return the raw product. +/// +/// A multiplicative hash concentrates entropy in the top bits, but +/// hashbrown takes the bucket index from the bottom ones — and node ids +/// are pointer-aligned, so without the rotate every hash ends in at least +/// three zero bits and only one home bucket in eight is reachable. +/// `rustc-hash` added this in 2.0; a first cut of `NodeIdHasher` copied +/// the 1.x algorithm and reproduced exactly the defect upstream removed. +#[test] +fn node_id_hasher_finish_rotates_entropy_down() { + use std::hash::Hasher; + + use crate::spaces::NodeIdHasher; + + // Pointer-aligned ids, as tree-sitter produces. + let low_bits_set = (1..64_usize).any(|i| { + let mut h = NodeIdHasher::default(); + h.write_usize(i * 8); + h.finish() & 0b111 != 0 + }); + assert!( + low_bits_set, + "every 8-aligned id hashed to a value with three zero low bits, so \ + only one home bucket in eight is reachable — `finish` is not \ + rotating" + ); +} diff --git a/src/tools.rs b/src/tools.rs index 008e6bff..a8387cf0 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -775,6 +775,27 @@ pub(crate) fn check_metrics( check_func_space::(source, filename, |func_space| check(func_space.metrics)); } +/// Analyses `source` **byte-for-byte** and returns its metrics. +/// +/// Use this, not [`check_metrics`], when a test's input must reach the +/// parser unaltered: `check_func_space` normalises CRLF and trims then +/// re-appends a trailing newline, so a construct ending at EOF is +/// unreachable through it and such a test passes vacuously (#1051). It +/// also returns a value, which `check_metrics`' bare `fn` callback +/// cannot. Restrict `options` in timing-sensitive tests so an unrelated +/// metric's cost cannot dominate and misattribute a regression. +#[cfg(test)] +#[track_caller] +pub(crate) fn metrics_verbatim( + lang: crate::LANG, + source: &[u8], + options: crate::MetricsOptions, +) -> crate::CodeMetrics { + crate::analyze(crate::Source::new(lang, source), options) + .expect("verbatim source must analyse") + .metrics +} + /// Asserts that `func_space` has a direct child space named `name` and that /// its `kind` matches `expected`. ///