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 `MethodDefinition`,
19//!    `VariableDeclarator`), same pattern as the internal complexity visitor.
20//! 2. The function's own `id` (named `function foo() {}`, named function
21//!    expression `const x = function named() {}`).
22//! 3. `(anonymous_N)` with the current counter value; counter then increments.
23//!
24//! Counter scope is per-file. Reference implementation:
25//! `oxc-coverage-instrument/src/transform.rs` (`fn_counter` field; lines 201
26//! and 612 at the time of writing).
27
28use std::path::Path;
29
30use oxc_allocator::Allocator;
31#[allow(clippy::wildcard_imports, reason = "many AST types used")]
32use oxc_ast::ast::*;
33use oxc_ast_visit::{Visit, walk};
34use oxc_parser::Parser;
35use oxc_semantic::ScopeFlags;
36use oxc_span::{SourceType, Span};
37
38/// A single static-inventory entry: `(name, line)` for one function.
39///
40/// `name` is beacon-compatible (see the module docs for the naming rule).
41/// `line` is 1-based, matching the AST span start.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct InventoryEntry {
44    /// Beacon-compatible function name.
45    pub name: String,
46    /// 1-based source line of the function declaration.
47    pub line: u32,
48}
49
50/// Visitor that collects [`InventoryEntry`] values in file traversal order.
51struct InventoryVisitor<'a> {
52    line_offsets: &'a [u32],
53    entries: Vec<InventoryEntry>,
54    /// Parent-provided name override (method key, variable binding, etc.).
55    pending_name: Option<String>,
56    /// File-scoped monotonic counter for unnamed functions.
57    anonymous_counter: u32,
58}
59
60impl<'a> InventoryVisitor<'a> {
61    const fn new(line_offsets: &'a [u32]) -> Self {
62        Self {
63            line_offsets,
64            entries: Vec::new(),
65            pending_name: None,
66            anonymous_counter: 0,
67        }
68    }
69
70    /// Resolve a function's name and advance the counter.
71    ///
72    /// Mirrors `oxc-coverage-instrument`'s two-step flow: `resolve_function_name`
73    /// reads the current counter value for the anonymous-case name, and
74    /// `add_function` advances the counter unconditionally on every
75    /// instrumented function (named or not). We collapse both into one call.
76    ///
77    /// Name precedence: parent `pending_name` (method key / variable binding)
78    /// → function's own `id` → counter.
79    fn resolve_name(&mut self, explicit: Option<&str>) -> String {
80        let n = self.anonymous_counter;
81        self.anonymous_counter += 1;
82        if let Some(pending) = self.pending_name.take() {
83            return pending;
84        }
85        if let Some(name) = explicit {
86            return name.to_owned();
87        }
88        format!("(anonymous_{n})")
89    }
90
91    fn record(&mut self, name: String, span: Span) {
92        let (line, _col) =
93            fallow_types::extract::byte_offset_to_line_col(self.line_offsets, span.start);
94        self.entries.push(InventoryEntry { name, line });
95    }
96}
97
98impl<'ast> Visit<'ast> for InventoryVisitor<'_> {
99    fn visit_function(&mut self, func: &Function<'ast>, flags: ScopeFlags) {
100        // Bodyless functions (TypeScript overload signatures, `abstract`
101        // class methods, `declare function ...`) are not instrumented at
102        // runtime. The instrumenter only calls `add_function` when a body
103        // exists, so neither recording an entry nor advancing the counter
104        // for these signatures keeps our naming in lockstep.
105        if func.body.is_none() {
106            walk::walk_function(self, func, flags);
107            return;
108        }
109        let name = self.resolve_name(func.id.as_ref().map(|id| id.name.as_str()));
110        self.record(name, func.span);
111        walk::walk_function(self, func, flags);
112    }
113
114    fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'ast>) {
115        let name = self.resolve_name(None);
116        self.record(name, arrow.span);
117        walk::walk_arrow_function_expression(self, arrow);
118    }
119
120    fn visit_method_definition(&mut self, method: &MethodDefinition<'ast>) {
121        if let Some(name) = method.key.static_name() {
122            self.pending_name = Some(name.to_string());
123        }
124        walk::walk_method_definition(self, method);
125        self.pending_name = None;
126    }
127
128    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'ast>) {
129        if let Some(id) = decl.id.get_binding_identifier()
130            && decl.init.as_ref().is_some_and(|init| {
131                matches!(
132                    init,
133                    Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
134                )
135            })
136        {
137            self.pending_name = Some(id.name.to_string());
138        }
139        walk::walk_variable_declarator(self, decl);
140        self.pending_name = None;
141    }
142
143    fn visit_object_property(&mut self, prop: &ObjectProperty<'ast>) {
144        // Object-literal methods (`{ run() {} }`) and arrow properties
145        // (`{ run: () => 1 }`) intentionally do NOT inherit the outer
146        // variable binding's name. Clear any pending_name leaked from an
147        // ancestor (e.g., `const obj = { run() {} }`) so the inner function
148        // falls through to the anonymous counter, matching the e2e
149        // verification against `oxc-coverage-instrument`.
150        self.pending_name = None;
151        walk::walk_object_property(self, prop);
152        self.pending_name = None;
153    }
154}
155
156/// Parse `source` at `path` and return every function as an [`InventoryEntry`].
157///
158/// Only plain JS/TS/JSX/TSX sources are supported. Callers should skip SFC,
159/// Astro, MDX, CSS, HTML, and other non-JS inputs; those use different
160/// instrumentation paths and are out of scope for the first inventory release.
161///
162/// Errors are swallowed: the returned vector covers whatever could be parsed.
163/// This mirrors how the rest of the extract pipeline handles partial parse
164/// results.
165#[must_use]
166pub fn walk_source(path: &Path, source: &str) -> Vec<InventoryEntry> {
167    let source_type = SourceType::from_path(path).unwrap_or_default();
168    let allocator = Allocator::default();
169    let parser_return = Parser::new(&allocator, source, source_type).parse();
170
171    let line_offsets = fallow_types::extract::compute_line_offsets(source);
172    let mut visitor = InventoryVisitor::new(&line_offsets);
173    visitor.visit_program(&parser_return.program);
174
175    // If the initial parse found nothing, retry with JSX/TSX source type
176    // (matches parse.rs fallback for `.js` files that actually contain JSX).
177    // Keep this independent of file length: tiny components such as
178    // `const A = () => <div />;` are common and still need inventory entries.
179    if visitor.entries.is_empty() && !source_type.is_jsx() {
180        let jsx_type = if source_type.is_typescript() {
181            SourceType::tsx()
182        } else {
183            SourceType::jsx()
184        };
185        let allocator2 = Allocator::default();
186        let retry_return = Parser::new(&allocator2, source, jsx_type).parse();
187        let mut retry_visitor = InventoryVisitor::new(&line_offsets);
188        retry_visitor.visit_program(&retry_return.program);
189        if !retry_visitor.entries.is_empty() {
190            return retry_visitor.entries;
191        }
192    }
193
194    visitor.entries
195}
196
197#[cfg(all(test, not(miri)))]
198mod tests {
199    use super::*;
200    use std::path::PathBuf;
201
202    fn walk(source: &str) -> Vec<InventoryEntry> {
203        walk_source(&PathBuf::from("test.ts"), source)
204    }
205
206    #[test]
207    fn named_function_declaration_uses_its_own_name() {
208        let entries = walk("function foo() { return 1; }");
209        assert_eq!(entries.len(), 1);
210        assert_eq!(entries[0].name, "foo");
211        assert_eq!(entries[0].line, 1);
212    }
213
214    #[test]
215    fn const_arrow_captures_binding_name() {
216        let entries = walk("const bar = () => 42;");
217        assert_eq!(entries.len(), 1);
218        assert_eq!(entries[0].name, "bar");
219    }
220
221    #[test]
222    fn const_function_expression_captures_binding_name_not_fn_id() {
223        // When both are present, oxc-coverage-instrument prefers the
224        // parent-provided pending_name (the `const` binding). Our walker
225        // matches that precedence.
226        let entries = walk("const outer = function inner() { return 1; };");
227        assert_eq!(entries.len(), 1);
228        assert_eq!(entries[0].name, "outer");
229    }
230
231    #[test]
232    fn class_methods_use_method_names() {
233        let entries = walk(
234            r"
235            class Foo {
236              bar() { return 1; }
237              baz() { return 2; }
238            }",
239        );
240        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
241        assert_eq!(names, vec!["bar", "baz"]);
242    }
243
244    #[test]
245    fn anonymous_arrow_passed_as_argument_uses_counter() {
246        let entries = walk("setTimeout(() => { console.log('hi'); }, 10);");
247        assert_eq!(entries.len(), 1);
248        assert_eq!(entries[0].name, "(anonymous_0)");
249    }
250
251    #[test]
252    fn multiple_anonymous_functions_increment_counter_in_source_order() {
253        let entries = walk(
254            r"
255            [1, 2, 3].map(() => 1);
256            [4, 5, 6].filter(() => true);
257            ",
258        );
259        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
260        assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
261    }
262
263    #[test]
264    fn named_function_still_advances_counter_matching_instrumenter() {
265        // Oracle: `oxc-coverage-instrument` advances its `fn_counter` on
266        // every function with a body (named or anonymous). The anonymous
267        // arrow below is the second emitted function, so its slot is `1`.
268        let entries = walk(
269            r"
270            function named() { return 1; }
271            [1].map(() => 2);
272            ",
273        );
274        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
275        assert_eq!(names, vec!["named", "(anonymous_1)"]);
276    }
277
278    #[test]
279    fn anonymous_after_named_chain_uses_next_counter_value() {
280        // Regression for the "counter only advances on anonymous" bug caught
281        // in rust-reviewer BLOCK. Each named function MUST still bump the
282        // counter so a trailing anonymous gets the right index.
283        let entries = walk(
284            r"
285            function a() {}
286            function b() {}
287            function c() {}
288            const d = () => 4;
289            ",
290        );
291        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
292        // `a`, `b`, `c`, and the binding `d` consume counter slots 0-3.
293        // There is no free-floating anonymous here; all four are resolved
294        // by name. If a truly anonymous arrow appeared, it would be slot 4.
295        assert_eq!(names, vec!["a", "b", "c", "d"]);
296    }
297
298    #[test]
299    fn typescript_overload_signatures_dont_emit_or_advance_counter() {
300        // Overload signatures have no body, are not runtime-instrumented,
301        // and therefore must not consume a counter slot. The trailing
302        // anonymous arrow is the second bodyful function, so it must be
303        // `(anonymous_1)` (slot 0 goes to the `foo` implementation).
304        let entries = walk(
305            r"
306            function foo(): number;
307            function foo(s: string): string;
308            function foo(s?: string): number | string { return s ? s : 1; }
309            [1].map(() => 2);
310            ",
311        );
312        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
313        assert_eq!(names, vec!["foo", "(anonymous_1)"]);
314    }
315
316    #[test]
317    fn export_default_named_function_keeps_explicit_name() {
318        let entries = walk("export default function foo() { return 1; }");
319        assert_eq!(entries.len(), 1);
320        assert_eq!(entries[0].name, "foo");
321    }
322
323    #[test]
324    fn export_default_anonymous_function_uses_counter() {
325        let entries = walk("export default function() { return 1; }");
326        assert_eq!(entries.len(), 1);
327        assert_eq!(entries[0].name, "(anonymous_0)");
328    }
329
330    #[test]
331    fn nested_function_numbered_after_parent_in_traversal_order() {
332        let entries = walk(
333            r"
334            function outer() {
335              return function() { return 1; };
336            }",
337        );
338        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
339        // `outer` is slot 0 (uses its own name); the nested anonymous is
340        // slot 1. The counter advances on every bodyful function, so the
341        // anonymous sees counter value 1 at resolution time.
342        assert_eq!(names, vec!["outer", "(anonymous_1)"]);
343    }
344
345    #[test]
346    fn line_number_is_one_based_from_source_start() {
347        let entries = walk("\n\nfunction atLineThree() {}");
348        assert_eq!(entries.len(), 1);
349        assert_eq!(entries[0].line, 3);
350    }
351
352    #[test]
353    fn short_jsx_in_js_file_retries_with_jsx_parser() {
354        let entries = walk_source(&PathBuf::from("component.js"), "const A = () => <div />;");
355        assert_eq!(entries.len(), 1);
356        assert_eq!(entries[0].name, "A");
357        assert_eq!(entries[0].line, 1);
358    }
359
360    #[test]
361    fn object_method_shorthand_uses_anonymous_counter() {
362        let entries = walk("const obj = { run() { return 1; } };");
363        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
364        assert_eq!(names, vec!["(anonymous_0)"]);
365    }
366
367    #[test]
368    fn class_property_arrow_uses_anonymous_counter() {
369        let entries = walk(
370            r"
371            class Foo {
372              bar = () => 1;
373            }",
374        );
375        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
376        assert_eq!(names, vec!["(anonymous_0)"]);
377    }
378}