Skip to main content

cuke_dedup/
typescript.rs

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