1use std::path::Path;
38
39use oxc_allocator::Allocator;
40#[allow(clippy::wildcard_imports, reason = "many AST types used")]
41use oxc_ast::ast::*;
42use oxc_ast_visit::{Visit, walk};
43use oxc_parser::Parser;
44use oxc_semantic::ScopeFlags;
45use oxc_span::{SourceType, Span};
46use rustc_hash::FxHashMap;
47
48#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct InventoryEntry {
60 pub name: String,
62 pub line: u32,
64 pub start_column: u32,
66 pub end_line: u32,
68 pub end_column: u32,
70 pub source_hash: String,
78}
79
80struct InventoryVisitor<'a> {
82 source: &'a str,
83 line_offsets: &'a [u32],
84 entries: Vec<InventoryEntry>,
85 pending_name: Option<String>,
87 pending_callee_name: Option<String>,
91 anonymous_counter: u32,
93}
94
95impl<'a> InventoryVisitor<'a> {
96 const fn new(source: &'a str, line_offsets: &'a [u32]) -> Self {
97 Self {
98 source,
99 line_offsets,
100 entries: Vec::new(),
101 pending_name: None,
102 pending_callee_name: None,
103 anonymous_counter: 0,
104 }
105 }
106
107 fn resolve_name(&mut self, explicit: Option<&str>) -> String {
118 let n = self.anonymous_counter;
119 self.anonymous_counter += 1;
120 if let Some(pending) = self.pending_name.take() {
121 return pending;
122 }
123 if let Some(name) = explicit {
124 return name.to_owned();
125 }
126 if let Some(callee) = self.pending_callee_name.take() {
127 return callee;
128 }
129 format!("(anonymous_{n})")
130 }
131
132 fn record(&mut self, name: String, span: Span) {
133 let (line, start_column) = self.line_col_utf16(span.start);
134 let (end_line, end_column) = self.line_col_utf16(span.end);
135 let source_hash = self
136 .source
137 .get(span.start as usize..span.end as usize)
138 .map_or_else(
139 || fallow_cov_protocol::source_hash_for(b""),
140 |slice| fallow_cov_protocol::source_hash_for(slice.as_bytes()),
141 );
142 self.entries.push(InventoryEntry {
143 name,
144 line,
145 start_column,
146 end_line,
147 end_column,
148 source_hash,
149 });
150 }
151
152 fn line_col_utf16(&self, byte_offset: u32) -> (u32, u32) {
161 let line_idx = match self.line_offsets.binary_search(&byte_offset) {
162 Ok(idx) => idx,
163 Err(idx) => idx.saturating_sub(1),
164 };
165 let line = line_idx as u32 + 1;
166 let line_start = self.line_offsets[line_idx] as usize;
167 let mut end = byte_offset as usize;
168 while end > line_start && !self.source.is_char_boundary(end) {
169 end -= 1;
170 }
171 let col_utf16 = self
172 .source
173 .get(line_start..end)
174 .map_or(0, |slice| slice.encode_utf16().count());
175 (line, col_utf16 as u32 + 1)
176 }
177}
178
179impl<'ast> Visit<'ast> for InventoryVisitor<'_> {
180 fn visit_function(&mut self, func: &Function<'ast>, flags: ScopeFlags) {
181 if func.body.is_none() {
182 walk::walk_function(self, func, flags);
183 return;
184 }
185 let name = self.resolve_name(func.id.as_ref().map(|id| id.name.as_str()));
186 self.record(name, func.span);
187 walk::walk_function(self, func, flags);
188 }
189
190 fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'ast>) {
191 let name = self.resolve_name(None);
192 self.record(name, arrow.span);
193 walk::walk_arrow_function_expression(self, arrow);
194 }
195
196 fn visit_method_definition(&mut self, method: &MethodDefinition<'ast>) {
197 if let Some(name) = method.key.static_name() {
198 self.pending_name = Some(name.to_string());
199 }
200 walk::walk_method_definition(self, method);
201 self.pending_name = None;
202 }
203
204 fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'ast>) {
205 if let Some(id) = decl.id.get_binding_identifier()
206 && decl.init.as_ref().is_some_and(|init| {
207 matches!(
208 init,
209 Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
210 )
211 })
212 {
213 self.pending_name = Some(id.name.to_string());
214 }
215 walk::walk_variable_declarator(self, decl);
216 self.pending_name = None;
217 }
218
219 fn visit_object_property(&mut self, prop: &ObjectProperty<'ast>) {
220 self.pending_name = None;
221 walk::walk_object_property(self, prop);
222 self.pending_name = None;
223 }
224
225 fn visit_call_expression(&mut self, call: &CallExpression<'ast>) {
235 self.visit_expression(&call.callee);
236 let name = callee_name(&call.callee);
237 for argument in &call.arguments {
238 self.pending_callee_name.clone_from(&name);
239 self.visit_argument(argument);
240 }
241 self.pending_callee_name = None;
242 }
243
244 fn visit_new_expression(&mut self, new_expr: &NewExpression<'ast>) {
245 self.visit_expression(&new_expr.callee);
246 let name = callee_name(&new_expr.callee);
247 for argument in &new_expr.arguments {
248 self.pending_callee_name.clone_from(&name);
249 self.visit_argument(argument);
250 }
251 self.pending_callee_name = None;
252 }
253}
254
255fn callee_name(callee: &Expression<'_>) -> Option<String> {
261 match callee {
262 Expression::Identifier(ident) => Some(ident.name.to_string()),
263 Expression::StaticMemberExpression(member) => Some(member.property.name.to_string()),
264 Expression::ComputedMemberExpression(member) => match &member.expression {
265 Expression::StringLiteral(lit) => Some(lit.value.to_string()),
266 _ => None,
267 },
268 Expression::ParenthesizedExpression(paren) => callee_name(&paren.expression),
272 _ => None,
273 }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct InventoryComplexity {
286 pub cyclomatic: u16,
288 pub cognitive: u16,
290}
291
292#[must_use]
302pub fn walk_source(path: &Path, source: &str) -> Vec<InventoryEntry> {
303 walk_source_with_complexity(path, source).0
304}
305
306#[must_use]
318pub fn walk_source_with_complexity(
319 path: &Path,
320 source: &str,
321) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
322 let source_type = SourceType::from_path(path).unwrap_or_default();
323 let line_offsets = fallow_types::extract::compute_line_offsets(source);
324
325 let primary = walk_one_parse(source, source_type, &line_offsets);
326 if primary.0.is_empty() && !source_type.is_jsx() {
327 let jsx_type = if source_type.is_typescript() {
328 SourceType::tsx()
329 } else {
330 SourceType::jsx()
331 };
332 let retry = walk_one_parse(source, jsx_type, &line_offsets);
333 if !retry.0.is_empty() {
334 return retry;
335 }
336 }
337
338 primary
339}
340
341fn walk_one_parse(
344 source: &str,
345 source_type: SourceType,
346 line_offsets: &[u32],
347) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
348 let allocator = Allocator::default();
349 let parser_return = Parser::new(&allocator, source, source_type).parse();
350
351 let mut visitor = InventoryVisitor::new(source, line_offsets);
352 visitor.visit_program(&parser_return.program);
353
354 let complexity =
355 crate::complexity::compute_complexity(&parser_return.program, source, line_offsets);
356 let metrics: FxHashMap<String, InventoryComplexity> = complexity
357 .into_iter()
358 .filter_map(|fc| {
359 fc.source_hash.map(|hash| {
360 (
361 hash,
362 InventoryComplexity {
363 cyclomatic: fc.cyclomatic,
364 cognitive: fc.cognitive,
365 },
366 )
367 })
368 })
369 .collect();
370
371 (visitor.entries, metrics)
372}
373
374#[cfg(all(test, not(miri)))]
375mod tests {
376 use super::*;
377 use std::path::PathBuf;
378
379 fn walk(source: &str) -> Vec<InventoryEntry> {
380 walk_source(&PathBuf::from("test.ts"), source)
381 }
382
383 #[test]
384 fn named_function_declaration_uses_its_own_name() {
385 let entries = walk("function foo() { return 1; }");
386 assert_eq!(entries.len(), 1);
387 assert_eq!(entries[0].name, "foo");
388 assert_eq!(entries[0].line, 1);
389 }
390
391 #[test]
392 fn const_arrow_captures_binding_name() {
393 let entries = walk("const bar = () => 42;");
394 assert_eq!(entries.len(), 1);
395 assert_eq!(entries[0].name, "bar");
396 }
397
398 #[test]
399 fn const_function_expression_captures_binding_name_not_fn_id() {
400 let entries = walk("const outer = function inner() { return 1; };");
401 assert_eq!(entries.len(), 1);
402 assert_eq!(entries[0].name, "outer");
403 }
404
405 #[test]
406 fn class_methods_use_method_names() {
407 let entries = walk(
408 r"
409 class Foo {
410 bar() { return 1; }
411 baz() { return 2; }
412 }",
413 );
414 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
415 assert_eq!(names, vec!["bar", "baz"]);
416 }
417
418 #[test]
419 fn callback_argument_takes_the_callee_name() {
420 let entries = walk("setTimeout(() => { console.log('hi'); }, 10);");
423 assert_eq!(entries.len(), 1);
424 assert_eq!(entries[0].name, "setTimeout");
425 }
426
427 #[test]
428 fn member_callee_names_each_callback_in_source_order() {
429 let entries = walk(
430 r"
431 [1, 2, 3].map(() => 1);
432 [4, 5, 6].filter(() => true);
433 ",
434 );
435 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
436 assert_eq!(names, vec!["map", "filter"]);
437 }
438
439 #[test]
440 fn named_function_still_advances_counter_matching_instrumenter() {
441 let entries = walk(
445 r"
446 function named() { return 1; }
447 [1].map(() => 2);
448 ",
449 );
450 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
451 assert_eq!(names, vec!["named", "map"]);
452 }
453
454 #[test]
455 fn plain_identifier_callee_names_the_callback() {
456 let entries = walk("useMemo(() => compute());");
457 assert_eq!(entries[0].name, "useMemo");
458 }
459
460 #[test]
461 fn new_expression_callee_names_the_callback() {
462 let entries = walk("new Promise((resolve) => resolve(1));");
463 assert_eq!(entries[0].name, "Promise");
464 }
465
466 #[test]
467 fn callback_after_a_string_argument_is_named_from_the_callee() {
468 let entries = walk(r#"el.addEventListener("click", () => handle());"#);
471 assert_eq!(entries[0].name, "addEventListener");
472 }
473
474 #[test]
475 fn computed_string_key_callee_is_named() {
476 let entries = walk(r#"obj["handler"](() => run());"#);
477 assert_eq!(entries[0].name, "handler");
478 }
479
480 #[test]
481 fn chained_call_does_not_leak_the_earlier_callee_onto_the_later_callback() {
482 let entries = walk("p.then(() => a).catch(() => b);");
486 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
487 assert_eq!(names, vec!["then", "catch"]);
488 }
489
490 #[test]
491 fn nested_callbacks_each_take_their_own_callee() {
492 let entries = walk("outer(() => inner(() => 1));");
493 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
494 assert_eq!(names, vec!["outer", "inner"]);
495 }
496
497 #[test]
498 fn binding_name_wins_over_callee() {
499 let entries = walk("const handler = () => run();");
502 assert_eq!(entries[0].name, "handler");
503 }
504
505 #[test]
506 fn named_function_expression_argument_keeps_its_own_id() {
507 let entries = walk("run(function inner() { return 1; });");
508 assert_eq!(entries[0].name, "inner");
509 }
510
511 #[test]
512 fn iife_callee_stays_anonymous() {
513 let entries = walk("(function () { return 1; })();");
515 assert_eq!(entries[0].name, "(anonymous_0)");
516 }
517
518 #[test]
519 fn computed_non_string_callee_stays_anonymous() {
520 let entries = walk("handlers[index](() => run());");
521 assert_eq!(entries[0].name, "(anonymous_0)");
522 }
523
524 #[test]
525 fn parenthesized_callee_unwraps_to_the_inner_name() {
526 assert_eq!(walk("(foo)(() => run());")[0].name, "foo");
527 assert_eq!(walk("(a.b)(() => run());")[0].name, "b");
528 }
529
530 #[test]
531 fn anonymous_after_named_chain_uses_next_counter_value() {
532 let entries = walk(
533 r"
534 function a() {}
535 function b() {}
536 function c() {}
537 const d = () => 4;
538 ",
539 );
540 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
541 assert_eq!(names, vec!["a", "b", "c", "d"]);
542 }
543
544 #[test]
545 fn typescript_overload_signatures_dont_emit_or_advance_counter() {
546 let entries = walk(
547 r"
548 function foo(): number;
549 function foo(s: string): string;
550 function foo(s?: string): number | string { return s ? s : 1; }
551 [1].map(() => 2);
552 ",
553 );
554 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
555 assert_eq!(names, vec!["foo", "map"]);
556 }
557
558 #[test]
559 fn export_default_named_function_keeps_explicit_name() {
560 let entries = walk("export default function foo() { return 1; }");
561 assert_eq!(entries.len(), 1);
562 assert_eq!(entries[0].name, "foo");
563 }
564
565 #[test]
566 fn export_default_anonymous_function_uses_counter() {
567 let entries = walk("export default function() { return 1; }");
568 assert_eq!(entries.len(), 1);
569 assert_eq!(entries[0].name, "(anonymous_0)");
570 }
571
572 #[test]
573 fn nested_function_numbered_after_parent_in_traversal_order() {
574 let entries = walk(
575 r"
576 function outer() {
577 return function() { return 1; };
578 }",
579 );
580 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
581 assert_eq!(names, vec!["outer", "(anonymous_1)"]);
582 }
583
584 #[test]
585 fn line_number_is_one_based_from_source_start() {
586 let entries = walk("\n\nfunction atLineThree() {}");
587 assert_eq!(entries.len(), 1);
588 assert_eq!(entries[0].line, 3);
589 }
590
591 #[test]
592 fn short_jsx_in_js_file_retries_with_jsx_parser() {
593 let entries = walk_source(&PathBuf::from("component.js"), "const A = () => <div />;");
594 assert_eq!(entries.len(), 1);
595 assert_eq!(entries[0].name, "A");
596 assert_eq!(entries[0].line, 1);
597 }
598
599 #[test]
600 fn object_method_shorthand_uses_anonymous_counter() {
601 let entries = walk("const obj = { run() { return 1; } };");
602 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
603 assert_eq!(names, vec!["(anonymous_0)"]);
604 }
605
606 #[test]
607 fn class_property_arrow_uses_anonymous_counter() {
608 let entries = walk(
609 r"
610 class Foo {
611 bar = () => 1;
612 }",
613 );
614 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
615 assert_eq!(names, vec!["(anonymous_0)"]);
616 }
617
618 #[test]
619 fn records_one_indexed_utf16_columns() {
620 let entries = walk("function foo() { return 1; }");
621 assert_eq!(entries.len(), 1);
622 assert_eq!(entries[0].start_column, 1);
623 assert_eq!(entries[0].end_line, 1);
624 assert!(entries[0].end_column > entries[0].start_column);
625 }
626
627 #[test]
628 fn utf16_column_counts_code_units_not_bytes() {
629 let entries = walk("const e = \"\u{1F600}\"; const f = () => 1;");
630 let f = entries.iter().find(|e| e.name == "f").expect("f present");
631 let byte_prefix_len = "const e = \"\u{1F600}\"; const f = ".len() as u32;
632 assert!(f.start_column < byte_prefix_len + 1);
633 }
634
635 #[test]
636 fn same_line_distinct_named_functions_have_distinct_positions() {
637 let entries = walk("function a() {} function b() {}");
638 let a = entries.iter().find(|e| e.name == "a").expect("a present");
639 let b = entries.iter().find(|e| e.name == "b").expect("b present");
640 assert_eq!(a.line, b.line, "both on line 1");
641 assert_ne!(
642 a.start_column, b.start_column,
643 "same-line functions are column-disambiguated"
644 );
645 }
646
647 #[test]
648 fn same_line_anonymous_functions_stay_distinct_via_counter() {
649 let entries = walk("const xs = [() => 1, () => 2];");
650 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
651 assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
652 assert_eq!(entries[0].line, entries[1].line, "both on line 1");
653 assert_ne!(
654 entries[0].name, entries[1].name,
655 "counter keeps them distinct"
656 );
657 }
658
659 #[test]
660 fn source_hash_is_the_content_digest_of_the_function_span() {
661 let src = "function foo() { return 1; }";
662 let entries = walk(src);
663 assert_eq!(entries.len(), 1);
664 assert_eq!(
665 entries[0].source_hash,
666 fallow_cov_protocol::source_hash_for(src.as_bytes())
667 );
668 assert_eq!(entries[0].source_hash.len(), 16);
669 assert!(
670 entries[0]
671 .source_hash
672 .chars()
673 .all(|c| c.is_ascii_hexdigit())
674 );
675 }
676
677 #[test]
678 fn source_hash_survives_line_moves_and_tracks_body_edits() {
679 let original = walk("function foo() { return 1; }");
680 let moved = walk("\n\nfunction foo() { return 1; }");
681 assert_eq!(
682 original[0].source_hash, moved[0].source_hash,
683 "a moved-but-unedited function must keep its source_hash"
684 );
685 let edited = walk("function foo() { return 2; }");
686 assert_ne!(
687 original[0].source_hash, edited[0].source_hash,
688 "an edited body must change the source_hash"
689 );
690 }
691}