Skip to main content

cuke_dedup/
typescript.rs

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