Skip to main content

cuke_dedup/
typescript.rs

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