Skip to main content

cuke_dedup/
typescript.rs

1//! Tree-sitter based JavaScript and TypeScript step-definition extraction.
2
3mod ast;
4mod handler;
5mod matcher;
6mod module_resolver;
7mod project_resolution;
8mod registrations;
9mod suppression;
10
11use self::handler::{
12    bind_arguments, collect_handler_bindings, fingerprint_handler, fingerprint_method_handler,
13    resolve_handler, HandlerBinding,
14};
15#[cfg(test)]
16use self::handler::{bounded_source_snippet, MAX_HANDLER_SNIPPET_CHARS};
17pub use self::matcher::normalize_matcher;
18#[cfg(test)]
19use self::matcher::normalize_regular_expression;
20pub(crate) use self::matcher::rust_regex_expression;
21use self::matcher::{matcher_value, normalize_matcher_with_flags};
22use self::matcher::{rust_regex_support, RegexSupport};
23use self::module_resolver::RegistrationResolver;
24use self::registrations::{
25    decorator_registration_name, detect_framework, detect_registrations, registration_callee,
26    registration_name, unresolved_registration_module, RegistrationCallee, RegistrationNames,
27};
28use self::suppression::inline_suppressions;
29use crate::model::{Framework, MatcherKind, SourceLocation, StepDefinition};
30use crate::source_adapter::{
31    adapter_for_language, grammar_for_language, SourceAdapter, SourceExtractionSession,
32    UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX,
33};
34pub use crate::source_adapter::{
35    Extraction, ExtractionDiagnostic, ExtractionDiagnosticLevel, SourceFile, SourceLanguage,
36};
37use anyhow::{Context, Result};
38use std::collections::{BTreeMap, BTreeSet};
39use tree_sitter::{Node, Parser};
40
41pub(crate) struct TreeSitterSourceAdapter {
42    name: &'static str,
43    language: SourceLanguage,
44}
45
46pub(crate) static JAVASCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
47    name: "javascript",
48    language: SourceLanguage::JavaScript,
49};
50pub(crate) static TYPESCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
51    name: "typescript",
52    language: SourceLanguage::TypeScript,
53};
54pub(crate) static TSX_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
55    name: "tsx",
56    language: SourceLanguage::Tsx,
57};
58
59#[derive(Default)]
60pub(crate) struct TypeScriptExtractionSession {
61    resolver: RegistrationResolver,
62    configured_registrations: std::collections::BTreeSet<String>,
63}
64
65impl TypeScriptExtractionSession {
66    pub(crate) fn for_root(root: &std::path::Path, registrations: &[String]) -> Self {
67        Self {
68            resolver: RegistrationResolver::for_root(root),
69            configured_registrations: registrations.iter().cloned().collect(),
70        }
71    }
72}
73
74impl SourceAdapter for TreeSitterSourceAdapter {
75    fn name(&self) -> &'static str {
76        self.name
77    }
78
79    fn language(&self) -> SourceLanguage {
80        self.language
81    }
82
83    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction> {
84        let mut session = SourceExtractionSession::default();
85        self.extract_with_session(source, file, &mut session)
86    }
87
88    fn extract_with_session(
89        &self,
90        source: &str,
91        file: &SourceFile,
92        session: &mut SourceExtractionSession,
93    ) -> Result<Extraction> {
94        if file.language != self.language {
95            anyhow::bail!(
96                "{} adapter cannot parse {:?} source {}",
97                self.name,
98                file.language,
99                file.path.display()
100            );
101        }
102        extract_detailed_impl(source, file, &mut session.typescript)
103    }
104}
105
106struct AdapterContext<'source, 'tree> {
107    source: &'source [u8],
108    source_lines: &'source [&'source str],
109    file: &'source SourceFile,
110    framework: Framework,
111    registrations: &'source RegistrationNames,
112    handler_bindings: &'source BTreeMap<String, Vec<HandlerBinding<'tree>>>,
113}
114
115#[derive(Default)]
116struct UnresolvedRegistrationCalls {
117    count: usize,
118    first_location: Option<SourceLocation>,
119    modules: BTreeMap<String, (usize, SourceLocation)>,
120}
121
122impl UnresolvedRegistrationCalls {
123    fn record(&mut self, location: SourceLocation) {
124        self.count = self.count.saturating_add(1);
125        self.first_location.get_or_insert(location);
126    }
127
128    fn record_module(&mut self, module: &str, location: SourceLocation) {
129        let entry = self
130            .modules
131            .entry(module.to_owned())
132            .or_insert_with(|| (0, location));
133        entry.0 = entry.0.saturating_add(1);
134    }
135}
136
137/// Reads and extracts step definitions from a discovered JavaScript or TypeScript file.
138pub fn extract_file(file: &SourceFile) -> Result<Vec<StepDefinition>> {
139    Ok(extract_file_detailed(file)?.definitions)
140}
141
142/// Reads a source file and returns definitions plus localized extraction diagnostics.
143pub fn extract_file_detailed(file: &SourceFile) -> Result<Extraction> {
144    adapter_for_language(file.language).extract_file(file)
145}
146
147/// Extracts step definitions from in-memory source using `file` for syntax and locations.
148pub fn extract(source: &str, file: &SourceFile) -> Result<Vec<StepDefinition>> {
149    Ok(extract_detailed(source, file)?.definitions)
150}
151
152/// Extracts definitions and localized diagnostics from in-memory source.
153pub fn extract_detailed(source: &str, file: &SourceFile) -> Result<Extraction> {
154    adapter_for_language(file.language).extract(source, file)
155}
156
157fn extract_detailed_impl(
158    source: &str,
159    file: &SourceFile,
160    session: &mut TypeScriptExtractionSession,
161) -> Result<Extraction> {
162    let mut parser = Parser::new();
163    parser
164        .set_language(&grammar_for_language(file.language))
165        .context("failed to initialize the JavaScript/TypeScript parser")?;
166    let tree = parser
167        .parse(source, None)
168        .context("Tree-sitter did not return a syntax tree")?;
169
170    let root = tree.root_node();
171    let source_bytes = source.as_bytes();
172    let source_lines: Vec<_> = source.lines().collect();
173    let framework = detect_framework(root, source_bytes);
174    let registrations = detect_registrations(
175        root,
176        source_bytes,
177        framework,
178        &file.path,
179        &mut session.resolver,
180        &session.configured_registrations,
181    )?;
182    let framework = registrations.framework;
183    let mut handler_bindings = BTreeMap::new();
184    collect_handler_bindings(root, source_bytes, &mut handler_bindings);
185    let context = AdapterContext {
186        source: source_bytes,
187        source_lines: &source_lines,
188        file,
189        framework,
190        registrations: &registrations,
191        handler_bindings: &handler_bindings,
192    };
193    let mut definitions = Vec::new();
194    let mut diagnostics = Vec::new();
195    let mut unresolved_registration_calls = UnresolvedRegistrationCalls::default();
196    if root.has_error() {
197        let syntax_node = first_syntax_error(root).unwrap_or(root);
198        diagnostics.push(ExtractionDiagnostic {
199            level: ExtractionDiagnosticLevel::Error,
200            location: node_location(file, syntax_node, source_bytes),
201            message: "source contains JavaScript/TypeScript syntax errors, so analysis is incomplete; fix the syntax, use a .tsx extension for JSX, or narrow definition discovery".to_owned(),
202        });
203    }
204    collect_calls(
205        root,
206        &context,
207        &mut definitions,
208        &mut diagnostics,
209        &mut unresolved_registration_calls,
210    );
211    if let Some(location) = unresolved_registration_calls.first_location {
212        diagnostics.push(ExtractionDiagnostic {
213            level: ExtractionDiagnosticLevel::Warning,
214            location,
215            message: format!(
216                "{UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX} {} call(s) look like step registrations but could not be resolved to a supported registration import or global; definitions may be missing",
217                unresolved_registration_calls.count
218            ),
219        });
220    }
221    let mut explained_modules = BTreeSet::new();
222    for (module, (count, location)) in unresolved_registration_calls.modules {
223        let cause = registrations::unresolved_module_reason(&registrations, &module)
224            .map(|reason| format!(" ({reason})"))
225            .unwrap_or_default();
226        diagnostics.push(ExtractionDiagnostic {
227            level: ExtractionDiagnosticLevel::Warning,
228            location,
229            message: format!(
230                "{UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX} {count} call(s) import a known registration through unresolved module `{module}`{cause}; definitions may be missing"
231            ),
232        });
233        explained_modules.insert(module);
234    }
235    // A specifier can fail resolution without any call being attributed to it — a barrel that is
236    // imported but whose registrations are re-exported onward, or a file whose registration calls
237    // were themselves unresolvable. Report the cause anyway so a resolution limit is never
238    // reduced to a bare "produced 0 definitions".
239    for (module, recorded) in registrations::unresolved_module_reasons(&registrations) {
240        if explained_modules.contains(module) {
241            continue;
242        }
243        let line = recorded.row + 1;
244        let column = unicode_column(source_bytes, recorded.byte_offset, recorded.byte_column);
245        diagnostics.push(ExtractionDiagnostic {
246            level: ExtractionDiagnosticLevel::Warning,
247            location: SourceLocation::new(&file.path, line, column, line, column),
248            message: format!(
249                "{UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX} module `{module}` could not be resolved statically ({}); definitions may be missing",
250                recorded.reason
251            ),
252        });
253    }
254    definitions.sort_by(|left, right| {
255        left.location
256            .line
257            .cmp(&right.location.line)
258            .then(left.location.column.cmp(&right.location.column))
259    });
260    Ok(Extraction {
261        definitions,
262        diagnostics,
263    })
264}
265
266fn collect_calls<'tree>(
267    node: Node<'tree>,
268    context: &AdapterContext<'_, 'tree>,
269    definitions: &mut Vec<StepDefinition>,
270    diagnostics: &mut Vec<ExtractionDiagnostic>,
271    unresolved_registration_calls: &mut UnresolvedRegistrationCalls,
272) {
273    let mut stack = vec![node];
274    while let Some(node) = stack.pop() {
275        if node.kind() == "call_expression" {
276            if let Some(unresolved) = unresolved_registration_call(node, context) {
277                let location = node_location(context.file, node, context.source);
278                match unresolved {
279                    UnresolvedRegistration::Generic => {
280                        unresolved_registration_calls.record(location)
281                    }
282                    UnresolvedRegistration::Module(module) => {
283                        unresolved_registration_calls.record_module(module, location)
284                    }
285                }
286            }
287            let definition = if node
288                .parent()
289                .is_some_and(|parent| parent.kind() == "decorator")
290            {
291                extract_decorator(node, context, diagnostics)
292            } else {
293                extract_call(node, context, diagnostics)
294            };
295            if let Some(definition) = definition {
296                definitions.push(definition);
297            }
298        }
299        let mut cursor = node.walk();
300        let children: Vec<_> = node.named_children(&mut cursor).collect();
301        stack.extend(children.into_iter().rev());
302    }
303}
304
305enum UnresolvedRegistration<'a> {
306    Generic,
307    Module(&'a str),
308}
309
310fn unresolved_registration_call<'a>(
311    call: Node<'_>,
312    context: &'a AdapterContext<'_, '_>,
313) -> Option<UnresolvedRegistration<'a>> {
314    let function = call.child_by_field_name("function")?;
315    if registration_name(function, context.source, context.registrations).is_some()
316        || decorator_registration_name(function, context.source, context.registrations).is_some()
317    {
318        return None;
319    }
320    if let Some(module) =
321        unresolved_registration_module(function, context.source, context.registrations)
322    {
323        return Some(UnresolvedRegistration::Module(module));
324    }
325    let looks_like_registration = match registration_callee(function, context.source) {
326        Some(RegistrationCallee::Identifier(name)) => matches!(
327            name,
328            "Given"
329                | "When"
330                | "Then"
331                | "And"
332                | "But"
333                | "Step"
334                | "defineStep"
335                | "given"
336                | "when"
337                | "then"
338        ),
339        // Lowercase `.then()` is ordinary Promise usage, not registration evidence. Member
340        // expressions intentionally use only the distinctive registration-style properties.
341        Some(RegistrationCallee::Property { name, .. }) => matches!(
342            name.as_ref(),
343            "Given" | "When" | "Then" | "And" | "But" | "Step" | "defineStep"
344        ),
345        _ => false,
346    };
347    looks_like_registration.then_some(UnresolvedRegistration::Generic)
348}
349
350fn extract_call<'tree>(
351    call: Node<'tree>,
352    context: &AdapterContext<'_, 'tree>,
353    diagnostics: &mut Vec<ExtractionDiagnostic>,
354) -> Option<StepDefinition> {
355    let source = context.source;
356    let function = call.child_by_field_name("function")?;
357    let (callee, registration, registration_framework) =
358        registration_name(function, source, context.registrations)?;
359    let arguments = call.child_by_field_name("arguments")?;
360    let mut cursor = arguments.walk();
361    let arguments: Vec<_> = arguments.named_children(&mut cursor).collect();
362    if arguments.len() < 2 {
363        return None;
364    }
365    let matcher_node = arguments[0];
366    let (matcher, matcher_kind, matcher_flags) =
367        extract_matcher(matcher_node, context, diagnostics)?;
368    let handler = arguments.iter().rev().copied().find(|node| {
369        matches!(
370            node.kind(),
371            "arrow_function"
372                | "function_expression"
373                | "generator_function"
374                | "identifier"
375                | "member_expression"
376                | "call_expression"
377        )
378    });
379    let Some(handler) = handler else {
380        diagnostics.push(ExtractionDiagnostic {
381            level: ExtractionDiagnosticLevel::Warning,
382            location: node_location(context.file, call, source),
383            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
384        });
385        return None;
386    };
387    let bound_arguments = bind_arguments(handler, source);
388    let resolved_handler = resolve_handler(handler, source, context.handler_bindings);
389    if resolved_handler.is_none() && handler.kind() == "call_expression" {
390        diagnostics.push(ExtractionDiagnostic {
391            level: ExtractionDiagnosticLevel::Warning,
392            location: node_location(context.file, handler, source),
393            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
394        });
395    }
396    let comparable = resolved_handler.is_some();
397    let handler = resolved_handler.unwrap_or(handler);
398    Some(StepDefinition {
399        normalized_matcher: normalize_matcher_with_flags(&matcher, matcher_kind, &matcher_flags),
400        matcher,
401        matcher_kind,
402        matcher_flags,
403        handler: fingerprint_handler(handler, bound_arguments, source, comparable),
404        framework: effective_registration_framework(registration_framework, context.framework),
405        registration: if registration.is_empty() {
406            callee
407        } else {
408            registration
409        },
410        location: node_location(context.file, call, source),
411        inline_suppressions: inline_suppressions(
412            call,
413            context.source_lines,
414            context.file,
415            diagnostics,
416        ),
417    })
418}
419
420fn extract_decorator<'tree>(
421    call: Node<'tree>,
422    context: &AdapterContext<'_, 'tree>,
423    diagnostics: &mut Vec<ExtractionDiagnostic>,
424) -> Option<StepDefinition> {
425    let source = context.source;
426    let function = call.child_by_field_name("function")?;
427    let (callee, registration, registration_framework) =
428        decorator_registration_name(function, source, context.registrations)?;
429    // `arguments` is a required field of a tree-sitter call_expression.
430    let arguments = call.child_by_field_name("arguments")?;
431    let mut cursor = arguments.walk();
432    let Some(matcher_node) = arguments.named_children(&mut cursor).next() else {
433        diagnostics.push(ExtractionDiagnostic {
434            level: ExtractionDiagnosticLevel::Warning,
435            location: node_location(context.file, call, source),
436            message: "dynamic or unsupported step matcher cannot be analyzed statically".to_owned(),
437        });
438        return None;
439    };
440    let (matcher, matcher_kind, matcher_flags) =
441        extract_matcher(matcher_node, context, diagnostics)?;
442    let Some(method) = decorated_method(call) else {
443        diagnostics.push(ExtractionDiagnostic {
444            level: ExtractionDiagnosticLevel::Warning,
445            location: node_location(context.file, call, source),
446            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
447        });
448        return None;
449    };
450    let Some(handler) = fingerprint_method_handler(method, source) else {
451        diagnostics.push(ExtractionDiagnostic {
452            level: ExtractionDiagnosticLevel::Warning,
453            location: node_location(context.file, method, source),
454            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
455        });
456        return None;
457    };
458    Some(StepDefinition {
459        normalized_matcher: normalize_matcher_with_flags(&matcher, matcher_kind, &matcher_flags),
460        matcher,
461        matcher_kind,
462        matcher_flags,
463        handler,
464        framework: effective_registration_framework(registration_framework, context.framework),
465        registration: if registration.is_empty() {
466            callee
467        } else {
468            registration
469        },
470        location: node_location(context.file, call, source),
471        inline_suppressions: inline_suppressions(
472            call,
473            context.source_lines,
474            context.file,
475            diagnostics,
476        ),
477    })
478}
479
480/// Resolves a decorator to the class method it decorates without crossing another class member.
481/// Tree-sitter represents decorators as siblings immediately before their member, so skipping
482/// only consecutive decorator siblings keeps the association structural and unambiguous.
483fn decorated_method(call: Node<'_>) -> Option<Node<'_>> {
484    let decorator = call
485        .parent()
486        .filter(|parent| parent.kind() == "decorator")?;
487    if let Some(method) = decorator
488        .parent()
489        .filter(|parent| parent.kind() == "method_definition")
490    {
491        return Some(method);
492    }
493    let mut sibling = decorator.next_named_sibling()?;
494    while sibling.kind() == "decorator" {
495        sibling = sibling.next_named_sibling()?;
496    }
497    (sibling.kind() == "method_definition").then_some(sibling)
498}
499
500fn effective_registration_framework(binding: Framework, file: Framework) -> Framework {
501    if binding == Framework::Unknown {
502        file
503    } else {
504        binding
505    }
506}
507
508fn extract_matcher(
509    matcher_node: Node<'_>,
510    context: &AdapterContext<'_, '_>,
511    diagnostics: &mut Vec<ExtractionDiagnostic>,
512) -> Option<(String, MatcherKind, String)> {
513    let source = context.source;
514    let (matcher, matcher_kind, matcher_flags) = match matcher_value(matcher_node, source) {
515        Some(value) => value,
516        None if matcher_node.kind() == "string" => {
517            diagnostics.push(ExtractionDiagnostic {
518                level: ExtractionDiagnosticLevel::Error,
519                location: node_location(context.file, matcher_node, source),
520                message: "invalid JavaScript escape sequence in step matcher".to_owned(),
521            });
522            return None;
523        }
524        None => {
525            diagnostics.push(ExtractionDiagnostic {
526                level: ExtractionDiagnosticLevel::Warning,
527                location: node_location(context.file, matcher_node, source),
528                message: "dynamic or unsupported step matcher cannot be analyzed statically"
529                    .to_owned(),
530            });
531            return None;
532        }
533    };
534    if matcher_kind == MatcherKind::RegularExpression {
535        match rust_regex_support(&matcher, &matcher_flags) {
536            RegexSupport::Unsupported => diagnostics.push(ExtractionDiagnostic {
537                level: ExtractionDiagnosticLevel::Warning,
538                location: node_location(context.file, matcher_node, source),
539                message: "regular expression uses syntax unsupported by static usage analysis; unused and ambiguity checks will treat this definition as indeterminate".to_owned(),
540            }),
541            // The analysis phase emits one run-level operational error after reports are written.
542            RegexSupport::ResourceLimit | RegexSupport::Supported => {}
543        }
544    }
545    Some((matcher, matcher_kind, matcher_flags))
546}
547
548fn node_location(file: &SourceFile, node: Node<'_>, source: &[u8]) -> SourceLocation {
549    let start = node.start_position();
550    let end = node.end_position();
551    SourceLocation::new(
552        &file.path,
553        start.row + 1,
554        unicode_column(source, node.start_byte(), start.column),
555        end.row + 1,
556        unicode_column(source, node.end_byte(), end.column),
557    )
558}
559
560fn unicode_column(source: &[u8], byte_offset: usize, byte_column: usize) -> usize {
561    let line_start = byte_offset.saturating_sub(byte_column);
562    let end = byte_offset.min(source.len());
563    std::str::from_utf8(&source[line_start.min(end)..end])
564        .map_or(byte_column, |prefix| prefix.chars().count())
565        + 1
566}
567
568fn first_syntax_error(root: Node<'_>) -> Option<Node<'_>> {
569    let mut stack = vec![root];
570    while let Some(node) = stack.pop() {
571        if node.is_error() || node.is_missing() {
572            return Some(node);
573        }
574        let mut cursor = node.walk();
575        let children: Vec<_> = node.named_children(&mut cursor).collect();
576        stack.extend(children.into_iter().rev());
577    }
578    None
579}
580
581fn node_text<'a>(node: Node<'_>, source: &'a [u8]) -> &'a str {
582    std::str::from_utf8(&source[node.byte_range()]).unwrap_or("")
583}
584
585#[cfg(test)]
586mod tests;