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