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