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` (accessors
19//!    carry the instrumenter's `get ` / `set ` label), an `ObjectProperty` key
20//!    (shorthand methods, accessors, and function-valued data properties), a
21//!    `VariableDeclarator` binding, a member-assignment target
22//!    (`o.foo = () => {}` -> "foo"), or an anonymous `export default`
23//!    (-> "default"), OR from the callee of the call / `new` expression a
24//!    function is passed to as an argument (`arr.map(cb)` -> "map",
25//!    `foo(cb)` -> "foo", `new Promise(cb)` -> "Promise"). The callee case
26//!    matches `oxc-coverage-instrument`'s opt-in `name_callback_arguments`
27//!    (which `@fallow-cli/beacon` enables), so a callback's static name
28//!    lines up with its runtime-instrumented name instead of both sides drifting
29//!    to different anonymous placeholders.
30//! 2. The function's own `id` (named `function foo() {}`, named function
31//!    expression `const x = function named() {}`).
32//! 3. `(anonymous_N)` with the current counter value; counter then increments.
33//!    Only genuinely unnamed functions reach this: an immediately-invoked
34//!    function expression, an arrow returned from another function, or a
35//!    computed non-string-key call.
36//!
37//! Counter scope is per-file. Reference implementation:
38//! `oxc-coverage-instrument/src/transform.rs` (`resolve_function_name` +
39//! `callback_argument_name`).
40
41use std::path::Path;
42
43use oxc_allocator::Allocator;
44#[allow(clippy::wildcard_imports, reason = "many AST types used")]
45use oxc_ast::ast::*;
46use oxc_ast_visit::{Visit, walk};
47use oxc_parser::Parser;
48use oxc_semantic::ScopeFlags;
49use oxc_span::{SourceType, Span};
50use rustc_hash::FxHashMap;
51
52use crate::js_text::JsText;
53
54/// A single static-inventory entry for one function.
55///
56/// `name` is beacon-compatible (see the module docs for the naming rule).
57/// `line` is 1-based, matching the AST span start. The `start_column` /
58/// `end_line` / `end_column` fields carry the function-node span in the
59/// 1-indexed UTF-16 convention the cross-surface `FunctionIdentity` join key
60/// expects (see `fallow_cov_protocol::FunctionIdentity::start_column`). They
61/// are descriptive metadata: the join hash is `(file, name, line)` only, so
62/// column fidelity never affects the join, only display / same-line
63/// disambiguation.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct InventoryEntry {
66    /// Beacon-compatible function name.
67    pub name: String,
68    /// 1-based source line of the function declaration (node `span.start`).
69    pub line: u32,
70    /// 1-indexed UTF-16 column of the function node start.
71    pub start_column: u32,
72    /// 1-based source line where the function node ends.
73    pub end_line: u32,
74    /// 1-indexed UTF-16 column of the function node end.
75    pub end_column: u32,
76    /// Content digest of the function's full-span source slice
77    /// (`&source[span.start..span.end]`): first 8 bytes of SHA-256 as 16
78    /// lowercase hex characters, via `fallow_cov_protocol::source_hash_for`.
79    /// The slice is the canonical body bytes (signature line + body + closing
80    /// brace, no whitespace normalization), identical for `Function` and
81    /// `ArrowFunctionExpression`. Stable across line moves, so a
82    /// moved-but-unedited function keeps the same hash. `None` for a function
83    /// with more nested functions below it than the cap in `function_hash`,
84    /// the same rule that the complexity walk applies.
85    pub source_hash: Option<String>,
86    /// Whether the name was taken from the callee of the call this function is
87    /// passed to (`arr.map(cb)` -> `map`) instead of from a declaration, a
88    /// binding, or a property key. Lets a consumer describe the function as a
89    /// callback passed to that callee rather than as a declaration.
90    pub is_callback: bool,
91}
92
93/// Rolling state for [`InventoryVisitor::line_col_utf16`]: the last resolved
94/// offset's line index, clamped byte position, and 0-based UTF-16 column.
95/// `line_idx` starts at `usize::MAX` so the first query never matches.
96#[derive(Clone, Copy)]
97struct ColCache {
98    line_idx: usize,
99    byte_end: usize,
100    utf16_units: usize,
101}
102
103impl ColCache {
104    const EMPTY: Self = Self {
105        line_idx: usize::MAX,
106        byte_end: 0,
107        utf16_units: 0,
108    };
109}
110
111#[cfg(test)]
112thread_local! {
113    /// Source bytes that the column lookups encoded on this thread.
114    static UTF16_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
115}
116
117/// Return the bytes that the column lookups encoded on this thread since the
118/// last call.
119#[cfg(test)]
120fn take_utf16_bytes() -> usize {
121    UTF16_BYTES.with(|encoded| encoded.replace(0))
122}
123
124/// A function that the inventory walk is inside.
125struct OpenFunction {
126    /// Index of its entry in [`InventoryVisitor::entries`].
127    entry: usize,
128    span: Span,
129    /// Levels of nested functions below it so far.
130    height: u32,
131}
132
133/// A resolved function name plus how it was resolved.
134struct ResolvedName {
135    name: String,
136    /// `true` only when the name came from a call / `new` callee.
137    is_callback: bool,
138}
139
140impl ResolvedName {
141    const fn named(name: String) -> Self {
142        Self {
143            name,
144            is_callback: false,
145        }
146    }
147}
148
149/// Visitor that collects [`InventoryEntry`] values in file traversal order.
150struct InventoryVisitor<'a> {
151    source: &'a str,
152    line_offsets: &'a [u32],
153    entries: Vec<InventoryEntry>,
154    /// Column cursors for span starts and span ends. One shared cursor jumps
155    /// across the whole span for each nested function on one line.
156    start_cols: ColCache,
157    end_cols: ColCache,
158    /// Open functions. The hash is computed on leave, when the height is known.
159    open: Vec<OpenFunction>,
160    /// Parent-provided name override (method key, variable binding, etc.).
161    pending_name: Option<String>,
162    /// Callee name for a function passed as a call / `new` argument. Ranks BELOW
163    /// the function's own `id` (a named function expression keeps its id), so it
164    /// is a separate slot from `pending_name` (which ranks above the id).
165    pending_callee_name: Option<String>,
166    /// File-scoped monotonic counter for unnamed functions.
167    anonymous_counter: u32,
168}
169
170impl<'a> InventoryVisitor<'a> {
171    const fn new(source: &'a str, line_offsets: &'a [u32]) -> Self {
172        Self {
173            source,
174            line_offsets,
175            entries: Vec::new(),
176            start_cols: ColCache::EMPTY,
177            end_cols: ColCache::EMPTY,
178            open: Vec::new(),
179            pending_name: None,
180            pending_callee_name: None,
181            anonymous_counter: 0,
182        }
183    }
184
185    /// Resolve a function's name and advance the counter.
186    ///
187    /// Mirrors `oxc-coverage-instrument`'s two-step flow: `resolve_function_name`
188    /// reads the current counter value for the anonymous-case name, and
189    /// `add_function` advances the counter unconditionally on every
190    /// instrumented function (named or not). We collapse both into one call.
191    ///
192    /// Name precedence, matching the instrumenter's `resolve_function_name`:
193    /// parent `pending_name` (method key / variable binding) → function's own
194    /// `id` → call/`new` callee (`pending_callee_name`) → counter.
195    fn resolve_name(&mut self, explicit: Option<&str>) -> ResolvedName {
196        let n = self.anonymous_counter;
197        self.anonymous_counter += 1;
198        if let Some(pending) = self.pending_name.take() {
199            return ResolvedName::named(pending);
200        }
201        if let Some(name) = explicit {
202            return ResolvedName::named(name.to_owned());
203        }
204        if let Some(callee) = self.pending_callee_name.take() {
205            return ResolvedName {
206                name: callee,
207                is_callback: true,
208            };
209        }
210        ResolvedName::named(format!("(anonymous_{n})"))
211    }
212
213    fn record(&mut self, resolved: ResolvedName, span: Span) {
214        let mut start_cols = self.start_cols;
215        let (line, start_column) = self.line_col_utf16(&mut start_cols, span.start);
216        self.start_cols = start_cols;
217        let mut end_cols = self.end_cols;
218        let (end_line, end_column) = self.line_col_utf16(&mut end_cols, span.end);
219        self.end_cols = end_cols;
220        self.open.push(OpenFunction {
221            entry: self.entries.len(),
222            span,
223            height: 0,
224        });
225        self.entries.push(InventoryEntry {
226            name: resolved.name,
227            line,
228            start_column,
229            end_line,
230            end_column,
231            source_hash: None,
232            is_callback: resolved.is_callback,
233        });
234    }
235
236    /// Close the function that [`Self::record`] opened last and hash it.
237    fn leave_function(&mut self) {
238        let Some(open) = self.open.pop() else {
239            return;
240        };
241        self.entries[open.entry].source_hash =
242            crate::function_hash::function_source_hash(self.source, open.span, open.height);
243        if let Some(parent) = self.open.last_mut() {
244            parent.height = parent.height.max(open.height + 1);
245        }
246    }
247
248    /// Map a UTF-8 byte offset to `(1-based line, 1-indexed UTF-16 column)`.
249    ///
250    /// The line comes from the precomputed offset table; the column counts
251    /// UTF-16 code units from the line start to `byte_offset`, matching the
252    /// `FunctionIdentity` column convention (Istanbul / V8 / oxc all normalize
253    /// to 1-indexed UTF-16). A byte offset that does not fall on a char
254    /// boundary (it always should for an AST span) clamps to the nearest
255    /// boundary at or before it rather than panicking.
256    ///
257    /// Successive queries on the same line are answered incrementally from
258    /// [`ColCache`]: pre-order traversal emits nearby offsets, so counting
259    /// only the gap to the previous offset keeps the walk linear on
260    /// single-line (minified / generated) files instead of re-encoding the
261    /// full line prefix for every function.
262    fn line_col_utf16(&self, cache: &mut ColCache, byte_offset: u32) -> (u32, u32) {
263        let line_idx = match self.line_offsets.binary_search(&byte_offset) {
264            Ok(idx) => idx,
265            Err(idx) => idx.saturating_sub(1),
266        };
267        let line = line_idx as u32 + 1;
268        let line_start = self.line_offsets[line_idx] as usize;
269        let mut end = byte_offset as usize;
270        while end > line_start && !self.source.is_char_boundary(end) {
271            end -= 1;
272        }
273        // Both `end` and the cached position are char boundaries at or after
274        // `line_start` on the same line, so the gap slice is always valid and
275        // a backward gap never exceeds the cached column.
276        let from_cache = if cache.line_idx == line_idx {
277            if end >= cache.byte_end {
278                self.utf16_len(cache.byte_end, end)
279                    .map(|gap| cache.utf16_units + gap)
280            } else {
281                self.utf16_len(end, cache.byte_end)
282                    .map(|gap| cache.utf16_units - gap)
283            }
284        } else {
285            None
286        };
287        let col_utf16 = from_cache.unwrap_or_else(|| self.utf16_len(line_start, end).unwrap_or(0));
288        *cache = ColCache {
289            line_idx,
290            byte_end: end,
291            utf16_units: col_utf16,
292        };
293        (line, col_utf16 as u32 + 1)
294    }
295
296    /// UTF-16 code-unit count of `source[start..end]`, `None` when the range
297    /// is not sliceable.
298    fn utf16_len(&self, start: usize, end: usize) -> Option<usize> {
299        let slice = self.source.get(start..end)?;
300        #[cfg(test)]
301        UTF16_BYTES.with(|encoded| encoded.set(encoded.get() + slice.len()));
302        Some(slice.encode_utf16().count())
303    }
304}
305
306impl<'ast> Visit<'ast> for InventoryVisitor<'_> {
307    fn visit_function(&mut self, func: &Function<'ast>, flags: ScopeFlags) {
308        if func.body.is_none() {
309            walk::walk_function(self, func, flags);
310            return;
311        }
312        let resolved = self.resolve_name(func.id.as_ref().map(|id| id.name.as_str()));
313        self.record(resolved, func.span);
314        walk::walk_function(self, func, flags);
315        self.leave_function();
316    }
317
318    fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'ast>) {
319        let resolved = self.resolve_name(None);
320        self.record(resolved, arrow.span);
321        walk::walk_arrow_function_expression(self, arrow);
322        self.leave_function();
323    }
324
325    fn visit_method_definition(&mut self, method: &MethodDefinition<'ast>) {
326        if let Some(name) = method.key.static_name() {
327            self.pending_name = Some(accessor_label(
328                matches!(method.kind, MethodDefinitionKind::Get),
329                matches!(method.kind, MethodDefinitionKind::Set),
330                &name.text(),
331            ));
332        }
333        walk::walk_method_definition(self, method);
334        self.pending_name = None;
335    }
336
337    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'ast>) {
338        if let Some(id) = decl.id.get_binding_identifier()
339            && decl.init.as_ref().is_some_and(|init| {
340                matches!(
341                    init,
342                    Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
343                )
344            })
345        {
346            self.pending_name = Some(id.name.to_string());
347        }
348        walk::walk_variable_declarator(self, decl);
349        self.pending_name = None;
350    }
351
352    /// Carry an object-literal key into the function it holds, matching the
353    /// instrumenter's `register_object_property`: shorthand methods, accessors
354    /// (prefixed `get ` / `set `), and function-valued data properties inherit
355    /// the key name. A data property holding anything else (a call whose
356    /// argument is a callback, most of all) inherits nothing, so the callback
357    /// still takes its own callee's name.
358    fn visit_object_property(&mut self, prop: &ObjectProperty<'ast>) {
359        let is_method_like =
360            prop.method || matches!(prop.kind, PropertyKind::Get | PropertyKind::Set);
361        let is_function_valued = matches!(
362            prop.value,
363            Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_)
364        );
365        self.pending_name = if is_method_like || is_function_valued {
366            prop.key.static_name().map(|name| {
367                accessor_label(
368                    matches!(prop.kind, PropertyKind::Get),
369                    matches!(prop.kind, PropertyKind::Set),
370                    &name.text(),
371                )
372            })
373        } else {
374            None
375        };
376        walk::walk_object_property(self, prop);
377        self.pending_name = None;
378    }
379
380    /// Carry a member-assignment target into the function assigned to it
381    /// (`client.execute = () => {}` -> "execute"), matching the instrumenter's
382    /// `assignment_target_name`. A plain identifier target takes its name from
383    /// the binding instead, so it is left alone.
384    fn visit_assignment_expression(&mut self, expr: &AssignmentExpression<'ast>) {
385        if matches!(expr.operator, AssignmentOperator::Assign)
386            && matches!(
387                expr.right,
388                Expression::FunctionExpression(_)
389                    | Expression::ArrowFunctionExpression(_)
390                    | Expression::ClassExpression(_)
391            )
392        {
393            self.pending_name = assignment_target_name(&expr.left);
394        }
395        walk::walk_assignment_expression(self, expr);
396        self.pending_name = None;
397    }
398
399    /// Name an anonymous default export "default", matching the instrumenter's
400    /// `name_anonymous_default_export`. A named one keeps its own identifier.
401    fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'ast>) {
402        let anonymous = match &decl.declaration {
403            ExportDefaultDeclarationKind::FunctionDeclaration(func) => func.id.is_none(),
404            ExportDefaultDeclarationKind::ArrowFunctionExpression(_) => true,
405            _ => false,
406        };
407        if anonymous {
408            self.pending_name = Some("default".to_owned());
409        }
410        walk::walk_export_default_declaration(self, decl);
411        self.pending_name = None;
412    }
413
414    /// Name each function-valued argument from the callee (`arr.map(cb)` ->
415    /// "map", `foo(cb)` -> "foo"), matching `oxc-coverage-instrument`'s
416    /// `name_callback_arguments`. The callee subtree is visited FIRST with no
417    /// inherited name, so a chained call (`a.b().c(cb)`) never leaks `b` onto
418    /// `c`'s callback; the callee's own name is then applied afresh to each
419    /// argument. A binding name from a parent (declarator / method) is already
420    /// consumed by its direct function child before the body's calls, so it
421    /// never collides here. Type arguments are skipped (types hold no function
422    /// to inventory).
423    fn visit_call_expression(&mut self, call: &CallExpression<'ast>) {
424        self.visit_expression(&call.callee);
425        let name = callee_name(&call.callee);
426        for argument in &call.arguments {
427            self.pending_callee_name.clone_from(&name);
428            self.visit_argument(argument);
429        }
430        self.pending_callee_name = None;
431    }
432
433    fn visit_new_expression(&mut self, new_expr: &NewExpression<'ast>) {
434        self.visit_expression(&new_expr.callee);
435        let name = callee_name(&new_expr.callee);
436        for argument in &new_expr.arguments {
437            self.pending_callee_name.clone_from(&name);
438            self.visit_argument(argument);
439        }
440        self.pending_callee_name = None;
441    }
442}
443
444/// Prefix an accessor's name the way `oxc-coverage-instrument` labels one, so
445/// a getter reads `get closed` on both sides of the join. A plain method or
446/// data property keeps its bare key.
447fn accessor_label(is_getter: bool, is_setter: bool, name: &str) -> String {
448    if is_getter {
449        return format!("get {name}");
450    }
451    if is_setter {
452        return format!("set {name}");
453    }
454    name.to_owned()
455}
456
457/// Extract a name from a member-assignment target, matching the instrumenter's
458/// `assignment_target_name`: a static member uses the property, a computed
459/// member uses a string-literal key, and anything else yields no name.
460fn assignment_target_name(target: &AssignmentTarget<'_>) -> Option<String> {
461    match target {
462        AssignmentTarget::StaticMemberExpression(member) => Some(member.property.name.to_string()),
463        AssignmentTarget::ComputedMemberExpression(member) => match &member.expression {
464            Expression::StringLiteral(lit) => Some(lit.value.text().into_owned()),
465            _ => None,
466        },
467        _ => None,
468    }
469}
470
471/// Extract a display name from a call / `new` callee, matching
472/// `oxc-coverage-instrument`'s `callee_name`: a bare identifier keeps its name,
473/// a member access uses the (last) property, and a computed access uses a
474/// string-literal key. Anything else (a computed non-string index, a call
475/// result, a parenthesized expression) yields no name.
476fn callee_name(callee: &Expression<'_>) -> Option<String> {
477    match callee {
478        Expression::Identifier(ident) => Some(ident.name.to_string()),
479        Expression::StaticMemberExpression(member) => Some(member.property.name.to_string()),
480        Expression::ComputedMemberExpression(member) => match &member.expression {
481            Expression::StringLiteral(lit) => Some(lit.value.text().into_owned()),
482            _ => None,
483        },
484        // A parenthesized callee (`(foo)(cb)`, `(a.b)(cb)`) unwraps to its inner
485        // callee, matching the instrumenter. oxc keeps paren nodes by default
486        // (`preserve_parens`), so both sides see this node.
487        Expression::ParenthesizedExpression(paren) => callee_name(&paren.expression),
488        _ => None,
489    }
490}
491
492/// Per-function static complexity collected alongside the inventory walk.
493///
494/// Keyed to an [`InventoryEntry`] by its `source_hash`, which both this and the
495/// inventory walk derive from the identical full-span byte slice over the same
496/// parsed program (see [`InventoryEntry::source_hash`]). The hash is stable
497/// across line moves, so the pairing survives reformatting that shifts line
498/// numbers. `cyclomatic` and `cognitive` are descriptive context for downstream
499/// importance weighting, never thresholds.
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501pub struct InventoryComplexity {
502    /// `McCabe` cyclomatic complexity (1 + decision points).
503    pub cyclomatic: u16,
504    /// `SonarSource` cognitive complexity (structural + nesting penalty).
505    pub cognitive: u16,
506}
507
508/// Parse `source` at `path` and return every function as an [`InventoryEntry`].
509///
510/// Only plain JS/TS/JSX/TSX sources are supported. Callers should skip SFC,
511/// Astro, MDX, CSS, HTML, and other non-JS inputs; those use different
512/// instrumentation paths and are out of scope for the first inventory release.
513///
514/// Errors are swallowed: the returned vector covers whatever could be parsed.
515/// This mirrors how the rest of the extract pipeline handles partial parse
516/// results.
517#[must_use]
518pub fn walk_source(path: &Path, source: &str) -> Vec<InventoryEntry> {
519    walk_source_with_complexity(path, source).0
520}
521
522/// Parse `source` at `path` once and return every function as an
523/// [`InventoryEntry`] together with a `source_hash -> InventoryComplexity` map.
524///
525/// Both the inventory entries and the complexity map come from the SAME parse
526/// (including the JSX fallback retry), so the per-function `source_hash` values
527/// line up exactly and a caller can enrich each entry's metrics by a hash
528/// lookup. Functions whose span slice could not be sliced share the empty-input
529/// hash and simply don't pair; that degrades to "no metrics", never a panic.
530///
531/// Errors are swallowed, matching [`walk_source`]: the returned data covers
532/// whatever could be parsed.
533#[must_use]
534pub fn walk_source_with_complexity(
535    path: &Path,
536    source: &str,
537) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
538    let source_type = SourceType::from_path(path).unwrap_or_default();
539    let line_offsets = fallow_types::extract::compute_line_offsets(source);
540
541    let primary = walk_one_parse(source, source_type, &line_offsets);
542    if primary.0.is_empty() && !source_type.is_jsx() {
543        let jsx_type = if source_type.is_typescript() {
544            SourceType::tsx()
545        } else {
546            SourceType::jsx()
547        };
548        let retry = walk_one_parse(source, jsx_type, &line_offsets);
549        if !retry.0.is_empty() {
550            return retry;
551        }
552    }
553
554    primary
555}
556
557/// Run both the inventory and complexity visitors over a single parse of
558/// `source` under `source_type`, pairing them by `source_hash`.
559fn walk_one_parse(
560    source: &str,
561    source_type: SourceType,
562    line_offsets: &[u32],
563) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
564    crate::parse_bounded(source, || {
565        walk_one_parse_unbounded(source, source_type, line_offsets)
566    })
567    .unwrap_or_default()
568}
569
570fn walk_one_parse_unbounded(
571    source: &str,
572    source_type: SourceType,
573    line_offsets: &[u32],
574) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
575    let allocator = Allocator::default();
576    let parser_return = Parser::new(&allocator, source, source_type).parse();
577
578    let mut visitor = InventoryVisitor::new(source, line_offsets);
579    visitor.visit_program(&parser_return.program);
580
581    let complexity =
582        crate::complexity::compute_complexity(&parser_return.program, source, line_offsets);
583    let metrics: FxHashMap<String, InventoryComplexity> = complexity
584        .into_iter()
585        .filter_map(|fc| {
586            fc.source_hash.map(|hash| {
587                (
588                    hash,
589                    InventoryComplexity {
590                        cyclomatic: fc.cyclomatic,
591                        cognitive: fc.cognitive,
592                    },
593                )
594            })
595        })
596        .collect();
597
598    (visitor.entries, metrics)
599}
600
601#[cfg(all(test, not(miri)))]
602mod tests {
603    use super::*;
604    use std::path::PathBuf;
605
606    fn walk(source: &str) -> Vec<InventoryEntry> {
607        walk_source(&PathBuf::from("test.ts"), source)
608    }
609
610    #[test]
611    fn named_function_declaration_uses_its_own_name() {
612        let entries = walk("function foo() { return 1; }");
613        assert_eq!(entries.len(), 1);
614        assert_eq!(entries[0].name, "foo");
615        assert_eq!(entries[0].line, 1);
616    }
617
618    #[test]
619    fn const_arrow_captures_binding_name() {
620        let entries = walk("const bar = () => 42;");
621        assert_eq!(entries.len(), 1);
622        assert_eq!(entries[0].name, "bar");
623    }
624
625    #[test]
626    fn const_function_expression_captures_binding_name_not_fn_id() {
627        let entries = walk("const outer = function inner() { return 1; };");
628        assert_eq!(entries.len(), 1);
629        assert_eq!(entries[0].name, "outer");
630    }
631
632    #[test]
633    fn class_methods_use_method_names() {
634        let entries = walk(
635            r"
636            class Foo {
637              bar() { return 1; }
638              baz() { return 2; }
639            }",
640        );
641        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
642        assert_eq!(names, vec!["bar", "baz"]);
643    }
644
645    #[test]
646    fn callback_argument_takes_the_callee_name() {
647        // An arrow passed as a call argument now takes the callee name (matches
648        // the instrumenter's name_callback_arguments), not the anonymous counter.
649        let entries = walk("setTimeout(() => { console.log('hi'); }, 10);");
650        assert_eq!(entries.len(), 1);
651        assert_eq!(entries[0].name, "setTimeout");
652    }
653
654    #[test]
655    fn member_callee_names_each_callback_in_source_order() {
656        let entries = walk(
657            r"
658            [1, 2, 3].map(() => 1);
659            [4, 5, 6].filter(() => true);
660            ",
661        );
662        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
663        assert_eq!(names, vec!["map", "filter"]);
664    }
665
666    #[test]
667    fn named_function_still_advances_counter_matching_instrumenter() {
668        // The counter still advances on every function (named or callee-named),
669        // matching the instrumenter, so a later genuinely-anonymous function
670        // gets the right N. Here the callback is callee-named "map".
671        let entries = walk(
672            r"
673            function named() { return 1; }
674            [1].map(() => 2);
675            ",
676        );
677        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
678        assert_eq!(names, vec!["named", "map"]);
679    }
680
681    #[test]
682    fn plain_identifier_callee_names_the_callback() {
683        let entries = walk("useMemo(() => compute());");
684        assert_eq!(entries[0].name, "useMemo");
685    }
686
687    #[test]
688    fn new_expression_callee_names_the_callback() {
689        let entries = walk("new Promise((resolve) => resolve(1));");
690        assert_eq!(entries[0].name, "Promise");
691    }
692
693    #[test]
694    fn callback_after_a_string_argument_is_named_from_the_callee() {
695        // The event/route-handler shape: the function is a later argument, after
696        // a string. It is named from the callee, not the string.
697        let entries = walk(r#"el.addEventListener("click", () => handle());"#);
698        assert_eq!(entries[0].name, "addEventListener");
699    }
700
701    #[test]
702    fn computed_string_key_callee_is_named() {
703        let entries = walk(r#"obj["handler"](() => run());"#);
704        assert_eq!(entries[0].name, "handler");
705    }
706
707    #[test]
708    fn chained_call_does_not_leak_the_earlier_callee_onto_the_later_callback() {
709        // `.then`'s callback must be "then" and `.catch`'s must be "catch": the
710        // callee subtree (`p.then(cb).catch`) is visited before the outer
711        // arguments, so the earlier callee never leaks onto the later callback.
712        let entries = walk("p.then(() => a).catch(() => b);");
713        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
714        assert_eq!(names, vec!["then", "catch"]);
715    }
716
717    #[test]
718    fn nested_callbacks_each_take_their_own_callee() {
719        let entries = walk("outer(() => inner(() => 1));");
720        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
721        assert_eq!(names, vec!["outer", "inner"]);
722    }
723
724    #[test]
725    fn binding_name_wins_over_callee() {
726        // A declarator binding is consumed on function entry, before the body's
727        // calls, so a bound arrow keeps its name even when its body is a call.
728        let entries = walk("const handler = () => run();");
729        assert_eq!(entries[0].name, "handler");
730    }
731
732    #[test]
733    fn named_function_expression_argument_keeps_its_own_id() {
734        let entries = walk("run(function inner() { return 1; });");
735        assert_eq!(entries[0].name, "inner");
736    }
737
738    #[test]
739    fn iife_callee_stays_anonymous() {
740        // The function is the callee, not an argument, so it is not a callback.
741        let entries = walk("(function () { return 1; })();");
742        assert_eq!(entries[0].name, "(anonymous_0)");
743    }
744
745    #[test]
746    fn computed_non_string_callee_stays_anonymous() {
747        let entries = walk("handlers[index](() => run());");
748        assert_eq!(entries[0].name, "(anonymous_0)");
749    }
750
751    #[test]
752    fn parenthesized_callee_unwraps_to_the_inner_name() {
753        assert_eq!(walk("(foo)(() => run());")[0].name, "foo");
754        assert_eq!(walk("(a.b)(() => run());")[0].name, "b");
755    }
756
757    #[test]
758    fn anonymous_after_named_chain_uses_next_counter_value() {
759        let entries = walk(
760            r"
761            function a() {}
762            function b() {}
763            function c() {}
764            const d = () => 4;
765            ",
766        );
767        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
768        assert_eq!(names, vec!["a", "b", "c", "d"]);
769    }
770
771    #[test]
772    fn typescript_overload_signatures_dont_emit_or_advance_counter() {
773        let entries = walk(
774            r"
775            function foo(): number;
776            function foo(s: string): string;
777            function foo(s?: string): number | string { return s ? s : 1; }
778            [1].map(() => 2);
779            ",
780        );
781        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
782        assert_eq!(names, vec!["foo", "map"]);
783    }
784
785    #[test]
786    fn export_default_named_function_keeps_explicit_name() {
787        let entries = walk("export default function foo() { return 1; }");
788        assert_eq!(entries.len(), 1);
789        assert_eq!(entries[0].name, "foo");
790    }
791
792    #[test]
793    fn export_default_anonymous_function_is_named_default() {
794        let entries = walk("export default function() { return 1; }");
795        assert_eq!(entries.len(), 1);
796        assert_eq!(entries[0].name, "default");
797    }
798
799    #[test]
800    fn export_default_anonymous_arrow_is_named_default() {
801        let entries = walk("export default () => 1;");
802        assert_eq!(entries.len(), 1);
803        assert_eq!(entries[0].name, "default");
804    }
805
806    #[test]
807    fn nested_function_numbered_after_parent_in_traversal_order() {
808        let entries = walk(
809            r"
810            function outer() {
811              return function() { return 1; };
812            }",
813        );
814        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
815        assert_eq!(names, vec!["outer", "(anonymous_1)"]);
816    }
817
818    #[test]
819    fn line_number_is_one_based_from_source_start() {
820        let entries = walk("\n\nfunction atLineThree() {}");
821        assert_eq!(entries.len(), 1);
822        assert_eq!(entries[0].line, 3);
823    }
824
825    #[test]
826    fn short_jsx_in_js_file_retries_with_jsx_parser() {
827        let entries = walk_source(&PathBuf::from("component.js"), "const A = () => <div />;");
828        assert_eq!(entries.len(), 1);
829        assert_eq!(entries[0].name, "A");
830        assert_eq!(entries[0].line, 1);
831    }
832
833    #[test]
834    fn object_method_shorthand_uses_the_key_name() {
835        let entries = walk("const obj = { run() { return 1; } };");
836        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
837        assert_eq!(names, vec!["run"]);
838    }
839
840    #[test]
841    fn object_accessors_carry_the_get_and_set_label() {
842        let entries = walk(
843            r"
844            const obj = {
845              get closed() { return true; },
846              set closed(value) { this.value = value; },
847            };",
848        );
849        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
850        assert_eq!(names, vec!["get closed", "set closed"]);
851    }
852
853    #[test]
854    fn function_valued_object_property_uses_the_key_name() {
855        let entries = walk("const client = { execute: async (sql) => sql.length };");
856        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
857        assert_eq!(names, vec!["execute"]);
858    }
859
860    #[test]
861    fn object_property_holding_a_call_leaves_the_callback_to_its_callee() {
862        // The key names only a function-valued property. A property whose value
863        // is a call must not steal the name from that call's callback.
864        let entries = walk("const column = { references: memo(() => other.id) };");
865        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
866        assert_eq!(names, vec!["memo"]);
867    }
868
869    #[test]
870    fn class_accessors_carry_the_get_and_set_label() {
871        let entries = walk(
872            r"
873            class Client {
874              get closed() { return true; }
875              set closed(value) { this.value = value; }
876            }",
877        );
878        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
879        assert_eq!(names, vec!["get closed", "set closed"]);
880    }
881
882    #[test]
883    fn member_assignment_target_names_the_assigned_function() {
884        let entries = walk(
885            r#"
886            client.execute = async (sql) => sql;
887            client["rollback"] = function () { return 1; };
888            "#,
889        );
890        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
891        assert_eq!(names, vec!["execute", "rollback"]);
892    }
893
894    #[test]
895    fn only_callee_named_functions_are_flagged_as_callbacks() {
896        let entries = walk(
897            r"
898            function declared() { return 1; }
899            const bound = () => 2;
900            const obj = { run() { return 3; } };
901            [1].map(() => 4);
902            (function () { return 5; })();
903            ",
904        );
905        let flags: Vec<_> = entries
906            .iter()
907            .map(|e| (e.name.as_str(), e.is_callback))
908            .collect();
909        assert_eq!(
910            flags,
911            vec![
912                ("declared", false),
913                ("bound", false),
914                ("run", false),
915                ("map", true),
916                ("(anonymous_4)", false),
917            ]
918        );
919    }
920
921    #[test]
922    fn drizzle_style_schema_names_every_callback_after_its_callee() {
923        // The shape a Drizzle schema module has: a table callback, a column
924        // reference callback, and an index callback, all anonymous arrows that
925        // the instrumenter names after the function they are passed to.
926        let entries = walk(
927            r#"
928            export const users = sqliteTable("users", {
929              id: text("id").primaryKey(),
930              orgId: text("org_id").references(() => orgs.id),
931            }, (table) => [index("users_org_idx").on(table.orgId)]);
932            "#,
933        );
934        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
935        assert_eq!(names, vec!["references", "sqliteTable"]);
936        assert!(entries.iter().all(|entry| entry.is_callback));
937    }
938
939    #[test]
940    fn class_property_arrow_uses_anonymous_counter() {
941        let entries = walk(
942            r"
943            class Foo {
944              bar = () => 1;
945            }",
946        );
947        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
948        assert_eq!(names, vec!["(anonymous_0)"]);
949    }
950
951    #[test]
952    fn records_one_indexed_utf16_columns() {
953        let entries = walk("function foo() { return 1; }");
954        assert_eq!(entries.len(), 1);
955        assert_eq!(entries[0].start_column, 1);
956        assert_eq!(entries[0].end_line, 1);
957        assert!(entries[0].end_column > entries[0].start_column);
958    }
959
960    #[test]
961    fn utf16_column_counts_code_units_not_bytes() {
962        let entries = walk("const e = \"\u{1F600}\"; const f = () => 1;");
963        let f = entries.iter().find(|e| e.name == "f").expect("f present");
964        let byte_prefix_len = "const e = \"\u{1F600}\"; const f = ".len() as u32;
965        assert!(f.start_column < byte_prefix_len + 1);
966    }
967
968    #[test]
969    fn utf16_columns_stay_exact_across_a_long_single_line() {
970        // Minified shape: many functions with non-ASCII content on one line.
971        // Columns must match a naive full-prefix UTF-16 count even though the
972        // walker resolves them incrementally, including the backward offset
973        // jump from `outer`'s end to `inner`'s start and the reset to line 2.
974        use std::fmt::Write as _;
975        let mut src = String::new();
976        for i in 0..40 {
977            let _ = write!(src, "function f{i}() {{ return \"\u{1F600}\"; }} ");
978        }
979        src.push_str("function outer() { const inner = () => \"\u{1F600}\"; return inner; }");
980        src.push_str("\nconst tail = () => 1;");
981        let entries = walk(&src);
982        let col = |byte: usize| src[..byte].encode_utf16().count() as u32 + 1;
983
984        for i in [0_usize, 17, 39] {
985            let body = format!("function f{i}() {{ return \"\u{1F600}\"; }}");
986            let start = src.find(&body).expect("function text present");
987            let entry = entries
988                .iter()
989                .find(|e| e.name == format!("f{i}"))
990                .expect("entry present");
991            assert_eq!(entry.line, 1);
992            assert_eq!(entry.start_column, col(start));
993            assert_eq!(entry.end_line, 1);
994            assert_eq!(entry.end_column, col(start + body.len()));
995        }
996
997        let inner_start = src
998            .find("() => \"\u{1F600}\"")
999            .expect("inner arrow present");
1000        let inner = entries
1001            .iter()
1002            .find(|e| e.name == "inner")
1003            .expect("inner present");
1004        assert_eq!(inner.line, 1);
1005        assert_eq!(inner.start_column, col(inner_start));
1006
1007        let tail = entries
1008            .iter()
1009            .find(|e| e.name == "tail")
1010            .expect("tail present");
1011        let line2_start = src.find('\n').expect("newline present") + 1;
1012        let tail_start = src.rfind("() => 1").expect("tail arrow present");
1013        assert_eq!(tail.line, 2);
1014        assert_eq!(
1015            tail.start_column,
1016            src[line2_start..tail_start].encode_utf16().count() as u32 + 1
1017        );
1018    }
1019
1020    #[test]
1021    fn same_line_distinct_named_functions_have_distinct_positions() {
1022        let entries = walk("function a() {} function b() {}");
1023        let a = entries.iter().find(|e| e.name == "a").expect("a present");
1024        let b = entries.iter().find(|e| e.name == "b").expect("b present");
1025        assert_eq!(a.line, b.line, "both on line 1");
1026        assert_ne!(
1027            a.start_column, b.start_column,
1028            "same-line functions are column-disambiguated"
1029        );
1030    }
1031
1032    #[test]
1033    fn same_line_anonymous_functions_stay_distinct_via_counter() {
1034        let entries = walk("const xs = [() => 1, () => 2];");
1035        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1036        assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
1037        assert_eq!(entries[0].line, entries[1].line, "both on line 1");
1038        assert_ne!(
1039            entries[0].name, entries[1].name,
1040            "counter keeps them distinct"
1041        );
1042    }
1043
1044    /// A chain of `levels` nested arrows on one line.
1045    fn nested_arrow_source(levels: usize) -> String {
1046        format!("export const f = {}1;\n", "()=>".repeat(levels))
1047    }
1048
1049    #[test]
1050    fn deep_nesting_keeps_the_hash_join_with_complexity() {
1051        let levels = crate::function_hash::MAX_HASHED_FUNCTION_HEIGHT as usize + 3;
1052        let source = nested_arrow_source(levels);
1053        let (entries, complexity) = walk_source_with_complexity(&PathBuf::from("a.js"), &source);
1054        assert_eq!(entries.len(), levels);
1055        // Pre-order: the outermost arrow comes first and has the most levels
1056        // of nested functions below it.
1057        for (index, entry) in entries.iter().enumerate() {
1058            let height = levels - 1 - index;
1059            let kept = height <= crate::function_hash::MAX_HASHED_FUNCTION_HEIGHT as usize;
1060            assert_eq!(entry.source_hash.is_some(), kept, "height {height}");
1061            if let Some(hash) = &entry.source_hash {
1062                assert!(
1063                    complexity.contains_key(hash),
1064                    "height {height} has no metrics"
1065                );
1066            }
1067        }
1068    }
1069
1070    #[test]
1071    fn column_lookups_stay_linear_on_nested_functions_in_one_line() {
1072        // The counter is per thread, so the walk must stay on this thread:
1073        // a thread with a registered stack keeps the bounded parse on it.
1074        const STACK: usize = 64 << 20;
1075        let (encoded, entries, source_len) = std::thread::Builder::new()
1076            .stack_size(STACK)
1077            .spawn(|| {
1078                crate::register_thread_stack(STACK);
1079                take_utf16_bytes();
1080                let source = nested_arrow_source(4_000);
1081                let entries = walk_source(&PathBuf::from("a.js"), &source);
1082                (take_utf16_bytes(), entries.len(), source.len())
1083            })
1084            .expect("probe thread should start")
1085            .join()
1086            .expect("probe thread should finish");
1087        assert_eq!(entries, 4_000);
1088        assert!(
1089            encoded <= 4 * source_len,
1090            "encoded {encoded} bytes for {source_len}"
1091        );
1092    }
1093
1094    #[test]
1095    fn source_hash_is_the_content_digest_of_the_function_span() {
1096        let src = "function foo() { return 1; }";
1097        let entries = walk(src);
1098        assert_eq!(entries.len(), 1);
1099        let hash = entries[0]
1100            .source_hash
1101            .as_deref()
1102            .expect("a shallow function keeps its hash");
1103        assert_eq!(hash, fallow_cov_protocol::source_hash_for(src.as_bytes()));
1104        assert_eq!(hash.len(), 16);
1105        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1106    }
1107
1108    #[test]
1109    fn source_hash_survives_line_moves_and_tracks_body_edits() {
1110        let original = walk("function foo() { return 1; }");
1111        let moved = walk("\n\nfunction foo() { return 1; }");
1112        assert_eq!(
1113            original[0].source_hash, moved[0].source_hash,
1114            "a moved-but-unedited function must keep its source_hash"
1115        );
1116        let edited = walk("function foo() { return 2; }");
1117        assert_ne!(
1118            original[0].source_hash, edited[0].source_hash,
1119            "an edited body must change the source_hash"
1120        );
1121    }
1122}