1use 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#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct InventoryEntry {
50 pub name: String,
52 pub line: u32,
54 pub start_column: u32,
56 pub end_line: u32,
58 pub end_column: u32,
60 pub source_hash: String,
68}
69
70struct InventoryVisitor<'a> {
72 source: &'a str,
73 line_offsets: &'a [u32],
74 entries: Vec<InventoryEntry>,
75 pending_name: Option<String>,
77 anonymous_counter: u32,
79}
80
81impl<'a> InventoryVisitor<'a> {
82 const fn new(source: &'a str, line_offsets: &'a [u32]) -> Self {
83 Self {
84 source,
85 line_offsets,
86 entries: Vec::new(),
87 pending_name: None,
88 anonymous_counter: 0,
89 }
90 }
91
92 fn resolve_name(&mut self, explicit: Option<&str>) -> String {
102 let n = self.anonymous_counter;
103 self.anonymous_counter += 1;
104 if let Some(pending) = self.pending_name.take() {
105 return pending;
106 }
107 if let Some(name) = explicit {
108 return name.to_owned();
109 }
110 format!("(anonymous_{n})")
111 }
112
113 fn record(&mut self, name: String, span: Span) {
114 let (line, start_column) = self.line_col_utf16(span.start);
115 let (end_line, end_column) = self.line_col_utf16(span.end);
116 let source_hash = self
120 .source
121 .get(span.start as usize..span.end as usize)
122 .map_or_else(
123 || fallow_cov_protocol::source_hash_for(b""),
124 |slice| fallow_cov_protocol::source_hash_for(slice.as_bytes()),
125 );
126 self.entries.push(InventoryEntry {
127 name,
128 line,
129 start_column,
130 end_line,
131 end_column,
132 source_hash,
133 });
134 }
135
136 fn line_col_utf16(&self, byte_offset: u32) -> (u32, u32) {
145 let line_idx = match self.line_offsets.binary_search(&byte_offset) {
146 Ok(idx) => idx,
147 Err(idx) => idx.saturating_sub(1),
148 };
149 let line = line_idx as u32 + 1;
150 let line_start = self.line_offsets[line_idx] as usize;
151 let mut end = byte_offset as usize;
152 while end > line_start && !self.source.is_char_boundary(end) {
153 end -= 1;
154 }
155 let col_utf16 = self
156 .source
157 .get(line_start..end)
158 .map_or(0, |slice| slice.encode_utf16().count());
159 (line, col_utf16 as u32 + 1)
160 }
161}
162
163impl<'ast> Visit<'ast> for InventoryVisitor<'_> {
164 fn visit_function(&mut self, func: &Function<'ast>, flags: ScopeFlags) {
165 if func.body.is_none() {
171 walk::walk_function(self, func, flags);
172 return;
173 }
174 let name = self.resolve_name(func.id.as_ref().map(|id| id.name.as_str()));
175 self.record(name, func.span);
176 walk::walk_function(self, func, flags);
177 }
178
179 fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'ast>) {
180 let name = self.resolve_name(None);
181 self.record(name, arrow.span);
182 walk::walk_arrow_function_expression(self, arrow);
183 }
184
185 fn visit_method_definition(&mut self, method: &MethodDefinition<'ast>) {
186 if let Some(name) = method.key.static_name() {
187 self.pending_name = Some(name.to_string());
188 }
189 walk::walk_method_definition(self, method);
190 self.pending_name = None;
191 }
192
193 fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'ast>) {
194 if let Some(id) = decl.id.get_binding_identifier()
195 && decl.init.as_ref().is_some_and(|init| {
196 matches!(
197 init,
198 Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
199 )
200 })
201 {
202 self.pending_name = Some(id.name.to_string());
203 }
204 walk::walk_variable_declarator(self, decl);
205 self.pending_name = None;
206 }
207
208 fn visit_object_property(&mut self, prop: &ObjectProperty<'ast>) {
209 self.pending_name = None;
216 walk::walk_object_property(self, prop);
217 self.pending_name = None;
218 }
219}
220
221#[must_use]
231pub fn walk_source(path: &Path, source: &str) -> Vec<InventoryEntry> {
232 let source_type = SourceType::from_path(path).unwrap_or_default();
233 let allocator = Allocator::default();
234 let parser_return = Parser::new(&allocator, source, source_type).parse();
235
236 let line_offsets = fallow_types::extract::compute_line_offsets(source);
237 let mut visitor = InventoryVisitor::new(source, &line_offsets);
238 visitor.visit_program(&parser_return.program);
239
240 if visitor.entries.is_empty() && !source_type.is_jsx() {
245 let jsx_type = if source_type.is_typescript() {
246 SourceType::tsx()
247 } else {
248 SourceType::jsx()
249 };
250 let allocator2 = Allocator::default();
251 let retry_return = Parser::new(&allocator2, source, jsx_type).parse();
252 let mut retry_visitor = InventoryVisitor::new(source, &line_offsets);
253 retry_visitor.visit_program(&retry_return.program);
254 if !retry_visitor.entries.is_empty() {
255 return retry_visitor.entries;
256 }
257 }
258
259 visitor.entries
260}
261
262#[cfg(all(test, not(miri)))]
263mod tests {
264 use super::*;
265 use std::path::PathBuf;
266
267 fn walk(source: &str) -> Vec<InventoryEntry> {
268 walk_source(&PathBuf::from("test.ts"), source)
269 }
270
271 #[test]
272 fn named_function_declaration_uses_its_own_name() {
273 let entries = walk("function foo() { return 1; }");
274 assert_eq!(entries.len(), 1);
275 assert_eq!(entries[0].name, "foo");
276 assert_eq!(entries[0].line, 1);
277 }
278
279 #[test]
280 fn const_arrow_captures_binding_name() {
281 let entries = walk("const bar = () => 42;");
282 assert_eq!(entries.len(), 1);
283 assert_eq!(entries[0].name, "bar");
284 }
285
286 #[test]
287 fn const_function_expression_captures_binding_name_not_fn_id() {
288 let entries = walk("const outer = function inner() { return 1; };");
292 assert_eq!(entries.len(), 1);
293 assert_eq!(entries[0].name, "outer");
294 }
295
296 #[test]
297 fn class_methods_use_method_names() {
298 let entries = walk(
299 r"
300 class Foo {
301 bar() { return 1; }
302 baz() { return 2; }
303 }",
304 );
305 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
306 assert_eq!(names, vec!["bar", "baz"]);
307 }
308
309 #[test]
310 fn anonymous_arrow_passed_as_argument_uses_counter() {
311 let entries = walk("setTimeout(() => { console.log('hi'); }, 10);");
312 assert_eq!(entries.len(), 1);
313 assert_eq!(entries[0].name, "(anonymous_0)");
314 }
315
316 #[test]
317 fn multiple_anonymous_functions_increment_counter_in_source_order() {
318 let entries = walk(
319 r"
320 [1, 2, 3].map(() => 1);
321 [4, 5, 6].filter(() => true);
322 ",
323 );
324 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
325 assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
326 }
327
328 #[test]
329 fn named_function_still_advances_counter_matching_instrumenter() {
330 let entries = walk(
334 r"
335 function named() { return 1; }
336 [1].map(() => 2);
337 ",
338 );
339 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
340 assert_eq!(names, vec!["named", "(anonymous_1)"]);
341 }
342
343 #[test]
344 fn anonymous_after_named_chain_uses_next_counter_value() {
345 let entries = walk(
349 r"
350 function a() {}
351 function b() {}
352 function c() {}
353 const d = () => 4;
354 ",
355 );
356 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
357 assert_eq!(names, vec!["a", "b", "c", "d"]);
361 }
362
363 #[test]
364 fn typescript_overload_signatures_dont_emit_or_advance_counter() {
365 let entries = walk(
370 r"
371 function foo(): number;
372 function foo(s: string): string;
373 function foo(s?: string): number | string { return s ? s : 1; }
374 [1].map(() => 2);
375 ",
376 );
377 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
378 assert_eq!(names, vec!["foo", "(anonymous_1)"]);
379 }
380
381 #[test]
382 fn export_default_named_function_keeps_explicit_name() {
383 let entries = walk("export default function foo() { return 1; }");
384 assert_eq!(entries.len(), 1);
385 assert_eq!(entries[0].name, "foo");
386 }
387
388 #[test]
389 fn export_default_anonymous_function_uses_counter() {
390 let entries = walk("export default function() { return 1; }");
391 assert_eq!(entries.len(), 1);
392 assert_eq!(entries[0].name, "(anonymous_0)");
393 }
394
395 #[test]
396 fn nested_function_numbered_after_parent_in_traversal_order() {
397 let entries = walk(
398 r"
399 function outer() {
400 return function() { return 1; };
401 }",
402 );
403 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
404 assert_eq!(names, vec!["outer", "(anonymous_1)"]);
408 }
409
410 #[test]
411 fn line_number_is_one_based_from_source_start() {
412 let entries = walk("\n\nfunction atLineThree() {}");
413 assert_eq!(entries.len(), 1);
414 assert_eq!(entries[0].line, 3);
415 }
416
417 #[test]
418 fn short_jsx_in_js_file_retries_with_jsx_parser() {
419 let entries = walk_source(&PathBuf::from("component.js"), "const A = () => <div />;");
420 assert_eq!(entries.len(), 1);
421 assert_eq!(entries[0].name, "A");
422 assert_eq!(entries[0].line, 1);
423 }
424
425 #[test]
426 fn object_method_shorthand_uses_anonymous_counter() {
427 let entries = walk("const obj = { run() { return 1; } };");
428 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
429 assert_eq!(names, vec!["(anonymous_0)"]);
430 }
431
432 #[test]
433 fn class_property_arrow_uses_anonymous_counter() {
434 let entries = walk(
435 r"
436 class Foo {
437 bar = () => 1;
438 }",
439 );
440 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
441 assert_eq!(names, vec!["(anonymous_0)"]);
442 }
443
444 #[test]
445 fn records_one_indexed_utf16_columns() {
446 let entries = walk("function foo() { return 1; }");
448 assert_eq!(entries.len(), 1);
449 assert_eq!(entries[0].start_column, 1);
450 assert_eq!(entries[0].end_line, 1);
451 assert!(entries[0].end_column > entries[0].start_column);
453 }
454
455 #[test]
456 fn utf16_column_counts_code_units_not_bytes() {
457 let entries = walk("const e = \"\u{1F600}\"; const f = () => 1;");
462 let f = entries.iter().find(|e| e.name == "f").expect("f present");
463 let byte_prefix_len = "const e = \"\u{1F600}\"; const f = ".len() as u32;
464 assert!(f.start_column < byte_prefix_len + 1);
465 }
466
467 #[test]
468 fn same_line_distinct_named_functions_have_distinct_positions() {
469 let entries = walk("function a() {} function b() {}");
473 let a = entries.iter().find(|e| e.name == "a").expect("a present");
474 let b = entries.iter().find(|e| e.name == "b").expect("b present");
475 assert_eq!(a.line, b.line, "both on line 1");
476 assert_ne!(
477 a.start_column, b.start_column,
478 "same-line functions are column-disambiguated"
479 );
480 }
481
482 #[test]
483 fn same_line_anonymous_functions_stay_distinct_via_counter() {
484 let entries = walk("const xs = [() => 1, () => 2];");
488 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
489 assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
490 assert_eq!(entries[0].line, entries[1].line, "both on line 1");
491 assert_ne!(
492 entries[0].name, entries[1].name,
493 "counter keeps them distinct"
494 );
495 }
496
497 #[test]
498 fn source_hash_is_the_content_digest_of_the_function_span() {
499 let src = "function foo() { return 1; }";
503 let entries = walk(src);
504 assert_eq!(entries.len(), 1);
505 assert_eq!(
506 entries[0].source_hash,
507 fallow_cov_protocol::source_hash_for(src.as_bytes())
508 );
509 assert_eq!(entries[0].source_hash.len(), 16);
510 assert!(
511 entries[0]
512 .source_hash
513 .chars()
514 .all(|c| c.is_ascii_hexdigit())
515 );
516 }
517
518 #[test]
519 fn source_hash_survives_line_moves_and_tracks_body_edits() {
520 let original = walk("function foo() { return 1; }");
524 let moved = walk("\n\nfunction foo() { return 1; }");
525 assert_eq!(
526 original[0].source_hash, moved[0].source_hash,
527 "a moved-but-unedited function must keep its source_hash"
528 );
529 let edited = walk("function foo() { return 2; }");
530 assert_ne!(
531 original[0].source_hash, edited[0].source_hash,
532 "an edited body must change the source_hash"
533 );
534 }
535}