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 ColCache {
84 line_idx: usize,
85 byte_end: usize,
86 utf16_units: usize,
87}
88
89struct InventoryVisitor<'a> {
91 source: &'a str,
92 line_offsets: &'a [u32],
93 entries: Vec<InventoryEntry>,
94 col_cache: ColCache,
95 pending_name: Option<String>,
97 pending_callee_name: Option<String>,
101 anonymous_counter: u32,
103}
104
105impl<'a> InventoryVisitor<'a> {
106 const fn new(source: &'a str, line_offsets: &'a [u32]) -> Self {
107 Self {
108 source,
109 line_offsets,
110 entries: Vec::new(),
111 col_cache: ColCache {
112 line_idx: usize::MAX,
113 byte_end: 0,
114 utf16_units: 0,
115 },
116 pending_name: None,
117 pending_callee_name: None,
118 anonymous_counter: 0,
119 }
120 }
121
122 fn resolve_name(&mut self, explicit: Option<&str>) -> String {
133 let n = self.anonymous_counter;
134 self.anonymous_counter += 1;
135 if let Some(pending) = self.pending_name.take() {
136 return pending;
137 }
138 if let Some(name) = explicit {
139 return name.to_owned();
140 }
141 if let Some(callee) = self.pending_callee_name.take() {
142 return callee;
143 }
144 format!("(anonymous_{n})")
145 }
146
147 fn record(&mut self, name: String, span: Span) {
148 let (line, start_column) = self.line_col_utf16(span.start);
149 let (end_line, end_column) = self.line_col_utf16(span.end);
150 let source_hash = self
151 .source
152 .get(span.start as usize..span.end as usize)
153 .map_or_else(
154 || fallow_cov_protocol::source_hash_for(b""),
155 |slice| fallow_cov_protocol::source_hash_for(slice.as_bytes()),
156 );
157 self.entries.push(InventoryEntry {
158 name,
159 line,
160 start_column,
161 end_line,
162 end_column,
163 source_hash,
164 });
165 }
166
167 fn line_col_utf16(&mut self, byte_offset: u32) -> (u32, u32) {
182 let line_idx = match self.line_offsets.binary_search(&byte_offset) {
183 Ok(idx) => idx,
184 Err(idx) => idx.saturating_sub(1),
185 };
186 let line = line_idx as u32 + 1;
187 let line_start = self.line_offsets[line_idx] as usize;
188 let mut end = byte_offset as usize;
189 while end > line_start && !self.source.is_char_boundary(end) {
190 end -= 1;
191 }
192 let from_cache = if self.col_cache.line_idx == line_idx {
196 if end >= self.col_cache.byte_end {
197 self.utf16_len(self.col_cache.byte_end, end)
198 .map(|gap| self.col_cache.utf16_units + gap)
199 } else {
200 self.utf16_len(end, self.col_cache.byte_end)
201 .map(|gap| self.col_cache.utf16_units - gap)
202 }
203 } else {
204 None
205 };
206 let col_utf16 = from_cache.unwrap_or_else(|| self.utf16_len(line_start, end).unwrap_or(0));
207 self.col_cache = ColCache {
208 line_idx,
209 byte_end: end,
210 utf16_units: col_utf16,
211 };
212 (line, col_utf16 as u32 + 1)
213 }
214
215 fn utf16_len(&self, start: usize, end: usize) -> Option<usize> {
218 self.source
219 .get(start..end)
220 .map(|slice| slice.encode_utf16().count())
221 }
222}
223
224impl<'ast> Visit<'ast> for InventoryVisitor<'_> {
225 fn visit_function(&mut self, func: &Function<'ast>, flags: ScopeFlags) {
226 if func.body.is_none() {
227 walk::walk_function(self, func, flags);
228 return;
229 }
230 let name = self.resolve_name(func.id.as_ref().map(|id| id.name.as_str()));
231 self.record(name, func.span);
232 walk::walk_function(self, func, flags);
233 }
234
235 fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'ast>) {
236 let name = self.resolve_name(None);
237 self.record(name, arrow.span);
238 walk::walk_arrow_function_expression(self, arrow);
239 }
240
241 fn visit_method_definition(&mut self, method: &MethodDefinition<'ast>) {
242 if let Some(name) = method.key.static_name() {
243 self.pending_name = Some(name.to_string());
244 }
245 walk::walk_method_definition(self, method);
246 self.pending_name = None;
247 }
248
249 fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'ast>) {
250 if let Some(id) = decl.id.get_binding_identifier()
251 && decl.init.as_ref().is_some_and(|init| {
252 matches!(
253 init,
254 Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
255 )
256 })
257 {
258 self.pending_name = Some(id.name.to_string());
259 }
260 walk::walk_variable_declarator(self, decl);
261 self.pending_name = None;
262 }
263
264 fn visit_object_property(&mut self, prop: &ObjectProperty<'ast>) {
265 self.pending_name = None;
266 walk::walk_object_property(self, prop);
267 self.pending_name = None;
268 }
269
270 fn visit_call_expression(&mut self, call: &CallExpression<'ast>) {
280 self.visit_expression(&call.callee);
281 let name = callee_name(&call.callee);
282 for argument in &call.arguments {
283 self.pending_callee_name.clone_from(&name);
284 self.visit_argument(argument);
285 }
286 self.pending_callee_name = None;
287 }
288
289 fn visit_new_expression(&mut self, new_expr: &NewExpression<'ast>) {
290 self.visit_expression(&new_expr.callee);
291 let name = callee_name(&new_expr.callee);
292 for argument in &new_expr.arguments {
293 self.pending_callee_name.clone_from(&name);
294 self.visit_argument(argument);
295 }
296 self.pending_callee_name = None;
297 }
298}
299
300fn callee_name(callee: &Expression<'_>) -> Option<String> {
306 match callee {
307 Expression::Identifier(ident) => Some(ident.name.to_string()),
308 Expression::StaticMemberExpression(member) => Some(member.property.name.to_string()),
309 Expression::ComputedMemberExpression(member) => match &member.expression {
310 Expression::StringLiteral(lit) => Some(lit.value.to_string()),
311 _ => None,
312 },
313 Expression::ParenthesizedExpression(paren) => callee_name(&paren.expression),
317 _ => None,
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub struct InventoryComplexity {
331 pub cyclomatic: u16,
333 pub cognitive: u16,
335}
336
337#[must_use]
347pub fn walk_source(path: &Path, source: &str) -> Vec<InventoryEntry> {
348 walk_source_with_complexity(path, source).0
349}
350
351#[must_use]
363pub fn walk_source_with_complexity(
364 path: &Path,
365 source: &str,
366) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
367 let source_type = SourceType::from_path(path).unwrap_or_default();
368 let line_offsets = fallow_types::extract::compute_line_offsets(source);
369
370 let primary = walk_one_parse(source, source_type, &line_offsets);
371 if primary.0.is_empty() && !source_type.is_jsx() {
372 let jsx_type = if source_type.is_typescript() {
373 SourceType::tsx()
374 } else {
375 SourceType::jsx()
376 };
377 let retry = walk_one_parse(source, jsx_type, &line_offsets);
378 if !retry.0.is_empty() {
379 return retry;
380 }
381 }
382
383 primary
384}
385
386fn walk_one_parse(
389 source: &str,
390 source_type: SourceType,
391 line_offsets: &[u32],
392) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
393 let allocator = Allocator::default();
394 let parser_return = Parser::new(&allocator, source, source_type).parse();
395
396 let mut visitor = InventoryVisitor::new(source, line_offsets);
397 visitor.visit_program(&parser_return.program);
398
399 let complexity =
400 crate::complexity::compute_complexity(&parser_return.program, source, line_offsets);
401 let metrics: FxHashMap<String, InventoryComplexity> = complexity
402 .into_iter()
403 .filter_map(|fc| {
404 fc.source_hash.map(|hash| {
405 (
406 hash,
407 InventoryComplexity {
408 cyclomatic: fc.cyclomatic,
409 cognitive: fc.cognitive,
410 },
411 )
412 })
413 })
414 .collect();
415
416 (visitor.entries, metrics)
417}
418
419#[cfg(all(test, not(miri)))]
420mod tests {
421 use super::*;
422 use std::path::PathBuf;
423
424 fn walk(source: &str) -> Vec<InventoryEntry> {
425 walk_source(&PathBuf::from("test.ts"), source)
426 }
427
428 #[test]
429 fn named_function_declaration_uses_its_own_name() {
430 let entries = walk("function foo() { return 1; }");
431 assert_eq!(entries.len(), 1);
432 assert_eq!(entries[0].name, "foo");
433 assert_eq!(entries[0].line, 1);
434 }
435
436 #[test]
437 fn const_arrow_captures_binding_name() {
438 let entries = walk("const bar = () => 42;");
439 assert_eq!(entries.len(), 1);
440 assert_eq!(entries[0].name, "bar");
441 }
442
443 #[test]
444 fn const_function_expression_captures_binding_name_not_fn_id() {
445 let entries = walk("const outer = function inner() { return 1; };");
446 assert_eq!(entries.len(), 1);
447 assert_eq!(entries[0].name, "outer");
448 }
449
450 #[test]
451 fn class_methods_use_method_names() {
452 let entries = walk(
453 r"
454 class Foo {
455 bar() { return 1; }
456 baz() { return 2; }
457 }",
458 );
459 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
460 assert_eq!(names, vec!["bar", "baz"]);
461 }
462
463 #[test]
464 fn callback_argument_takes_the_callee_name() {
465 let entries = walk("setTimeout(() => { console.log('hi'); }, 10);");
468 assert_eq!(entries.len(), 1);
469 assert_eq!(entries[0].name, "setTimeout");
470 }
471
472 #[test]
473 fn member_callee_names_each_callback_in_source_order() {
474 let entries = walk(
475 r"
476 [1, 2, 3].map(() => 1);
477 [4, 5, 6].filter(() => true);
478 ",
479 );
480 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
481 assert_eq!(names, vec!["map", "filter"]);
482 }
483
484 #[test]
485 fn named_function_still_advances_counter_matching_instrumenter() {
486 let entries = walk(
490 r"
491 function named() { return 1; }
492 [1].map(() => 2);
493 ",
494 );
495 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
496 assert_eq!(names, vec!["named", "map"]);
497 }
498
499 #[test]
500 fn plain_identifier_callee_names_the_callback() {
501 let entries = walk("useMemo(() => compute());");
502 assert_eq!(entries[0].name, "useMemo");
503 }
504
505 #[test]
506 fn new_expression_callee_names_the_callback() {
507 let entries = walk("new Promise((resolve) => resolve(1));");
508 assert_eq!(entries[0].name, "Promise");
509 }
510
511 #[test]
512 fn callback_after_a_string_argument_is_named_from_the_callee() {
513 let entries = walk(r#"el.addEventListener("click", () => handle());"#);
516 assert_eq!(entries[0].name, "addEventListener");
517 }
518
519 #[test]
520 fn computed_string_key_callee_is_named() {
521 let entries = walk(r#"obj["handler"](() => run());"#);
522 assert_eq!(entries[0].name, "handler");
523 }
524
525 #[test]
526 fn chained_call_does_not_leak_the_earlier_callee_onto_the_later_callback() {
527 let entries = walk("p.then(() => a).catch(() => b);");
531 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
532 assert_eq!(names, vec!["then", "catch"]);
533 }
534
535 #[test]
536 fn nested_callbacks_each_take_their_own_callee() {
537 let entries = walk("outer(() => inner(() => 1));");
538 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
539 assert_eq!(names, vec!["outer", "inner"]);
540 }
541
542 #[test]
543 fn binding_name_wins_over_callee() {
544 let entries = walk("const handler = () => run();");
547 assert_eq!(entries[0].name, "handler");
548 }
549
550 #[test]
551 fn named_function_expression_argument_keeps_its_own_id() {
552 let entries = walk("run(function inner() { return 1; });");
553 assert_eq!(entries[0].name, "inner");
554 }
555
556 #[test]
557 fn iife_callee_stays_anonymous() {
558 let entries = walk("(function () { return 1; })();");
560 assert_eq!(entries[0].name, "(anonymous_0)");
561 }
562
563 #[test]
564 fn computed_non_string_callee_stays_anonymous() {
565 let entries = walk("handlers[index](() => run());");
566 assert_eq!(entries[0].name, "(anonymous_0)");
567 }
568
569 #[test]
570 fn parenthesized_callee_unwraps_to_the_inner_name() {
571 assert_eq!(walk("(foo)(() => run());")[0].name, "foo");
572 assert_eq!(walk("(a.b)(() => run());")[0].name, "b");
573 }
574
575 #[test]
576 fn anonymous_after_named_chain_uses_next_counter_value() {
577 let entries = walk(
578 r"
579 function a() {}
580 function b() {}
581 function c() {}
582 const d = () => 4;
583 ",
584 );
585 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
586 assert_eq!(names, vec!["a", "b", "c", "d"]);
587 }
588
589 #[test]
590 fn typescript_overload_signatures_dont_emit_or_advance_counter() {
591 let entries = walk(
592 r"
593 function foo(): number;
594 function foo(s: string): string;
595 function foo(s?: string): number | string { return s ? s : 1; }
596 [1].map(() => 2);
597 ",
598 );
599 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
600 assert_eq!(names, vec!["foo", "map"]);
601 }
602
603 #[test]
604 fn export_default_named_function_keeps_explicit_name() {
605 let entries = walk("export default function foo() { return 1; }");
606 assert_eq!(entries.len(), 1);
607 assert_eq!(entries[0].name, "foo");
608 }
609
610 #[test]
611 fn export_default_anonymous_function_uses_counter() {
612 let entries = walk("export default function() { return 1; }");
613 assert_eq!(entries.len(), 1);
614 assert_eq!(entries[0].name, "(anonymous_0)");
615 }
616
617 #[test]
618 fn nested_function_numbered_after_parent_in_traversal_order() {
619 let entries = walk(
620 r"
621 function outer() {
622 return function() { return 1; };
623 }",
624 );
625 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
626 assert_eq!(names, vec!["outer", "(anonymous_1)"]);
627 }
628
629 #[test]
630 fn line_number_is_one_based_from_source_start() {
631 let entries = walk("\n\nfunction atLineThree() {}");
632 assert_eq!(entries.len(), 1);
633 assert_eq!(entries[0].line, 3);
634 }
635
636 #[test]
637 fn short_jsx_in_js_file_retries_with_jsx_parser() {
638 let entries = walk_source(&PathBuf::from("component.js"), "const A = () => <div />;");
639 assert_eq!(entries.len(), 1);
640 assert_eq!(entries[0].name, "A");
641 assert_eq!(entries[0].line, 1);
642 }
643
644 #[test]
645 fn object_method_shorthand_uses_anonymous_counter() {
646 let entries = walk("const obj = { run() { return 1; } };");
647 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
648 assert_eq!(names, vec!["(anonymous_0)"]);
649 }
650
651 #[test]
652 fn class_property_arrow_uses_anonymous_counter() {
653 let entries = walk(
654 r"
655 class Foo {
656 bar = () => 1;
657 }",
658 );
659 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
660 assert_eq!(names, vec!["(anonymous_0)"]);
661 }
662
663 #[test]
664 fn records_one_indexed_utf16_columns() {
665 let entries = walk("function foo() { return 1; }");
666 assert_eq!(entries.len(), 1);
667 assert_eq!(entries[0].start_column, 1);
668 assert_eq!(entries[0].end_line, 1);
669 assert!(entries[0].end_column > entries[0].start_column);
670 }
671
672 #[test]
673 fn utf16_column_counts_code_units_not_bytes() {
674 let entries = walk("const e = \"\u{1F600}\"; const f = () => 1;");
675 let f = entries.iter().find(|e| e.name == "f").expect("f present");
676 let byte_prefix_len = "const e = \"\u{1F600}\"; const f = ".len() as u32;
677 assert!(f.start_column < byte_prefix_len + 1);
678 }
679
680 #[test]
681 fn utf16_columns_stay_exact_across_a_long_single_line() {
682 use std::fmt::Write as _;
687 let mut src = String::new();
688 for i in 0..40 {
689 let _ = write!(src, "function f{i}() {{ return \"\u{1F600}\"; }} ");
690 }
691 src.push_str("function outer() { const inner = () => \"\u{1F600}\"; return inner; }");
692 src.push_str("\nconst tail = () => 1;");
693 let entries = walk(&src);
694 let col = |byte: usize| src[..byte].encode_utf16().count() as u32 + 1;
695
696 for i in [0_usize, 17, 39] {
697 let body = format!("function f{i}() {{ return \"\u{1F600}\"; }}");
698 let start = src.find(&body).expect("function text present");
699 let entry = entries
700 .iter()
701 .find(|e| e.name == format!("f{i}"))
702 .expect("entry present");
703 assert_eq!(entry.line, 1);
704 assert_eq!(entry.start_column, col(start));
705 assert_eq!(entry.end_line, 1);
706 assert_eq!(entry.end_column, col(start + body.len()));
707 }
708
709 let inner_start = src
710 .find("() => \"\u{1F600}\"")
711 .expect("inner arrow present");
712 let inner = entries
713 .iter()
714 .find(|e| e.name == "inner")
715 .expect("inner present");
716 assert_eq!(inner.line, 1);
717 assert_eq!(inner.start_column, col(inner_start));
718
719 let tail = entries
720 .iter()
721 .find(|e| e.name == "tail")
722 .expect("tail present");
723 let line2_start = src.find('\n').expect("newline present") + 1;
724 let tail_start = src.rfind("() => 1").expect("tail arrow present");
725 assert_eq!(tail.line, 2);
726 assert_eq!(
727 tail.start_column,
728 src[line2_start..tail_start].encode_utf16().count() as u32 + 1
729 );
730 }
731
732 #[test]
733 fn same_line_distinct_named_functions_have_distinct_positions() {
734 let entries = walk("function a() {} function b() {}");
735 let a = entries.iter().find(|e| e.name == "a").expect("a present");
736 let b = entries.iter().find(|e| e.name == "b").expect("b present");
737 assert_eq!(a.line, b.line, "both on line 1");
738 assert_ne!(
739 a.start_column, b.start_column,
740 "same-line functions are column-disambiguated"
741 );
742 }
743
744 #[test]
745 fn same_line_anonymous_functions_stay_distinct_via_counter() {
746 let entries = walk("const xs = [() => 1, () => 2];");
747 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
748 assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
749 assert_eq!(entries[0].line, entries[1].line, "both on line 1");
750 assert_ne!(
751 entries[0].name, entries[1].name,
752 "counter keeps them distinct"
753 );
754 }
755
756 #[test]
757 fn source_hash_is_the_content_digest_of_the_function_span() {
758 let src = "function foo() { return 1; }";
759 let entries = walk(src);
760 assert_eq!(entries.len(), 1);
761 assert_eq!(
762 entries[0].source_hash,
763 fallow_cov_protocol::source_hash_for(src.as_bytes())
764 );
765 assert_eq!(entries[0].source_hash.len(), 16);
766 assert!(
767 entries[0]
768 .source_hash
769 .chars()
770 .all(|c| c.is_ascii_hexdigit())
771 );
772 }
773
774 #[test]
775 fn source_hash_survives_line_moves_and_tracks_body_edits() {
776 let original = walk("function foo() { return 1; }");
777 let moved = walk("\n\nfunction foo() { return 1; }");
778 assert_eq!(
779 original[0].source_hash, moved[0].source_hash,
780 "a moved-but-unedited function must keep its source_hash"
781 );
782 let edited = walk("function foo() { return 2; }");
783 assert_ne!(
784 original[0].source_hash, edited[0].source_hash,
785 "an edited body must change the source_hash"
786 );
787 }
788}