Skip to main content

fallow_extract/
inventory.rs

1//! Function inventory walker for `fallow coverage upload-inventory`.
2//!
3//! Emits one [`InventoryEntry`] per function (declaration, expression, arrow,
4//! method) whose name matches what `oxc-coverage-instrument` produces at
5//! instrument time. This is the **static side** of the three-state production
6//! coverage story: uploaded inventory minus runtime-seen functions equals
7//! `untracked`.
8//!
9//! # Naming contract
10//!
11//! The cloud stores function identity as
12//! `(filePath, functionName, lineNumber)`. This walker is responsible for the
13//! `functionName` and `lineNumber` parts of that contract. Anonymous functions
14//! are named `(anonymous_N)` where `N` is a file-scoped monotonic counter that
15//! starts at 0 and increments in pre-order AST traversal each time a function
16//! is entered without a resolvable explicit name. Name resolution precedence:
17//!
18//! 1. Parent-provided `pending_name`: from a `MethodDefinition` /
19//!    `VariableDeclarator` binding, OR from the callee of the call / `new`
20//!    expression a function is passed to as an argument (`arr.map(cb)` ->
21//!    "map", `foo(cb)` -> "foo", `new Promise(cb)` -> "Promise"). The callee
22//!    case matches `oxc-coverage-instrument`'s opt-in `name_callback_arguments`
23//!    (which the Fallow runtime beacon enables), so a callback's static name
24//!    lines up with its runtime-instrumented name instead of both sides drifting
25//!    to different anonymous placeholders.
26//! 2. The function's own `id` (named `function foo() {}`, named function
27//!    expression `const x = function named() {}`).
28//! 3. `(anonymous_N)` with the current counter value; counter then increments.
29//!    Only genuinely unnamed functions reach this: an immediately-invoked
30//!    function expression, an arrow returned from another function, or a
31//!    computed non-string-key call.
32//!
33//! Counter scope is per-file. Reference implementation:
34//! `oxc-coverage-instrument/src/transform.rs` (`resolve_function_name` +
35//! `callback_argument_name`).
36
37use std::path::Path;
38
39use oxc_allocator::Allocator;
40#[allow(clippy::wildcard_imports, reason = "many AST types used")]
41use oxc_ast::ast::*;
42use oxc_ast_visit::{Visit, walk};
43use oxc_parser::Parser;
44use oxc_semantic::ScopeFlags;
45use oxc_span::{SourceType, Span};
46use rustc_hash::FxHashMap;
47
48/// A single static-inventory entry for one function.
49///
50/// `name` is beacon-compatible (see the module docs for the naming rule).
51/// `line` is 1-based, matching the AST span start. The `start_column` /
52/// `end_line` / `end_column` fields carry the function-node span in the
53/// 1-indexed UTF-16 convention the cross-surface `FunctionIdentity` join key
54/// expects (see `fallow_cov_protocol::FunctionIdentity::start_column`). They
55/// are descriptive metadata: the join hash is `(file, name, line)` only, so
56/// column fidelity never affects the join, only display / same-line
57/// disambiguation.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct InventoryEntry {
60    /// Beacon-compatible function name.
61    pub name: String,
62    /// 1-based source line of the function declaration (node `span.start`).
63    pub line: u32,
64    /// 1-indexed UTF-16 column of the function node start.
65    pub start_column: u32,
66    /// 1-based source line where the function node ends.
67    pub end_line: u32,
68    /// 1-indexed UTF-16 column of the function node end.
69    pub end_column: u32,
70    /// Content digest of the function's full-span source slice
71    /// (`&source[span.start..span.end]`): first 8 bytes of SHA-256 as 16
72    /// lowercase hex characters, via `fallow_cov_protocol::source_hash_for`.
73    /// The slice is the canonical body bytes (signature line + body + closing
74    /// brace, no whitespace normalization), identical for `Function` and
75    /// `ArrowFunctionExpression`. Stable across line moves, so a
76    /// moved-but-unedited function keeps the same hash.
77    pub source_hash: String,
78}
79
80/// Rolling state for [`InventoryVisitor::line_col_utf16`]: the last resolved
81/// offset's line index, clamped byte position, and 0-based UTF-16 column.
82/// `line_idx` starts at `usize::MAX` so the first query never matches.
83struct ColCache {
84    line_idx: usize,
85    byte_end: usize,
86    utf16_units: usize,
87}
88
89/// Visitor that collects [`InventoryEntry`] values in file traversal order.
90struct InventoryVisitor<'a> {
91    source: &'a str,
92    line_offsets: &'a [u32],
93    entries: Vec<InventoryEntry>,
94    col_cache: ColCache,
95    /// Parent-provided name override (method key, variable binding, etc.).
96    pending_name: Option<String>,
97    /// Callee name for a function passed as a call / `new` argument. Ranks BELOW
98    /// the function's own `id` (a named function expression keeps its id), so it
99    /// is a separate slot from `pending_name` (which ranks above the id).
100    pending_callee_name: Option<String>,
101    /// File-scoped monotonic counter for unnamed functions.
102    anonymous_counter: u32,
103}
104
105impl<'a> InventoryVisitor<'a> {
106    const fn new(source: &'a str, line_offsets: &'a [u32]) -> Self {
107        Self {
108            source,
109            line_offsets,
110            entries: Vec::new(),
111            col_cache: ColCache {
112                line_idx: usize::MAX,
113                byte_end: 0,
114                utf16_units: 0,
115            },
116            pending_name: None,
117            pending_callee_name: None,
118            anonymous_counter: 0,
119        }
120    }
121
122    /// Resolve a function's name and advance the counter.
123    ///
124    /// Mirrors `oxc-coverage-instrument`'s two-step flow: `resolve_function_name`
125    /// reads the current counter value for the anonymous-case name, and
126    /// `add_function` advances the counter unconditionally on every
127    /// instrumented function (named or not). We collapse both into one call.
128    ///
129    /// Name precedence, matching the instrumenter's `resolve_function_name`:
130    /// parent `pending_name` (method key / variable binding) → function's own
131    /// `id` → call/`new` callee (`pending_callee_name`) → counter.
132    fn resolve_name(&mut self, explicit: Option<&str>) -> String {
133        let n = self.anonymous_counter;
134        self.anonymous_counter += 1;
135        if let Some(pending) = self.pending_name.take() {
136            return pending;
137        }
138        if let Some(name) = explicit {
139            return name.to_owned();
140        }
141        if let Some(callee) = self.pending_callee_name.take() {
142            return callee;
143        }
144        format!("(anonymous_{n})")
145    }
146
147    fn record(&mut self, name: String, span: Span) {
148        let (line, start_column) = self.line_col_utf16(span.start);
149        let (end_line, end_column) = self.line_col_utf16(span.end);
150        let source_hash = self
151            .source
152            .get(span.start as usize..span.end as usize)
153            .map_or_else(
154                || fallow_cov_protocol::source_hash_for(b""),
155                |slice| fallow_cov_protocol::source_hash_for(slice.as_bytes()),
156            );
157        self.entries.push(InventoryEntry {
158            name,
159            line,
160            start_column,
161            end_line,
162            end_column,
163            source_hash,
164        });
165    }
166
167    /// Map a UTF-8 byte offset to `(1-based line, 1-indexed UTF-16 column)`.
168    ///
169    /// The line comes from the precomputed offset table; the column counts
170    /// UTF-16 code units from the line start to `byte_offset`, matching the
171    /// `FunctionIdentity` column convention (Istanbul / V8 / oxc all normalize
172    /// to 1-indexed UTF-16). A byte offset that does not fall on a char
173    /// boundary (it always should for an AST span) clamps to the nearest
174    /// boundary at or before it rather than panicking.
175    ///
176    /// Successive queries on the same line are answered incrementally from
177    /// [`ColCache`]: pre-order traversal emits nearby offsets, so counting
178    /// only the gap to the previous offset keeps the walk linear on
179    /// single-line (minified / generated) files instead of re-encoding the
180    /// full line prefix for every function.
181    fn line_col_utf16(&mut self, byte_offset: u32) -> (u32, u32) {
182        let line_idx = match self.line_offsets.binary_search(&byte_offset) {
183            Ok(idx) => idx,
184            Err(idx) => idx.saturating_sub(1),
185        };
186        let line = line_idx as u32 + 1;
187        let line_start = self.line_offsets[line_idx] as usize;
188        let mut end = byte_offset as usize;
189        while end > line_start && !self.source.is_char_boundary(end) {
190            end -= 1;
191        }
192        // Both `end` and the cached position are char boundaries at or after
193        // `line_start` on the same line, so the gap slice is always valid and
194        // a backward gap never exceeds the cached column.
195        let from_cache = if self.col_cache.line_idx == line_idx {
196            if end >= self.col_cache.byte_end {
197                self.utf16_len(self.col_cache.byte_end, end)
198                    .map(|gap| self.col_cache.utf16_units + gap)
199            } else {
200                self.utf16_len(end, self.col_cache.byte_end)
201                    .map(|gap| self.col_cache.utf16_units - gap)
202            }
203        } else {
204            None
205        };
206        let col_utf16 = from_cache.unwrap_or_else(|| self.utf16_len(line_start, end).unwrap_or(0));
207        self.col_cache = ColCache {
208            line_idx,
209            byte_end: end,
210            utf16_units: col_utf16,
211        };
212        (line, col_utf16 as u32 + 1)
213    }
214
215    /// UTF-16 code-unit count of `source[start..end]`, `None` when the range
216    /// is not sliceable.
217    fn utf16_len(&self, start: usize, end: usize) -> Option<usize> {
218        self.source
219            .get(start..end)
220            .map(|slice| slice.encode_utf16().count())
221    }
222}
223
224impl<'ast> Visit<'ast> for InventoryVisitor<'_> {
225    fn visit_function(&mut self, func: &Function<'ast>, flags: ScopeFlags) {
226        if func.body.is_none() {
227            walk::walk_function(self, func, flags);
228            return;
229        }
230        let name = self.resolve_name(func.id.as_ref().map(|id| id.name.as_str()));
231        self.record(name, func.span);
232        walk::walk_function(self, func, flags);
233    }
234
235    fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'ast>) {
236        let name = self.resolve_name(None);
237        self.record(name, arrow.span);
238        walk::walk_arrow_function_expression(self, arrow);
239    }
240
241    fn visit_method_definition(&mut self, method: &MethodDefinition<'ast>) {
242        if let Some(name) = method.key.static_name() {
243            self.pending_name = Some(name.to_string());
244        }
245        walk::walk_method_definition(self, method);
246        self.pending_name = None;
247    }
248
249    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'ast>) {
250        if let Some(id) = decl.id.get_binding_identifier()
251            && decl.init.as_ref().is_some_and(|init| {
252                matches!(
253                    init,
254                    Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
255                )
256            })
257        {
258            self.pending_name = Some(id.name.to_string());
259        }
260        walk::walk_variable_declarator(self, decl);
261        self.pending_name = None;
262    }
263
264    fn visit_object_property(&mut self, prop: &ObjectProperty<'ast>) {
265        self.pending_name = None;
266        walk::walk_object_property(self, prop);
267        self.pending_name = None;
268    }
269
270    /// Name each function-valued argument from the callee (`arr.map(cb)` ->
271    /// "map", `foo(cb)` -> "foo"), matching `oxc-coverage-instrument`'s
272    /// `name_callback_arguments`. The callee subtree is visited FIRST with no
273    /// inherited name, so a chained call (`a.b().c(cb)`) never leaks `b` onto
274    /// `c`'s callback; the callee's own name is then applied afresh to each
275    /// argument. A binding name from a parent (declarator / method) is already
276    /// consumed by its direct function child before the body's calls, so it
277    /// never collides here. Type arguments are skipped (types hold no function
278    /// to inventory).
279    fn visit_call_expression(&mut self, call: &CallExpression<'ast>) {
280        self.visit_expression(&call.callee);
281        let name = callee_name(&call.callee);
282        for argument in &call.arguments {
283            self.pending_callee_name.clone_from(&name);
284            self.visit_argument(argument);
285        }
286        self.pending_callee_name = None;
287    }
288
289    fn visit_new_expression(&mut self, new_expr: &NewExpression<'ast>) {
290        self.visit_expression(&new_expr.callee);
291        let name = callee_name(&new_expr.callee);
292        for argument in &new_expr.arguments {
293            self.pending_callee_name.clone_from(&name);
294            self.visit_argument(argument);
295        }
296        self.pending_callee_name = None;
297    }
298}
299
300/// Extract a display name from a call / `new` callee, matching
301/// `oxc-coverage-instrument`'s `callee_name`: a bare identifier keeps its name,
302/// a member access uses the (last) property, and a computed access uses a
303/// string-literal key. Anything else (a computed non-string index, a call
304/// result, a parenthesized expression) yields no name.
305fn callee_name(callee: &Expression<'_>) -> Option<String> {
306    match callee {
307        Expression::Identifier(ident) => Some(ident.name.to_string()),
308        Expression::StaticMemberExpression(member) => Some(member.property.name.to_string()),
309        Expression::ComputedMemberExpression(member) => match &member.expression {
310            Expression::StringLiteral(lit) => Some(lit.value.to_string()),
311            _ => None,
312        },
313        // A parenthesized callee (`(foo)(cb)`, `(a.b)(cb)`) unwraps to its inner
314        // callee, matching the instrumenter. oxc keeps paren nodes by default
315        // (`preserve_parens`), so both sides see this node.
316        Expression::ParenthesizedExpression(paren) => callee_name(&paren.expression),
317        _ => None,
318    }
319}
320
321/// Per-function static complexity collected alongside the inventory walk.
322///
323/// Keyed to an [`InventoryEntry`] by its `source_hash`, which both this and the
324/// inventory walk derive from the identical full-span byte slice over the same
325/// parsed program (see [`InventoryEntry::source_hash`]). The hash is stable
326/// across line moves, so the pairing survives reformatting that shifts line
327/// numbers. `cyclomatic` and `cognitive` are descriptive context for downstream
328/// importance weighting, never thresholds.
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub struct InventoryComplexity {
331    /// `McCabe` cyclomatic complexity (1 + decision points).
332    pub cyclomatic: u16,
333    /// `SonarSource` cognitive complexity (structural + nesting penalty).
334    pub cognitive: u16,
335}
336
337/// Parse `source` at `path` and return every function as an [`InventoryEntry`].
338///
339/// Only plain JS/TS/JSX/TSX sources are supported. Callers should skip SFC,
340/// Astro, MDX, CSS, HTML, and other non-JS inputs; those use different
341/// instrumentation paths and are out of scope for the first inventory release.
342///
343/// Errors are swallowed: the returned vector covers whatever could be parsed.
344/// This mirrors how the rest of the extract pipeline handles partial parse
345/// results.
346#[must_use]
347pub fn walk_source(path: &Path, source: &str) -> Vec<InventoryEntry> {
348    walk_source_with_complexity(path, source).0
349}
350
351/// Parse `source` at `path` once and return every function as an
352/// [`InventoryEntry`] together with a `source_hash -> InventoryComplexity` map.
353///
354/// Both the inventory entries and the complexity map come from the SAME parse
355/// (including the JSX fallback retry), so the per-function `source_hash` values
356/// line up exactly and a caller can enrich each entry's metrics by a hash
357/// lookup. Functions whose span slice could not be sliced share the empty-input
358/// hash and simply don't pair; that degrades to "no metrics", never a panic.
359///
360/// Errors are swallowed, matching [`walk_source`]: the returned data covers
361/// whatever could be parsed.
362#[must_use]
363pub fn walk_source_with_complexity(
364    path: &Path,
365    source: &str,
366) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
367    let source_type = SourceType::from_path(path).unwrap_or_default();
368    let line_offsets = fallow_types::extract::compute_line_offsets(source);
369
370    let primary = walk_one_parse(source, source_type, &line_offsets);
371    if primary.0.is_empty() && !source_type.is_jsx() {
372        let jsx_type = if source_type.is_typescript() {
373            SourceType::tsx()
374        } else {
375            SourceType::jsx()
376        };
377        let retry = walk_one_parse(source, jsx_type, &line_offsets);
378        if !retry.0.is_empty() {
379            return retry;
380        }
381    }
382
383    primary
384}
385
386/// Run both the inventory and complexity visitors over a single parse of
387/// `source` under `source_type`, pairing them by `source_hash`.
388fn walk_one_parse(
389    source: &str,
390    source_type: SourceType,
391    line_offsets: &[u32],
392) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
393    let allocator = Allocator::default();
394    let parser_return = Parser::new(&allocator, source, source_type).parse();
395
396    let mut visitor = InventoryVisitor::new(source, line_offsets);
397    visitor.visit_program(&parser_return.program);
398
399    let complexity =
400        crate::complexity::compute_complexity(&parser_return.program, source, line_offsets);
401    let metrics: FxHashMap<String, InventoryComplexity> = complexity
402        .into_iter()
403        .filter_map(|fc| {
404            fc.source_hash.map(|hash| {
405                (
406                    hash,
407                    InventoryComplexity {
408                        cyclomatic: fc.cyclomatic,
409                        cognitive: fc.cognitive,
410                    },
411                )
412            })
413        })
414        .collect();
415
416    (visitor.entries, metrics)
417}
418
419#[cfg(all(test, not(miri)))]
420mod tests {
421    use super::*;
422    use std::path::PathBuf;
423
424    fn walk(source: &str) -> Vec<InventoryEntry> {
425        walk_source(&PathBuf::from("test.ts"), source)
426    }
427
428    #[test]
429    fn named_function_declaration_uses_its_own_name() {
430        let entries = walk("function foo() { return 1; }");
431        assert_eq!(entries.len(), 1);
432        assert_eq!(entries[0].name, "foo");
433        assert_eq!(entries[0].line, 1);
434    }
435
436    #[test]
437    fn const_arrow_captures_binding_name() {
438        let entries = walk("const bar = () => 42;");
439        assert_eq!(entries.len(), 1);
440        assert_eq!(entries[0].name, "bar");
441    }
442
443    #[test]
444    fn const_function_expression_captures_binding_name_not_fn_id() {
445        let entries = walk("const outer = function inner() { return 1; };");
446        assert_eq!(entries.len(), 1);
447        assert_eq!(entries[0].name, "outer");
448    }
449
450    #[test]
451    fn class_methods_use_method_names() {
452        let entries = walk(
453            r"
454            class Foo {
455              bar() { return 1; }
456              baz() { return 2; }
457            }",
458        );
459        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
460        assert_eq!(names, vec!["bar", "baz"]);
461    }
462
463    #[test]
464    fn callback_argument_takes_the_callee_name() {
465        // An arrow passed as a call argument now takes the callee name (matches
466        // the instrumenter's name_callback_arguments), not the anonymous counter.
467        let entries = walk("setTimeout(() => { console.log('hi'); }, 10);");
468        assert_eq!(entries.len(), 1);
469        assert_eq!(entries[0].name, "setTimeout");
470    }
471
472    #[test]
473    fn member_callee_names_each_callback_in_source_order() {
474        let entries = walk(
475            r"
476            [1, 2, 3].map(() => 1);
477            [4, 5, 6].filter(() => true);
478            ",
479        );
480        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
481        assert_eq!(names, vec!["map", "filter"]);
482    }
483
484    #[test]
485    fn named_function_still_advances_counter_matching_instrumenter() {
486        // The counter still advances on every function (named or callee-named),
487        // matching the instrumenter, so a later genuinely-anonymous function
488        // gets the right N. Here the callback is callee-named "map".
489        let entries = walk(
490            r"
491            function named() { return 1; }
492            [1].map(() => 2);
493            ",
494        );
495        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
496        assert_eq!(names, vec!["named", "map"]);
497    }
498
499    #[test]
500    fn plain_identifier_callee_names_the_callback() {
501        let entries = walk("useMemo(() => compute());");
502        assert_eq!(entries[0].name, "useMemo");
503    }
504
505    #[test]
506    fn new_expression_callee_names_the_callback() {
507        let entries = walk("new Promise((resolve) => resolve(1));");
508        assert_eq!(entries[0].name, "Promise");
509    }
510
511    #[test]
512    fn callback_after_a_string_argument_is_named_from_the_callee() {
513        // The event/route-handler shape: the function is a later argument, after
514        // a string. It is named from the callee, not the string.
515        let entries = walk(r#"el.addEventListener("click", () => handle());"#);
516        assert_eq!(entries[0].name, "addEventListener");
517    }
518
519    #[test]
520    fn computed_string_key_callee_is_named() {
521        let entries = walk(r#"obj["handler"](() => run());"#);
522        assert_eq!(entries[0].name, "handler");
523    }
524
525    #[test]
526    fn chained_call_does_not_leak_the_earlier_callee_onto_the_later_callback() {
527        // `.then`'s callback must be "then" and `.catch`'s must be "catch": the
528        // callee subtree (`p.then(cb).catch`) is visited before the outer
529        // arguments, so the earlier callee never leaks onto the later callback.
530        let entries = walk("p.then(() => a).catch(() => b);");
531        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
532        assert_eq!(names, vec!["then", "catch"]);
533    }
534
535    #[test]
536    fn nested_callbacks_each_take_their_own_callee() {
537        let entries = walk("outer(() => inner(() => 1));");
538        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
539        assert_eq!(names, vec!["outer", "inner"]);
540    }
541
542    #[test]
543    fn binding_name_wins_over_callee() {
544        // A declarator binding is consumed on function entry, before the body's
545        // calls, so a bound arrow keeps its name even when its body is a call.
546        let entries = walk("const handler = () => run();");
547        assert_eq!(entries[0].name, "handler");
548    }
549
550    #[test]
551    fn named_function_expression_argument_keeps_its_own_id() {
552        let entries = walk("run(function inner() { return 1; });");
553        assert_eq!(entries[0].name, "inner");
554    }
555
556    #[test]
557    fn iife_callee_stays_anonymous() {
558        // The function is the callee, not an argument, so it is not a callback.
559        let entries = walk("(function () { return 1; })();");
560        assert_eq!(entries[0].name, "(anonymous_0)");
561    }
562
563    #[test]
564    fn computed_non_string_callee_stays_anonymous() {
565        let entries = walk("handlers[index](() => run());");
566        assert_eq!(entries[0].name, "(anonymous_0)");
567    }
568
569    #[test]
570    fn parenthesized_callee_unwraps_to_the_inner_name() {
571        assert_eq!(walk("(foo)(() => run());")[0].name, "foo");
572        assert_eq!(walk("(a.b)(() => run());")[0].name, "b");
573    }
574
575    #[test]
576    fn anonymous_after_named_chain_uses_next_counter_value() {
577        let entries = walk(
578            r"
579            function a() {}
580            function b() {}
581            function c() {}
582            const d = () => 4;
583            ",
584        );
585        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
586        assert_eq!(names, vec!["a", "b", "c", "d"]);
587    }
588
589    #[test]
590    fn typescript_overload_signatures_dont_emit_or_advance_counter() {
591        let entries = walk(
592            r"
593            function foo(): number;
594            function foo(s: string): string;
595            function foo(s?: string): number | string { return s ? s : 1; }
596            [1].map(() => 2);
597            ",
598        );
599        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
600        assert_eq!(names, vec!["foo", "map"]);
601    }
602
603    #[test]
604    fn export_default_named_function_keeps_explicit_name() {
605        let entries = walk("export default function foo() { return 1; }");
606        assert_eq!(entries.len(), 1);
607        assert_eq!(entries[0].name, "foo");
608    }
609
610    #[test]
611    fn export_default_anonymous_function_uses_counter() {
612        let entries = walk("export default function() { return 1; }");
613        assert_eq!(entries.len(), 1);
614        assert_eq!(entries[0].name, "(anonymous_0)");
615    }
616
617    #[test]
618    fn nested_function_numbered_after_parent_in_traversal_order() {
619        let entries = walk(
620            r"
621            function outer() {
622              return function() { return 1; };
623            }",
624        );
625        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
626        assert_eq!(names, vec!["outer", "(anonymous_1)"]);
627    }
628
629    #[test]
630    fn line_number_is_one_based_from_source_start() {
631        let entries = walk("\n\nfunction atLineThree() {}");
632        assert_eq!(entries.len(), 1);
633        assert_eq!(entries[0].line, 3);
634    }
635
636    #[test]
637    fn short_jsx_in_js_file_retries_with_jsx_parser() {
638        let entries = walk_source(&PathBuf::from("component.js"), "const A = () => <div />;");
639        assert_eq!(entries.len(), 1);
640        assert_eq!(entries[0].name, "A");
641        assert_eq!(entries[0].line, 1);
642    }
643
644    #[test]
645    fn object_method_shorthand_uses_anonymous_counter() {
646        let entries = walk("const obj = { run() { return 1; } };");
647        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
648        assert_eq!(names, vec!["(anonymous_0)"]);
649    }
650
651    #[test]
652    fn class_property_arrow_uses_anonymous_counter() {
653        let entries = walk(
654            r"
655            class Foo {
656              bar = () => 1;
657            }",
658        );
659        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
660        assert_eq!(names, vec!["(anonymous_0)"]);
661    }
662
663    #[test]
664    fn records_one_indexed_utf16_columns() {
665        let entries = walk("function foo() { return 1; }");
666        assert_eq!(entries.len(), 1);
667        assert_eq!(entries[0].start_column, 1);
668        assert_eq!(entries[0].end_line, 1);
669        assert!(entries[0].end_column > entries[0].start_column);
670    }
671
672    #[test]
673    fn utf16_column_counts_code_units_not_bytes() {
674        let entries = walk("const e = \"\u{1F600}\"; const f = () => 1;");
675        let f = entries.iter().find(|e| e.name == "f").expect("f present");
676        let byte_prefix_len = "const e = \"\u{1F600}\"; const f = ".len() as u32;
677        assert!(f.start_column < byte_prefix_len + 1);
678    }
679
680    #[test]
681    fn utf16_columns_stay_exact_across_a_long_single_line() {
682        // Minified shape: many functions with non-ASCII content on one line.
683        // Columns must match a naive full-prefix UTF-16 count even though the
684        // walker resolves them incrementally, including the backward offset
685        // jump from `outer`'s end to `inner`'s start and the reset to line 2.
686        use std::fmt::Write as _;
687        let mut src = String::new();
688        for i in 0..40 {
689            let _ = write!(src, "function f{i}() {{ return \"\u{1F600}\"; }} ");
690        }
691        src.push_str("function outer() { const inner = () => \"\u{1F600}\"; return inner; }");
692        src.push_str("\nconst tail = () => 1;");
693        let entries = walk(&src);
694        let col = |byte: usize| src[..byte].encode_utf16().count() as u32 + 1;
695
696        for i in [0_usize, 17, 39] {
697            let body = format!("function f{i}() {{ return \"\u{1F600}\"; }}");
698            let start = src.find(&body).expect("function text present");
699            let entry = entries
700                .iter()
701                .find(|e| e.name == format!("f{i}"))
702                .expect("entry present");
703            assert_eq!(entry.line, 1);
704            assert_eq!(entry.start_column, col(start));
705            assert_eq!(entry.end_line, 1);
706            assert_eq!(entry.end_column, col(start + body.len()));
707        }
708
709        let inner_start = src
710            .find("() => \"\u{1F600}\"")
711            .expect("inner arrow present");
712        let inner = entries
713            .iter()
714            .find(|e| e.name == "inner")
715            .expect("inner present");
716        assert_eq!(inner.line, 1);
717        assert_eq!(inner.start_column, col(inner_start));
718
719        let tail = entries
720            .iter()
721            .find(|e| e.name == "tail")
722            .expect("tail present");
723        let line2_start = src.find('\n').expect("newline present") + 1;
724        let tail_start = src.rfind("() => 1").expect("tail arrow present");
725        assert_eq!(tail.line, 2);
726        assert_eq!(
727            tail.start_column,
728            src[line2_start..tail_start].encode_utf16().count() as u32 + 1
729        );
730    }
731
732    #[test]
733    fn same_line_distinct_named_functions_have_distinct_positions() {
734        let entries = walk("function a() {} function b() {}");
735        let a = entries.iter().find(|e| e.name == "a").expect("a present");
736        let b = entries.iter().find(|e| e.name == "b").expect("b present");
737        assert_eq!(a.line, b.line, "both on line 1");
738        assert_ne!(
739            a.start_column, b.start_column,
740            "same-line functions are column-disambiguated"
741        );
742    }
743
744    #[test]
745    fn same_line_anonymous_functions_stay_distinct_via_counter() {
746        let entries = walk("const xs = [() => 1, () => 2];");
747        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
748        assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
749        assert_eq!(entries[0].line, entries[1].line, "both on line 1");
750        assert_ne!(
751            entries[0].name, entries[1].name,
752            "counter keeps them distinct"
753        );
754    }
755
756    #[test]
757    fn source_hash_is_the_content_digest_of_the_function_span() {
758        let src = "function foo() { return 1; }";
759        let entries = walk(src);
760        assert_eq!(entries.len(), 1);
761        assert_eq!(
762            entries[0].source_hash,
763            fallow_cov_protocol::source_hash_for(src.as_bytes())
764        );
765        assert_eq!(entries[0].source_hash.len(), 16);
766        assert!(
767            entries[0]
768                .source_hash
769                .chars()
770                .all(|c| c.is_ascii_hexdigit())
771        );
772    }
773
774    #[test]
775    fn source_hash_survives_line_moves_and_tracks_body_edits() {
776        let original = walk("function foo() { return 1; }");
777        let moved = walk("\n\nfunction foo() { return 1; }");
778        assert_eq!(
779            original[0].source_hash, moved[0].source_hash,
780            "a moved-but-unedited function must keep its source_hash"
781        );
782        let edited = walk("function foo() { return 2; }");
783        assert_ne!(
784            original[0].source_hash, edited[0].source_hash,
785            "an edited body must change the source_hash"
786        );
787    }
788}