1use crate::line_index::LineIndex;
24use cpd_core::models::Location;
25use oxc_allocator::Allocator;
26use oxc_ast::AstKind;
27use oxc_ast_visit::Visit;
28use oxc_parser::Parser;
29use oxc_span::GetSpan;
30
31#[derive(Debug, Clone, PartialEq)]
33pub struct RawFunction {
34 pub grammar: &'static str,
37 pub name: String,
38 pub start: Location,
39 pub end: Location,
40 pub head: Location,
44 pub kinds: Vec<u16>,
46}
47
48pub trait FunctionExtractor: Send + Sync {
50 fn grammar(&self) -> &'static str;
52 fn formats(&self) -> &'static [&'static str];
54 fn extract(&self, source: &str, format: &str) -> Vec<RawFunction>;
56}
57
58pub static EXTRACTORS: &[&dyn FunctionExtractor] = &[&OxcExtractor];
60
61pub fn extractor_for(format: &str) -> Option<&'static dyn FunctionExtractor> {
63 EXTRACTORS
64 .iter()
65 .copied()
66 .find(|e| e.formats().contains(&format))
67}
68
69pub fn supports_functions(format: &str) -> bool {
71 extractor_for(format).is_some()
72}
73
74pub fn supported_function_formats() -> Vec<&'static str> {
76 EXTRACTORS
77 .iter()
78 .flat_map(|e| e.formats().iter().copied())
79 .collect()
80}
81
82pub fn extract_functions(source: &str, format: &str) -> Vec<RawFunction> {
85 extract_with(extractor_for(format), source, format)
86}
87
88pub fn extract_with(
92 extractor: Option<&dyn FunctionExtractor>,
93 source: &str,
94 format: &str,
95) -> Vec<RawFunction> {
96 match extractor {
97 Some(extractor) if !source.is_empty() => extractor.extract(source, format),
98 _ => Vec::new(),
99 }
100}
101
102pub struct OxcExtractor;
104
105impl FunctionExtractor for OxcExtractor {
106 fn grammar(&self) -> &'static str {
107 "oxc"
108 }
109
110 fn formats(&self) -> &'static [&'static str] {
111 &["javascript", "typescript", "jsx", "tsx"]
112 }
113
114 fn extract(&self, source: &str, format: &str) -> Vec<RawFunction> {
115 extract_with_oxc(source, format)
116 }
117}
118
119fn extract_with_oxc(source: &str, format: &str) -> Vec<RawFunction> {
120 let allocator = Allocator::new();
121 let source_type = crate::javascript::source_type_for_format(format);
122 let parsed = Parser::new(&allocator, source, source_type).parse();
123 if parsed.fatal_error {
126 return Vec::new();
127 }
128 let line_index = LineIndex::new(source.as_bytes());
129 let mut extractor = Extractor {
130 frames: Vec::new(),
131 out: Vec::new(),
132 pending_name: None,
133 pending_head: None,
134 pending_call: None,
135 line_index: &line_index,
136 len: source.len(),
137 };
138 extractor.visit_program(&parsed.program);
139 extractor.out
140}
141
142struct Frame {
143 name: String,
144 head: u32,
145 start: u32,
146 end: u32,
147 kinds: Vec<u16>,
148}
149
150struct Extractor<'i> {
151 frames: Vec<Frame>,
152 out: Vec<RawFunction>,
153 pending_name: Option<String>,
156 pending_head: Option<(u32, u32)>,
160 pending_call: Option<(u32, u32)>,
165 line_index: &'i LineIndex,
166 len: usize,
167}
168
169impl Extractor<'_> {
170 fn open(&mut self, name: String, start: u32, end: u32) {
171 let head = match self.pending_head {
174 Some((head, value)) if value == start => {
175 self.pending_head = None;
176 head
177 }
178 _ => start,
179 };
180 self.frames.push(Frame {
181 name,
182 head,
183 start,
184 end,
185 kinds: Vec::new(),
186 });
187 }
188
189 fn close(&mut self) {
190 let Some(frame) = self.frames.pop() else {
191 return;
192 };
193 let start = (frame.start as usize).min(self.len);
194 let end = (frame.end as usize).min(self.len);
195 let head = (frame.head as usize).min(start);
196 self.out.push(RawFunction {
197 grammar: OxcExtractor.grammar(),
198 name: frame.name,
199 start: self.line_index.location(start),
200 end: self.line_index.location(end),
201 head: self.line_index.location(head),
202 kinds: frame.kinds,
203 });
204 }
205
206 fn take_name(&mut self, start: u32) -> Option<String> {
210 if self.pending_call.is_some() {
211 if self.pending_head.map(|(_, callback)| callback) != Some(start) {
212 return None;
213 }
214 self.pending_call = None;
215 }
216 self.pending_name.take()
217 }
218
219 fn name_head(&mut self, head: u32, value: Option<u32>) {
223 self.pending_head = match (&self.pending_name, value) {
224 (Some(_), Some(value)) => Some((head, value)),
225 _ => None,
226 };
227 }
228}
229
230impl<'a> Visit<'a> for Extractor<'_> {
231 fn enter_node(&mut self, kind: AstKind<'a>) {
232 match kind {
233 AstKind::VariableDeclarator(d) => {
234 self.pending_name = d.id.get_identifier_name().map(|n| n.to_string());
235 self.name_head(d.span.start, d.init.as_ref().map(|v| v.span().start));
236 }
237 AstKind::MethodDefinition(m) => {
238 self.pending_name = m.key.static_name().map(|n| n.into_owned());
239 self.name_head(m.key.span().start, Some(m.value.span.start));
240 }
241 AstKind::PropertyDefinition(p) => {
242 self.pending_name = p.key.static_name().map(|n| n.into_owned());
243 self.name_head(p.key.span().start, p.value.as_ref().map(|v| v.span().start));
244 }
245 AstKind::ObjectProperty(p) => {
246 self.pending_name = p.key.static_name().map(|n| n.into_owned());
247 self.name_head(p.key.span().start, Some(p.value.span().start));
248 }
249 AstKind::CallExpression(call) => {
250 if let Some((title, callback)) = test_case(call) {
251 self.pending_name = Some(title);
252 self.pending_head = Some((call.span.start, callback));
253 self.pending_call = Some((call.span.start, call.span.end));
254 }
255 }
256 AstKind::Function(f) => {
257 let own = f.id.as_ref().map(|id| id.name.to_string());
258 let name = match own {
259 Some(name) => name,
260 None => self
261 .take_name(f.span.start)
262 .unwrap_or_else(|| "<anonymous>".to_string()),
263 };
264 let span = f.span;
265 self.open(name, span.start, span.end);
266 }
267 AstKind::ArrowFunctionExpression(a) => {
268 let name = self
269 .take_name(a.span.start)
270 .unwrap_or_else(|| "<arrow>".to_string());
271 let span = a.span;
272 self.open(name, span.start, span.end);
273 }
274 _ => {}
275 }
276 let ty = kind.ty() as u16;
277 for frame in &mut self.frames {
278 frame.kinds.push(ty);
279 }
280 }
281
282 fn leave_node(&mut self, kind: AstKind<'a>) {
283 match kind {
284 AstKind::Function(_) | AstKind::ArrowFunctionExpression(_) => self.close(),
285 AstKind::CallExpression(call)
286 if self.pending_call == Some((call.span.start, call.span.end)) =>
287 {
288 self.pending_name = None;
289 self.pending_head = None;
290 self.pending_call = None;
291 }
292 AstKind::VariableDeclarator(_)
293 | AstKind::MethodDefinition(_)
294 | AstKind::PropertyDefinition(_)
295 | AstKind::ObjectProperty(_) => {
296 self.pending_name = None;
297 self.pending_head = None;
298 }
299 _ => {}
300 }
301 let _ = kind.span();
302 }
303}
304
305pub const TEST_CASE_CALLS: &[&str] = &["it", "test", "specify", "fit", "xit", "xtest", "bench"];
311
312pub const NOT_TEST_CASES: &[&str] = &[
316 "describe",
317 "step",
318 "beforeEach",
319 "afterEach",
320 "beforeAll",
321 "afterAll",
322 "use",
323 "extend",
324];
325
326fn test_case(call: &oxc_ast::ast::CallExpression<'_>) -> Option<(String, u32)> {
333 use oxc_ast::ast::Expression;
334 let mut callee = &call.callee;
337 let root = loop {
338 match callee {
339 Expression::Identifier(id) => break id.name.as_str(),
340 Expression::StaticMemberExpression(member) => {
341 if NOT_TEST_CASES.contains(&member.property.name.as_str()) {
342 return None;
343 }
344 callee = &member.object;
345 }
346 Expression::CallExpression(inner) => callee = &inner.callee,
347 _ => return None,
348 }
349 };
350 if !TEST_CASE_CALLS.contains(&root) {
351 return None;
352 }
353 let mut args = call.arguments.iter().filter_map(|a| a.as_expression());
354 let title = match args.next()? {
355 Expression::StringLiteral(literal) => literal.value.to_string(),
356 Expression::TemplateLiteral(template) if template.expressions.is_empty() => {
357 let text = template.quasis.first()?;
358 text.value
359 .cooked
360 .as_ref()
361 .unwrap_or(&text.value.raw)
362 .to_string()
363 }
364 _ => return None,
365 };
366 let callback = args.find_map(|a| match a {
367 Expression::ArrowFunctionExpression(f) => Some(f.span.start),
368 Expression::FunctionExpression(f) => Some(f.span.start),
369 _ => None,
370 })?;
371 let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
372 (!title.is_empty()).then_some((title, callback))
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 const SRC: &str = "export function total(items) {\n let sum = 0;\n for (const it of items) { sum += it.price; }\n return sum;\n}\nconst double = (x) => x * 2;\nclass Cart {\n add(item) { this.items.push(item); }\n}\nconst obj = { run() { return 1; }, cb: function () { return 2; } };\n";
380
381 #[test]
382 fn extracts_declarations_arrows_methods_and_properties_with_names() {
383 let fns = extract_functions(SRC, "javascript");
384 let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
385 assert_eq!(names, vec!["total", "double", "add", "run", "cb"]);
386 let total = &fns[0];
387 assert_eq!((total.start.line, total.end.line), (1, 5));
388 assert!(total.kinds.len() > 20, "{}", total.kinds.len());
389 assert_eq!(total.kinds[0], oxc_ast::AstType::Function as u16);
390 }
391
392 #[test]
393 fn nested_functions_are_emitted_separately_and_contribute_to_the_outer() {
394 let src = "function outer() {\n const inner = () => 1;\n return inner();\n}\n";
395 let fns = extract_functions(src, "typescript");
396 assert_eq!(fns.len(), 2);
397 assert_eq!(fns[0].name, "inner"); assert_eq!(fns[1].name, "outer");
399 assert!(fns[1].kinds.len() > fns[0].kinds.len());
400 }
401
402 #[test]
403 fn test_case_callbacks_go_by_their_titles() {
404 let src = "describe('money', () => {\n beforeEach(() => reset());\n it('rounds cents', () => {\n expect(round(149)).toBe(100);\n });\n test.each([[1, 2]])('adds %i', (a, b) => {\n expect(a + b).toBe(3);\n });\n it.only(`keeps ${'x'} dynamic`, () => {});\n it('has no callback');\n const later = () => 1;\n test(\"async one\", async function () { await later(); });\n it('named', function named() { [1].map((x) => x * 2); });\n test.each([[() => 1]])('table', (f) => f());\n test.describe('suite', () => {});\n test.step('step', async () => {});\n});\n";
405 let fns = extract_functions(src, "typescript");
406 let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
407 assert_eq!(
408 names,
409 vec![
410 "<arrow>",
411 "rounds cents",
412 "adds %i",
413 "<arrow>",
414 "later",
415 "async one",
416 "<arrow>",
417 "named",
418 "<arrow>",
419 "table",
420 "<arrow>",
421 "<arrow>",
422 "<arrow>"
423 ],
424 "hooks, suites and dynamic titles stay anonymous; a test without a callback names nothing"
425 );
426 let rounds = &fns[1];
428 assert!(src[rounds.head.offset as usize..].starts_with("it('rounds cents', () =>"));
429 assert!(src[rounds.start.offset as usize..].starts_with("() =>"));
430 }
431
432 #[test]
433 fn registry_dispatches_by_format_and_tags_the_grammar() {
434 assert_eq!(extractor_for("typescript").unwrap().grammar(), "oxc");
435 assert!(extractor_for("python").is_none());
436 let formats = supported_function_formats();
437 for f in ["javascript", "typescript", "jsx", "tsx"] {
438 assert!(formats.contains(&f), "{f}");
439 assert!(supports_functions(f));
440 }
441 let fns = extract_functions("const f = () => 1;", "jsx");
442 assert_eq!(fns.len(), 1);
443 assert_eq!(fns[0].grammar, "oxc");
444 }
445
446 #[test]
447 fn unsupported_or_empty_sources_yield_nothing() {
448 assert!(extract_functions("def f():\n pass\n", "python").is_empty());
449 assert!(extract_functions("", "javascript").is_empty());
450 }
451
452 #[test]
453 fn redeclared_functions_are_still_extracted() {
454 let src = "function f(a) { return a + 1; }\nfunction f(b) { return b + 1; }\n";
455 let fns = extract_functions(src, "javascript");
456 assert_eq!(
457 fns.len(),
458 2,
459 "a redeclaration diagnostic must not drop the file"
460 );
461 assert_eq!(fns[0].kinds, fns[1].kinds);
462 }
463
464 #[test]
465 fn renamed_copies_share_the_same_kind_sequence() {
466 let a = extract_functions("function a(x) { return x + 1; }", "javascript");
467 let b = extract_functions("function b(y) { return y + 1; }", "javascript");
468 assert_eq!(a[0].kinds, b[0].kinds);
469 }
470}