Skip to main content

cpd_tokenizer/
functions.rs

1//! Function extraction for similarity scoring (issue #999, stage 2).
2//!
3//! A [`FunctionExtractor`] turns a source file into its functions, each with
4//! a name, a span and the pre-order sequence of syntax-tree node types
5//! inside it. Node *types* only: identifiers and literal values are not part
6//! of the sequence, so the summary describes structure. The scoring in
7//! `cpd_core::similarity` is grammar-agnostic; it only ever compares two
8//! functions that carry the same [`FunctionExtractor::grammar`] id.
9//!
10//! # Adding a language
11//!
12//! 1. Implement [`FunctionExtractor`]: pick a stable `grammar` id (for a
13//!    tree-sitter grammar, its name), list the jscpd `formats` it serves,
14//!    and in `extract` walk the tree, opening a [`RawFunction`] at every
15//!    function-like node and appending each visited node's type id (any
16//!    dense `u16`, e.g. tree-sitter's `node.kind_id()`) to every open
17//!    function.
18//! 2. Add the extractor to [`EXTRACTORS`].
19//!
20//! Nothing else changes: the CLI, the MCP tool, the reporters and the
21//! fixtures pick the new formats up through [`supports_functions`].
22
23use 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/// A function found in a source, before token ranges are attached.
32#[derive(Debug, Clone, PartialEq)]
33pub struct RawFunction {
34    /// Grammar that produced `kinds`; functions of different grammars are
35    /// never compared.
36    pub grammar: &'static str,
37    pub name: String,
38    pub start: Location,
39    pub end: Location,
40    /// Where the code that names the function starts: the key of a method
41    /// or property, or the variable a function is assigned to, when that
42    /// code precedes `start`; `start` otherwise.
43    pub head: Location,
44    /// Pre-order syntax-tree node types of the function, itself included.
45    pub kinds: Vec<u16>,
46}
47
48/// Language plug-in for function extraction.
49pub trait FunctionExtractor: Send + Sync {
50    /// Stable identifier of the grammar behind the node-type ids.
51    fn grammar(&self) -> &'static str;
52    /// jscpd format names this extractor serves.
53    fn formats(&self) -> &'static [&'static str];
54    /// All functions of `source`; empty when the source does not parse.
55    fn extract(&self, source: &str, format: &str) -> Vec<RawFunction>;
56}
57
58/// Registered extractors, consulted in order. Add new languages here.
59pub static EXTRACTORS: &[&dyn FunctionExtractor] = &[&OxcExtractor];
60
61/// The extractor serving `format`, if any.
62pub fn extractor_for(format: &str) -> Option<&'static dyn FunctionExtractor> {
63    EXTRACTORS
64        .iter()
65        .copied()
66        .find(|e| e.formats().contains(&format))
67}
68
69/// Formats handled by [`extract_functions`].
70pub fn supports_functions(format: &str) -> bool {
71    extractor_for(format).is_some()
72}
73
74/// Every format some extractor serves, for messages and docs.
75pub fn supported_function_formats() -> Vec<&'static str> {
76    EXTRACTORS
77        .iter()
78        .flat_map(|e| e.formats().iter().copied())
79        .collect()
80}
81
82/// Extract every function of a source. Returns an empty vector for formats
83/// without an extractor and for sources that fail to parse.
84pub fn extract_functions(source: &str, format: &str) -> Vec<RawFunction> {
85    extract_with(extractor_for(format), source, format)
86}
87
88/// Every function of `source` as `extractor` finds them; empty without an
89/// extractor and for an empty source. For callers that pick extractors from
90/// a registry of their own, like `--semantic`'s.
91pub 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
102/// JavaScript, TypeScript, JSX and TSX through the oxc parser.
103pub 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    // Recoverable diagnostics leave a usable (possibly partial) AST; only a
124    // parser that gave up yields nothing (issue #1023).
125    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    /// Name from the enclosing declarator, property or method, consumed by
154    /// the next function node.
155    pending_name: Option<String>,
156    /// Where the code that named `pending_name` starts, and where its
157    /// value starts: the function that starts there takes that code as its
158    /// head.
159    pending_head: Option<(u32, u32)>,
160    /// The call (by its span: `test.each(t)('title', fn)` and its inner
161    /// `test.each(t)` start at the same byte) that set `pending_name` for
162    /// its test-case callback; leaving that call drops a name no function
163    /// took.
164    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        // Only the function the naming code is about takes its head; one
172        // that opens before it (an arrow in `test.each(table)`) leaves it.
173        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    /// The pending name, for the function that starts at `start`. A test
207    /// title belongs to its callback alone: a function in `.each(table)`
208    /// before it, or one nested in a named callback, does not take it.
209    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    /// Remember where the code naming the next function starts, for the
220    /// function that is the named value itself (`value` starts there), not
221    /// one nested in it.
222    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
305/// Functions that declare one test case in the JavaScript test frameworks
306/// (Jest, Vitest, Mocha, Jasmine, node:test, Bun): `it('title', fn)`, with
307/// `.only`, `.skip`, `.each(table)` and the like after it. Suites
308/// (`describe`) and hooks (`beforeEach`) are left out: they group or set up
309/// tests, while a test case is what a port carries over one by one.
310pub const TEST_CASE_CALLS: &[&str] = &["it", "test", "specify", "fit", "xit", "xtest", "bench"];
311
312/// Members of a test function that declare something other than a test
313/// case: a suite, a step inside a test, a hook, or configuration
314/// (`test.describe`, `test.step`, `test.beforeEach`, `test.use`).
315pub const NOT_TEST_CASES: &[&str] = &[
316    "describe",
317    "step",
318    "beforeEach",
319    "afterEach",
320    "beforeAll",
321    "afterAll",
322    "use",
323    "extend",
324];
325
326/// The title of the test case `call` declares and where its callback
327/// starts, when `call` is `it('rounds cents', () => …)` or one of its
328/// variants and the title is a plain string. The callback then goes by the
329/// title, which is what names a test in these frameworks, where it would
330/// otherwise be an anonymous arrow; its text starts at the call, so the
331/// title is part of what a model sees.
332fn test_case(call: &oxc_ast::ast::CallExpression<'_>) -> Option<(String, u32)> {
333    use oxc_ast::ast::Expression;
334    // `it`, `it.only`, `test.each(table)`, `it.concurrent.each(table)`,
335    // but not Playwright's `test.describe(…)` or `test.step(…)`.
336    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"); // closed first
398        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        // The test's code starts at the call, so its title is part of it.
427        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}