use std::path::Path;
use oxc_allocator::Allocator;
#[allow(clippy::wildcard_imports, reason = "many AST types used")]
use oxc_ast::ast::*;
use oxc_ast_visit::{Visit, walk};
use oxc_parser::Parser;
use oxc_semantic::ScopeFlags;
use oxc_span::{SourceType, Span};
use rustc_hash::FxHashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InventoryEntry {
pub name: String,
pub line: u32,
pub start_column: u32,
pub end_line: u32,
pub end_column: u32,
pub source_hash: String,
pub is_callback: bool,
}
struct ColCache {
line_idx: usize,
byte_end: usize,
utf16_units: usize,
}
struct ResolvedName {
name: String,
is_callback: bool,
}
impl ResolvedName {
const fn named(name: String) -> Self {
Self {
name,
is_callback: false,
}
}
}
struct InventoryVisitor<'a> {
source: &'a str,
line_offsets: &'a [u32],
entries: Vec<InventoryEntry>,
col_cache: ColCache,
pending_name: Option<String>,
pending_callee_name: Option<String>,
anonymous_counter: u32,
}
impl<'a> InventoryVisitor<'a> {
const fn new(source: &'a str, line_offsets: &'a [u32]) -> Self {
Self {
source,
line_offsets,
entries: Vec::new(),
col_cache: ColCache {
line_idx: usize::MAX,
byte_end: 0,
utf16_units: 0,
},
pending_name: None,
pending_callee_name: None,
anonymous_counter: 0,
}
}
fn resolve_name(&mut self, explicit: Option<&str>) -> ResolvedName {
let n = self.anonymous_counter;
self.anonymous_counter += 1;
if let Some(pending) = self.pending_name.take() {
return ResolvedName::named(pending);
}
if let Some(name) = explicit {
return ResolvedName::named(name.to_owned());
}
if let Some(callee) = self.pending_callee_name.take() {
return ResolvedName {
name: callee,
is_callback: true,
};
}
ResolvedName::named(format!("(anonymous_{n})"))
}
fn record(&mut self, resolved: ResolvedName, span: Span) {
let (line, start_column) = self.line_col_utf16(span.start);
let (end_line, end_column) = self.line_col_utf16(span.end);
let source_hash = self
.source
.get(span.start as usize..span.end as usize)
.map_or_else(
|| fallow_cov_protocol::source_hash_for(b""),
|slice| fallow_cov_protocol::source_hash_for(slice.as_bytes()),
);
self.entries.push(InventoryEntry {
name: resolved.name,
line,
start_column,
end_line,
end_column,
source_hash,
is_callback: resolved.is_callback,
});
}
fn line_col_utf16(&mut self, byte_offset: u32) -> (u32, u32) {
let line_idx = match self.line_offsets.binary_search(&byte_offset) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
};
let line = line_idx as u32 + 1;
let line_start = self.line_offsets[line_idx] as usize;
let mut end = byte_offset as usize;
while end > line_start && !self.source.is_char_boundary(end) {
end -= 1;
}
let from_cache = if self.col_cache.line_idx == line_idx {
if end >= self.col_cache.byte_end {
self.utf16_len(self.col_cache.byte_end, end)
.map(|gap| self.col_cache.utf16_units + gap)
} else {
self.utf16_len(end, self.col_cache.byte_end)
.map(|gap| self.col_cache.utf16_units - gap)
}
} else {
None
};
let col_utf16 = from_cache.unwrap_or_else(|| self.utf16_len(line_start, end).unwrap_or(0));
self.col_cache = ColCache {
line_idx,
byte_end: end,
utf16_units: col_utf16,
};
(line, col_utf16 as u32 + 1)
}
fn utf16_len(&self, start: usize, end: usize) -> Option<usize> {
self.source
.get(start..end)
.map(|slice| slice.encode_utf16().count())
}
}
impl<'ast> Visit<'ast> for InventoryVisitor<'_> {
fn visit_function(&mut self, func: &Function<'ast>, flags: ScopeFlags) {
if func.body.is_none() {
walk::walk_function(self, func, flags);
return;
}
let resolved = self.resolve_name(func.id.as_ref().map(|id| id.name.as_str()));
self.record(resolved, func.span);
walk::walk_function(self, func, flags);
}
fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'ast>) {
let resolved = self.resolve_name(None);
self.record(resolved, arrow.span);
walk::walk_arrow_function_expression(self, arrow);
}
fn visit_method_definition(&mut self, method: &MethodDefinition<'ast>) {
if let Some(name) = method.key.static_name() {
self.pending_name = Some(accessor_label(
matches!(method.kind, MethodDefinitionKind::Get),
matches!(method.kind, MethodDefinitionKind::Set),
&name,
));
}
walk::walk_method_definition(self, method);
self.pending_name = None;
}
fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'ast>) {
if let Some(id) = decl.id.get_binding_identifier()
&& decl.init.as_ref().is_some_and(|init| {
matches!(
init,
Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
)
})
{
self.pending_name = Some(id.name.to_string());
}
walk::walk_variable_declarator(self, decl);
self.pending_name = None;
}
fn visit_object_property(&mut self, prop: &ObjectProperty<'ast>) {
let is_method_like =
prop.method || matches!(prop.kind, PropertyKind::Get | PropertyKind::Set);
let is_function_valued = matches!(
prop.value,
Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_)
);
self.pending_name = if is_method_like || is_function_valued {
prop.key.static_name().map(|name| {
accessor_label(
matches!(prop.kind, PropertyKind::Get),
matches!(prop.kind, PropertyKind::Set),
&name,
)
})
} else {
None
};
walk::walk_object_property(self, prop);
self.pending_name = None;
}
fn visit_assignment_expression(&mut self, expr: &AssignmentExpression<'ast>) {
if matches!(expr.operator, AssignmentOperator::Assign)
&& matches!(
expr.right,
Expression::FunctionExpression(_)
| Expression::ArrowFunctionExpression(_)
| Expression::ClassExpression(_)
)
{
self.pending_name = assignment_target_name(&expr.left);
}
walk::walk_assignment_expression(self, expr);
self.pending_name = None;
}
fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'ast>) {
let anonymous = match &decl.declaration {
ExportDefaultDeclarationKind::FunctionDeclaration(func) => func.id.is_none(),
ExportDefaultDeclarationKind::ArrowFunctionExpression(_) => true,
_ => false,
};
if anonymous {
self.pending_name = Some("default".to_owned());
}
walk::walk_export_default_declaration(self, decl);
self.pending_name = None;
}
fn visit_call_expression(&mut self, call: &CallExpression<'ast>) {
self.visit_expression(&call.callee);
let name = callee_name(&call.callee);
for argument in &call.arguments {
self.pending_callee_name.clone_from(&name);
self.visit_argument(argument);
}
self.pending_callee_name = None;
}
fn visit_new_expression(&mut self, new_expr: &NewExpression<'ast>) {
self.visit_expression(&new_expr.callee);
let name = callee_name(&new_expr.callee);
for argument in &new_expr.arguments {
self.pending_callee_name.clone_from(&name);
self.visit_argument(argument);
}
self.pending_callee_name = None;
}
}
fn accessor_label(is_getter: bool, is_setter: bool, name: &str) -> String {
if is_getter {
return format!("get {name}");
}
if is_setter {
return format!("set {name}");
}
name.to_owned()
}
fn assignment_target_name(target: &AssignmentTarget<'_>) -> Option<String> {
match target {
AssignmentTarget::StaticMemberExpression(member) => Some(member.property.name.to_string()),
AssignmentTarget::ComputedMemberExpression(member) => match &member.expression {
Expression::StringLiteral(lit) => Some(lit.value.to_string()),
_ => None,
},
_ => None,
}
}
fn callee_name(callee: &Expression<'_>) -> Option<String> {
match callee {
Expression::Identifier(ident) => Some(ident.name.to_string()),
Expression::StaticMemberExpression(member) => Some(member.property.name.to_string()),
Expression::ComputedMemberExpression(member) => match &member.expression {
Expression::StringLiteral(lit) => Some(lit.value.to_string()),
_ => None,
},
Expression::ParenthesizedExpression(paren) => callee_name(&paren.expression),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InventoryComplexity {
pub cyclomatic: u16,
pub cognitive: u16,
}
#[must_use]
pub fn walk_source(path: &Path, source: &str) -> Vec<InventoryEntry> {
walk_source_with_complexity(path, source).0
}
#[must_use]
pub fn walk_source_with_complexity(
path: &Path,
source: &str,
) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
let source_type = SourceType::from_path(path).unwrap_or_default();
let line_offsets = fallow_types::extract::compute_line_offsets(source);
let primary = walk_one_parse(source, source_type, &line_offsets);
if primary.0.is_empty() && !source_type.is_jsx() {
let jsx_type = if source_type.is_typescript() {
SourceType::tsx()
} else {
SourceType::jsx()
};
let retry = walk_one_parse(source, jsx_type, &line_offsets);
if !retry.0.is_empty() {
return retry;
}
}
primary
}
fn walk_one_parse(
source: &str,
source_type: SourceType,
line_offsets: &[u32],
) -> (Vec<InventoryEntry>, FxHashMap<String, InventoryComplexity>) {
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, source, source_type).parse();
let mut visitor = InventoryVisitor::new(source, line_offsets);
visitor.visit_program(&parser_return.program);
let complexity =
crate::complexity::compute_complexity(&parser_return.program, source, line_offsets);
let metrics: FxHashMap<String, InventoryComplexity> = complexity
.into_iter()
.filter_map(|fc| {
fc.source_hash.map(|hash| {
(
hash,
InventoryComplexity {
cyclomatic: fc.cyclomatic,
cognitive: fc.cognitive,
},
)
})
})
.collect();
(visitor.entries, metrics)
}
#[cfg(all(test, not(miri)))]
mod tests {
use super::*;
use std::path::PathBuf;
fn walk(source: &str) -> Vec<InventoryEntry> {
walk_source(&PathBuf::from("test.ts"), source)
}
#[test]
fn named_function_declaration_uses_its_own_name() {
let entries = walk("function foo() { return 1; }");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "foo");
assert_eq!(entries[0].line, 1);
}
#[test]
fn const_arrow_captures_binding_name() {
let entries = walk("const bar = () => 42;");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "bar");
}
#[test]
fn const_function_expression_captures_binding_name_not_fn_id() {
let entries = walk("const outer = function inner() { return 1; };");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "outer");
}
#[test]
fn class_methods_use_method_names() {
let entries = walk(
r"
class Foo {
bar() { return 1; }
baz() { return 2; }
}",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["bar", "baz"]);
}
#[test]
fn callback_argument_takes_the_callee_name() {
let entries = walk("setTimeout(() => { console.log('hi'); }, 10);");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "setTimeout");
}
#[test]
fn member_callee_names_each_callback_in_source_order() {
let entries = walk(
r"
[1, 2, 3].map(() => 1);
[4, 5, 6].filter(() => true);
",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["map", "filter"]);
}
#[test]
fn named_function_still_advances_counter_matching_instrumenter() {
let entries = walk(
r"
function named() { return 1; }
[1].map(() => 2);
",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["named", "map"]);
}
#[test]
fn plain_identifier_callee_names_the_callback() {
let entries = walk("useMemo(() => compute());");
assert_eq!(entries[0].name, "useMemo");
}
#[test]
fn new_expression_callee_names_the_callback() {
let entries = walk("new Promise((resolve) => resolve(1));");
assert_eq!(entries[0].name, "Promise");
}
#[test]
fn callback_after_a_string_argument_is_named_from_the_callee() {
let entries = walk(r#"el.addEventListener("click", () => handle());"#);
assert_eq!(entries[0].name, "addEventListener");
}
#[test]
fn computed_string_key_callee_is_named() {
let entries = walk(r#"obj["handler"](() => run());"#);
assert_eq!(entries[0].name, "handler");
}
#[test]
fn chained_call_does_not_leak_the_earlier_callee_onto_the_later_callback() {
let entries = walk("p.then(() => a).catch(() => b);");
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["then", "catch"]);
}
#[test]
fn nested_callbacks_each_take_their_own_callee() {
let entries = walk("outer(() => inner(() => 1));");
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["outer", "inner"]);
}
#[test]
fn binding_name_wins_over_callee() {
let entries = walk("const handler = () => run();");
assert_eq!(entries[0].name, "handler");
}
#[test]
fn named_function_expression_argument_keeps_its_own_id() {
let entries = walk("run(function inner() { return 1; });");
assert_eq!(entries[0].name, "inner");
}
#[test]
fn iife_callee_stays_anonymous() {
let entries = walk("(function () { return 1; })();");
assert_eq!(entries[0].name, "(anonymous_0)");
}
#[test]
fn computed_non_string_callee_stays_anonymous() {
let entries = walk("handlers[index](() => run());");
assert_eq!(entries[0].name, "(anonymous_0)");
}
#[test]
fn parenthesized_callee_unwraps_to_the_inner_name() {
assert_eq!(walk("(foo)(() => run());")[0].name, "foo");
assert_eq!(walk("(a.b)(() => run());")[0].name, "b");
}
#[test]
fn anonymous_after_named_chain_uses_next_counter_value() {
let entries = walk(
r"
function a() {}
function b() {}
function c() {}
const d = () => 4;
",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["a", "b", "c", "d"]);
}
#[test]
fn typescript_overload_signatures_dont_emit_or_advance_counter() {
let entries = walk(
r"
function foo(): number;
function foo(s: string): string;
function foo(s?: string): number | string { return s ? s : 1; }
[1].map(() => 2);
",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["foo", "map"]);
}
#[test]
fn export_default_named_function_keeps_explicit_name() {
let entries = walk("export default function foo() { return 1; }");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "foo");
}
#[test]
fn export_default_anonymous_function_is_named_default() {
let entries = walk("export default function() { return 1; }");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "default");
}
#[test]
fn export_default_anonymous_arrow_is_named_default() {
let entries = walk("export default () => 1;");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "default");
}
#[test]
fn nested_function_numbered_after_parent_in_traversal_order() {
let entries = walk(
r"
function outer() {
return function() { return 1; };
}",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["outer", "(anonymous_1)"]);
}
#[test]
fn line_number_is_one_based_from_source_start() {
let entries = walk("\n\nfunction atLineThree() {}");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].line, 3);
}
#[test]
fn short_jsx_in_js_file_retries_with_jsx_parser() {
let entries = walk_source(&PathBuf::from("component.js"), "const A = () => <div />;");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "A");
assert_eq!(entries[0].line, 1);
}
#[test]
fn object_method_shorthand_uses_the_key_name() {
let entries = walk("const obj = { run() { return 1; } };");
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["run"]);
}
#[test]
fn object_accessors_carry_the_get_and_set_label() {
let entries = walk(
r"
const obj = {
get closed() { return true; },
set closed(value) { this.value = value; },
};",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["get closed", "set closed"]);
}
#[test]
fn function_valued_object_property_uses_the_key_name() {
let entries = walk("const client = { execute: async (sql) => sql.length };");
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["execute"]);
}
#[test]
fn object_property_holding_a_call_leaves_the_callback_to_its_callee() {
let entries = walk("const column = { references: memo(() => other.id) };");
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["memo"]);
}
#[test]
fn class_accessors_carry_the_get_and_set_label() {
let entries = walk(
r"
class Client {
get closed() { return true; }
set closed(value) { this.value = value; }
}",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["get closed", "set closed"]);
}
#[test]
fn member_assignment_target_names_the_assigned_function() {
let entries = walk(
r#"
client.execute = async (sql) => sql;
client["rollback"] = function () { return 1; };
"#,
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["execute", "rollback"]);
}
#[test]
fn only_callee_named_functions_are_flagged_as_callbacks() {
let entries = walk(
r"
function declared() { return 1; }
const bound = () => 2;
const obj = { run() { return 3; } };
[1].map(() => 4);
(function () { return 5; })();
",
);
let flags: Vec<_> = entries
.iter()
.map(|e| (e.name.as_str(), e.is_callback))
.collect();
assert_eq!(
flags,
vec![
("declared", false),
("bound", false),
("run", false),
("map", true),
("(anonymous_4)", false),
]
);
}
#[test]
fn drizzle_style_schema_names_every_callback_after_its_callee() {
let entries = walk(
r#"
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
orgId: text("org_id").references(() => orgs.id),
}, (table) => [index("users_org_idx").on(table.orgId)]);
"#,
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["references", "sqliteTable"]);
assert!(entries.iter().all(|entry| entry.is_callback));
}
#[test]
fn class_property_arrow_uses_anonymous_counter() {
let entries = walk(
r"
class Foo {
bar = () => 1;
}",
);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["(anonymous_0)"]);
}
#[test]
fn records_one_indexed_utf16_columns() {
let entries = walk("function foo() { return 1; }");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].start_column, 1);
assert_eq!(entries[0].end_line, 1);
assert!(entries[0].end_column > entries[0].start_column);
}
#[test]
fn utf16_column_counts_code_units_not_bytes() {
let entries = walk("const e = \"\u{1F600}\"; const f = () => 1;");
let f = entries.iter().find(|e| e.name == "f").expect("f present");
let byte_prefix_len = "const e = \"\u{1F600}\"; const f = ".len() as u32;
assert!(f.start_column < byte_prefix_len + 1);
}
#[test]
fn utf16_columns_stay_exact_across_a_long_single_line() {
use std::fmt::Write as _;
let mut src = String::new();
for i in 0..40 {
let _ = write!(src, "function f{i}() {{ return \"\u{1F600}\"; }} ");
}
src.push_str("function outer() { const inner = () => \"\u{1F600}\"; return inner; }");
src.push_str("\nconst tail = () => 1;");
let entries = walk(&src);
let col = |byte: usize| src[..byte].encode_utf16().count() as u32 + 1;
for i in [0_usize, 17, 39] {
let body = format!("function f{i}() {{ return \"\u{1F600}\"; }}");
let start = src.find(&body).expect("function text present");
let entry = entries
.iter()
.find(|e| e.name == format!("f{i}"))
.expect("entry present");
assert_eq!(entry.line, 1);
assert_eq!(entry.start_column, col(start));
assert_eq!(entry.end_line, 1);
assert_eq!(entry.end_column, col(start + body.len()));
}
let inner_start = src
.find("() => \"\u{1F600}\"")
.expect("inner arrow present");
let inner = entries
.iter()
.find(|e| e.name == "inner")
.expect("inner present");
assert_eq!(inner.line, 1);
assert_eq!(inner.start_column, col(inner_start));
let tail = entries
.iter()
.find(|e| e.name == "tail")
.expect("tail present");
let line2_start = src.find('\n').expect("newline present") + 1;
let tail_start = src.rfind("() => 1").expect("tail arrow present");
assert_eq!(tail.line, 2);
assert_eq!(
tail.start_column,
src[line2_start..tail_start].encode_utf16().count() as u32 + 1
);
}
#[test]
fn same_line_distinct_named_functions_have_distinct_positions() {
let entries = walk("function a() {} function b() {}");
let a = entries.iter().find(|e| e.name == "a").expect("a present");
let b = entries.iter().find(|e| e.name == "b").expect("b present");
assert_eq!(a.line, b.line, "both on line 1");
assert_ne!(
a.start_column, b.start_column,
"same-line functions are column-disambiguated"
);
}
#[test]
fn same_line_anonymous_functions_stay_distinct_via_counter() {
let entries = walk("const xs = [() => 1, () => 2];");
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["(anonymous_0)", "(anonymous_1)"]);
assert_eq!(entries[0].line, entries[1].line, "both on line 1");
assert_ne!(
entries[0].name, entries[1].name,
"counter keeps them distinct"
);
}
#[test]
fn source_hash_is_the_content_digest_of_the_function_span() {
let src = "function foo() { return 1; }";
let entries = walk(src);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].source_hash,
fallow_cov_protocol::source_hash_for(src.as_bytes())
);
assert_eq!(entries[0].source_hash.len(), 16);
assert!(
entries[0]
.source_hash
.chars()
.all(|c| c.is_ascii_hexdigit())
);
}
#[test]
fn source_hash_survives_line_moves_and_tracks_body_edits() {
let original = walk("function foo() { return 1; }");
let moved = walk("\n\nfunction foo() { return 1; }");
assert_eq!(
original[0].source_hash, moved[0].source_hash,
"a moved-but-unedited function must keep its source_hash"
);
let edited = walk("function foo() { return 2; }");
assert_ne!(
original[0].source_hash, edited[0].source_hash,
"an edited body must change the source_hash"
);
}
}