Skip to main content

cuke_dedup/
typescript.rs

1//! Tree-sitter based JavaScript and TypeScript step-definition extraction.
2
3mod assertions;
4mod ast;
5mod framework_callbacks;
6pub(crate) mod frameworks;
7mod handler;
8mod matcher;
9mod module_resolver;
10mod project_resolution;
11mod registrations;
12mod suppression;
13
14use self::assertions::AssertionBindings;
15use self::handler::{
16    bind_arguments, collect_handler_bindings, fingerprint_handler, fingerprint_method_handler,
17    resolve_handler, HandlerBinding,
18};
19#[cfg(test)]
20use self::handler::{bounded_source_snippet, MAX_HANDLER_SNIPPET_CHARS};
21pub use self::matcher::normalize_matcher;
22#[cfg(test)]
23use self::matcher::normalize_regular_expression;
24pub(crate) use self::matcher::rust_regex_expression;
25pub(crate) use self::matcher::semantic_regex_flags;
26use self::matcher::{matcher_value, normalize_matcher_with_flags};
27use self::matcher::{rust_regex_support, RegexSupport};
28use self::module_resolver::RegistrationResolver;
29use self::registrations::{
30    decorator_registration_name, detect_framework, detect_registrations, registration_callee,
31    registration_name, unresolved_registration_module, RegistrationCallee, RegistrationNames,
32};
33use self::suppression::inline_suppressions;
34use crate::model::{Framework, HandlerFingerprint, MatcherKind, SourceLocation, StepDefinition};
35use crate::source_adapter::{
36    adapter_for_language, grammar_for_language, AdapterSessionState, SourceAdapter,
37    SourceExtractionSession, StatefulSourceAdapter, UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX,
38    UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX,
39};
40pub use crate::source_adapter::{
41    Extraction, ExtractionDiagnostic, ExtractionDiagnosticLevel, SourceFile, SourceLanguage,
42};
43use anyhow::{Context, Result};
44use std::collections::{BTreeMap, BTreeSet};
45use tree_sitter::{Node, Parser};
46
47pub(crate) struct TreeSitterSourceAdapter {
48    name: &'static str,
49    language: SourceLanguage,
50}
51
52pub(crate) static JAVASCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
53    name: "javascript",
54    language: SourceLanguage::JavaScript,
55};
56pub(crate) static TYPESCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
57    name: "typescript",
58    language: SourceLanguage::TypeScript,
59};
60pub(crate) static TSX_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
61    name: "tsx",
62    language: SourceLanguage::Tsx,
63};
64
65#[derive(Default)]
66pub(crate) struct TypeScriptExtractionSession {
67    resolver: RegistrationResolver,
68    configured_registrations: std::collections::BTreeSet<String>,
69    configured_assertion_modules: std::collections::BTreeSet<String>,
70}
71
72impl TypeScriptExtractionSession {
73    pub(crate) fn for_root(
74        root: &std::path::Path,
75        registrations: &[String],
76        assertion_modules: &[String],
77    ) -> Self {
78        Self {
79            resolver: RegistrationResolver::for_root(root),
80            configured_registrations: registrations.iter().cloned().collect(),
81            configured_assertion_modules: assertion_modules.iter().cloned().collect(),
82        }
83    }
84}
85
86impl AdapterSessionState for TypeScriptExtractionSession {
87    fn initialize(
88        root: Option<&std::path::Path>,
89        registrations: &[String],
90        assertion_modules: &[String],
91    ) -> Self {
92        match root {
93            Some(root) => Self::for_root(root, registrations, assertion_modules),
94            None => Self::default(),
95        }
96    }
97}
98
99impl StatefulSourceAdapter for TreeSitterSourceAdapter {
100    type State = TypeScriptExtractionSession;
101}
102
103impl SourceAdapter for TreeSitterSourceAdapter {
104    fn name(&self) -> &'static str {
105        self.name
106    }
107
108    fn language(&self) -> SourceLanguage {
109        self.language
110    }
111
112    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction> {
113        let mut session = SourceExtractionSession::default();
114        self.extract_with_session(source, file, &mut session)
115    }
116
117    fn extract_with_session(
118        &self,
119        source: &str,
120        file: &SourceFile,
121        session: &mut SourceExtractionSession,
122    ) -> Result<Extraction> {
123        if file.language != self.language {
124            anyhow::bail!(
125                "{} adapter cannot parse {:?} source {}",
126                self.name,
127                file.language,
128                file.path.display()
129            );
130        }
131        extract_detailed_impl(
132            source,
133            file,
134            session.state::<TypeScriptExtractionSession>()?,
135        )
136    }
137}
138
139struct AdapterContext<'source, 'tree> {
140    source: &'source [u8],
141    source_lines: &'source [&'source str],
142    file: &'source SourceFile,
143    framework: Framework,
144    registrations: &'source RegistrationNames,
145    framework_calls: &'source framework_callbacks::FrameworkCalls,
146    assertions: &'source AssertionBindings,
147    handler_bindings: &'source BTreeMap<String, Vec<HandlerBinding<'tree>>>,
148}
149
150#[derive(Default)]
151struct UnresolvedRegistrationCalls {
152    count: usize,
153    first_location: Option<SourceLocation>,
154    modules: BTreeMap<String, (usize, SourceLocation)>,
155    /// Calls that hand a registration to a function the analyzer does not model.
156    passed: usize,
157    first_passed: Option<SourceLocation>,
158}
159
160impl UnresolvedRegistrationCalls {
161    fn record(&mut self, location: SourceLocation) {
162        self.count = self.count.saturating_add(1);
163        self.first_location.get_or_insert(location);
164    }
165
166    fn record_passed(&mut self, location: SourceLocation) {
167        self.passed = self.passed.saturating_add(1);
168        self.first_passed.get_or_insert(location);
169    }
170
171    fn record_module(&mut self, module: &str, location: SourceLocation) {
172        let entry = self
173            .modules
174            .entry(module.to_owned())
175            .or_insert_with(|| (0, location));
176        entry.0 = entry.0.saturating_add(1);
177    }
178}
179
180/// Reads and extracts step definitions from a discovered JavaScript or TypeScript file.
181pub fn extract_file(file: &SourceFile) -> Result<Vec<StepDefinition>> {
182    Ok(extract_file_detailed(file)?.definitions)
183}
184
185/// Reads a source file and returns definitions plus localized extraction diagnostics.
186pub fn extract_file_detailed(file: &SourceFile) -> Result<Extraction> {
187    adapter_for_language(file.language).extract_file(file)
188}
189
190/// Extracts step definitions from in-memory source using `file` for syntax and locations.
191pub fn extract(source: &str, file: &SourceFile) -> Result<Vec<StepDefinition>> {
192    Ok(extract_detailed(source, file)?.definitions)
193}
194
195/// Extracts definitions and localized diagnostics from in-memory source.
196pub fn extract_detailed(source: &str, file: &SourceFile) -> Result<Extraction> {
197    adapter_for_language(file.language).extract(source, file)
198}
199
200fn extract_detailed_impl(
201    source: &str,
202    file: &SourceFile,
203    session: &mut TypeScriptExtractionSession,
204) -> Result<Extraction> {
205    let mut parser = Parser::new();
206    parser
207        .set_language(&grammar_for_language(file.language))
208        .context("failed to initialize the JavaScript/TypeScript parser")?;
209    let tree = parser
210        .parse(source, None)
211        .context("Tree-sitter did not return a syntax tree")?;
212
213    let root = tree.root_node();
214    let source_bytes = source.as_bytes();
215    let source_lines: Vec<_> = source.lines().collect();
216    let framework = detect_framework(root, source_bytes);
217    let registrations = detect_registrations(
218        root,
219        source_bytes,
220        framework,
221        &file.path,
222        &mut session.resolver,
223        &session.configured_registrations,
224    )?;
225    let framework = registrations.framework;
226    let framework_calls = if registrations.has_callback_framework() {
227        framework_callbacks::discover(
228            root,
229            source_bytes,
230            &file.path,
231            &mut session.resolver,
232            &registrations,
233        )?
234    } else {
235        framework_callbacks::FrameworkCalls::default()
236    };
237    let assertions = AssertionBindings::discover(
238        root,
239        source_bytes,
240        &registrations,
241        &session.configured_assertion_modules,
242    );
243    let mut handler_bindings = BTreeMap::new();
244    collect_handler_bindings(root, source_bytes, &mut handler_bindings);
245    let context = AdapterContext {
246        source: source_bytes,
247        source_lines: &source_lines,
248        file,
249        framework,
250        registrations: &registrations,
251        framework_calls: &framework_calls,
252        assertions: &assertions,
253        handler_bindings: &handler_bindings,
254    };
255    let mut definitions = Vec::new();
256    let mut diagnostics = Vec::new();
257    let mut unresolved_registration_calls = UnresolvedRegistrationCalls::default();
258    if root.has_error() {
259        let syntax_node = first_syntax_error(root).unwrap_or(root);
260        // A parse failure still leaves error-recovered definitions worth analyzing, so it does not
261        // discard the file. It is a completeness signal (via its message prefix), which marks the
262        // corpus incomplete; the CLI keeps the run non-fatal unless `--fail-on-unparseable` or
263        // `--fail-on-incomplete` is set, so the level here stays a warning.
264        diagnostics.push(ExtractionDiagnostic {
265            level: ExtractionDiagnosticLevel::Warning,
266            location: node_location(file, syntax_node, source_bytes),
267            message: format!("{UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX}, so analysis is incomplete; fix the syntax, use a .tsx extension for JSX, or narrow definition discovery"),
268        });
269    }
270    collect_calls(
271        root,
272        &context,
273        &mut definitions,
274        &mut diagnostics,
275        &mut unresolved_registration_calls,
276    );
277    if let Some(location) = unresolved_registration_calls.first_location {
278        diagnostics.push(ExtractionDiagnostic {
279            level: ExtractionDiagnosticLevel::Warning,
280            location,
281            message: format!(
282                "{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",
283                unresolved_registration_calls.count
284            ),
285        });
286    }
287    if let Some(location) = unresolved_registration_calls.first_passed {
288        diagnostics.push(ExtractionDiagnostic {
289            level: ExtractionDiagnosticLevel::Warning,
290            location,
291            message: format!(
292                "{UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX} {} call(s) pass a step registration to a function the analyzer does not model; definitions may be missing",
293                unresolved_registration_calls.passed
294            ),
295        });
296    }
297    let mut explained_modules = BTreeSet::new();
298    for (module, (count, location)) in unresolved_registration_calls.modules {
299        let cause = registrations::unresolved_module_reason(&registrations, &module)
300            .map(|reason| format!(" ({reason})"))
301            .unwrap_or_default();
302        diagnostics.push(ExtractionDiagnostic {
303            level: ExtractionDiagnosticLevel::Warning,
304            location,
305            message: format!(
306                "{UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX} {count} call(s) import a known registration through unresolved module `{module}`{cause}; definitions may be missing"
307            ),
308        });
309        explained_modules.insert(module);
310    }
311    // A specifier can fail resolution without any call being attributed to it — a barrel that is
312    // imported but whose registrations are re-exported onward, or a file whose registration calls
313    // were themselves unresolvable. Report the cause anyway so a resolution limit is never
314    // reduced to a bare "produced 0 definitions".
315    for (module, recorded) in registrations::unresolved_module_reasons(&registrations) {
316        if explained_modules.contains(module) {
317            continue;
318        }
319        let line = recorded.row + 1;
320        let column = unicode_column(source_bytes, recorded.byte_offset, recorded.byte_column);
321        diagnostics.push(ExtractionDiagnostic {
322            level: ExtractionDiagnosticLevel::Warning,
323            location: SourceLocation::new(&file.path, line, column, line, column),
324            message: format!(
325                "{UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX} module `{module}` could not be resolved statically ({}); definitions may be missing",
326                recorded.reason
327            ),
328        });
329    }
330    definitions.sort_by(|left, right| {
331        left.location
332            .line
333            .cmp(&right.location.line)
334            .then(left.location.column.cmp(&right.location.column))
335    });
336    Ok(Extraction {
337        definitions,
338        diagnostics,
339    })
340}
341
342fn collect_calls<'tree>(
343    node: Node<'tree>,
344    context: &AdapterContext<'_, 'tree>,
345    definitions: &mut Vec<StepDefinition>,
346    diagnostics: &mut Vec<ExtractionDiagnostic>,
347    unresolved_registration_calls: &mut UnresolvedRegistrationCalls,
348) {
349    let mut stack = vec![node];
350    while let Some(node) = stack.pop() {
351        if node.kind() == "call_expression" {
352            if let Some(unresolved) = unresolved_registration_call(node, context) {
353                let location = node_location(context.file, node, context.source);
354                match unresolved {
355                    UnresolvedRegistration::Generic => {
356                        unresolved_registration_calls.record(location)
357                    }
358                    UnresolvedRegistration::Module(module) => {
359                        unresolved_registration_calls.record_module(module, location)
360                    }
361                    UnresolvedRegistration::Passed => {
362                        unresolved_registration_calls.record_passed(location)
363                    }
364                }
365            }
366            let definition = if node
367                .parent()
368                .is_some_and(|parent| parent.kind() == "decorator")
369            {
370                extract_decorator(node, context, diagnostics)
371            } else {
372                extract_call(node, context, diagnostics)
373            };
374            if let Some(definition) = definition {
375                definitions.push(definition);
376            }
377        }
378        let mut cursor = node.walk();
379        let children: Vec<_> = node.named_children(&mut cursor).collect();
380        stack.extend(children.into_iter().rev());
381    }
382}
383
384enum UnresolvedRegistration<'a> {
385    Generic,
386    Module(&'a str),
387    /// `helper(Given, 'a step', fn)`: a registration handed to another function, which may call it.
388    Passed,
389}
390
391/// Whether a registration appears anywhere in a call's arguments as a value rather than being
392/// called: directly (`helper(Given)`), inside an object or array (`helper({ register: Given })`,
393/// `helper([Given])`), returned from a function (`helper(() => Given)`), or handed to a constructor.
394/// A nested call is not entered: it checks its own arguments, and a registration it calls
395/// (`helper(() => Given('a', fn))`) is extracted as a definition. Since calls are never entered, a
396/// registration reached here is always a value, never a callee, and each node is walked once.
397fn arguments_pass_registration(arguments: Node<'_>, context: &AdapterContext<'_, '_>) -> bool {
398    let mut stack = vec![arguments];
399    while let Some(node) = stack.pop() {
400        let passed = match node.kind() {
401            // `Given.bind(null)` is still the registration, bound; any other call yields something
402            // new and checks its own arguments.
403            "call_expression" => {
404                let bound = node
405                    .child_by_field_name("function")
406                    .filter(|function| function.kind() == "member_expression")
407                    .filter(|function| {
408                        function
409                            .child_by_field_name("property")
410                            .is_some_and(|property| node_text(property, context.source) == "bind")
411                    })
412                    .and_then(|function| function.child_by_field_name("object"));
413                stack.extend(bound);
414                continue;
415            }
416            "identifier" | "member_expression" | "subscript_expression" => {
417                registration_name(node, context.source, context.registrations).is_some()
418                    || decorator_registration_name(node, context.source, context.registrations)
419                        .is_some()
420            }
421            // `{ Given }` passes the binding under its own name.
422            "shorthand_property_identifier" => context
423                .registrations
424                .recognizes_alias_at(node, node_text(node, context.source)),
425            _ => false,
426        };
427        if passed {
428            return true;
429        }
430        push_value_positions(node, context.source, &mut stack);
431    }
432    false
433}
434
435/// Pushes the children of `node` whose value can be `node`'s own value. Only these can carry a
436/// registration through unchanged: `[Given]`, `{ step: Given }`, `flag ? Given : When`,
437/// `Given || fallback`, `() => Given`. Everything else consumes the registration and yields
438/// something new — `Given.name`, `typeof Given`, `Given !== undefined`, `new Given()` — so the walk
439/// stops there rather than report a value that is not the registration.
440fn push_value_positions<'tree>(node: Node<'tree>, source: &[u8], stack: &mut Vec<Node<'tree>>) {
441    let field = |name: &str| node.child_by_field_name(name);
442    match node.kind() {
443        "arguments"
444        | "array"
445        | "object"
446        | "parenthesized_expression"
447        | "as_expression"
448        | "satisfies_expression"
449        | "non_null_expression"
450        | "await_expression" => {
451            let mut cursor = node.walk();
452            stack.extend(node.named_children(&mut cursor));
453        }
454        "pair" | "assignment_expression" => stack.extend(field("value").or(field("right"))),
455        // Spreading a literal carries its elements (`...[Given]`); spreading anything else copies
456        // its own properties into a new container (`...Given`), which is not the registration.
457        "spread_element" => stack.extend(
458            node.named_child(0)
459                .filter(|operand| matches!(operand.kind(), "array" | "object")),
460        ),
461        // `(a, b)` evaluates to its last operand only.
462        "sequence_expression" => {
463            let mut cursor = node.walk();
464            stack.extend(node.named_children(&mut cursor).last());
465        }
466        "ternary_expression" => stack.extend(
467            [field("consequence"), field("alternative")]
468                .into_iter()
469                .flatten(),
470        ),
471        "binary_expression" => {
472            let preserves = field("operator")
473                .is_some_and(|operator| matches!(node_text(operator, source), "||" | "&&" | "??"));
474            if preserves {
475                stack.extend([field("left"), field("right")].into_iter().flatten());
476            }
477        }
478        // A function or object method passes whatever it returns or yields; its parameters are not
479        // values.
480        "arrow_function" | "function_expression" | "generator_function" | "method_definition" => {
481            match field("body") {
482                Some(body) if body.kind() == "statement_block" => push_returned_values(body, stack),
483                body => stack.extend(body),
484            }
485        }
486        // A constructor stores its arguments; the class it constructs is consumed.
487        "new_expression" => stack.extend(field("arguments")),
488        _ => {}
489    }
490}
491
492/// Pushes the value of every `return` and `yield` in a function body, through any control flow —
493/// `if`, `try`, `switch`, loops — but not into a nested function, whose returns are its own.
494fn push_returned_values<'tree>(body: Node<'tree>, stack: &mut Vec<Node<'tree>>) {
495    let mut pending = vec![body];
496    while let Some(node) = pending.pop() {
497        match node.kind() {
498            "arrow_function"
499            | "function_expression"
500            | "function_declaration"
501            | "generator_function"
502            | "generator_function_declaration"
503            | "method_definition"
504            | "class"
505            | "class_declaration" => continue,
506            "return_statement" | "yield_expression" => {
507                let mut cursor = node.walk();
508                stack.extend(node.named_children(&mut cursor));
509            }
510            _ => {}
511        }
512        let mut cursor = node.walk();
513        pending.extend(node.named_children(&mut cursor));
514    }
515}
516
517fn unresolved_registration_call<'a>(
518    call: Node<'_>,
519    context: &'a AdapterContext<'_, '_>,
520) -> Option<UnresolvedRegistration<'a>> {
521    let function = call.child_by_field_name("function")?;
522    if context
523        .framework_calls
524        .incomplete
525        .contains(&function.start_byte())
526    {
527        return Some(UnresolvedRegistration::Generic);
528    }
529    if context
530        .framework_calls
531        .handled
532        .contains(&function.start_byte())
533    {
534        return None;
535    }
536    if registration_name(function, context.source, context.registrations)
537        .is_some_and(|(_, _, framework)| framework_callbacks::is_new_framework(framework))
538    {
539        return Some(UnresolvedRegistration::Generic);
540    }
541    if registration_name(function, context.source, context.registrations).is_some()
542        || decorator_registration_name(function, context.source, context.registrations).is_some()
543    {
544        return None;
545    }
546    // A registration passed as an argument can be called by the receiving function, whose body the
547    // analyzer does not follow. The definition it registers would be missed, so it is reported.
548    let passes_registration = call
549        .child_by_field_name("arguments")
550        .is_some_and(|arguments| arguments_pass_registration(arguments, context));
551    if passes_registration {
552        return Some(UnresolvedRegistration::Passed);
553    }
554    if let Some(module) =
555        unresolved_registration_module(function, context.source, context.registrations)
556    {
557        return Some(UnresolvedRegistration::Module(module));
558    }
559    let looks_like_registration = match registration_callee(function, context.source) {
560        Some(RegistrationCallee::Identifier(name)) => matches!(
561            name,
562            "Given"
563                | "When"
564                | "Then"
565                | "And"
566                | "But"
567                | "Step"
568                | "defineStep"
569                | "given"
570                | "when"
571                | "then"
572        ),
573        // Lowercase `.then()` is ordinary Promise usage, not registration evidence. Member
574        // expressions intentionally use only the distinctive registration-style properties.
575        Some(RegistrationCallee::Property { name, .. }) => matches!(
576            name.as_ref(),
577            "Given" | "When" | "Then" | "And" | "But" | "Step" | "defineStep"
578        ),
579        _ => false,
580    };
581    looks_like_registration.then_some(UnresolvedRegistration::Generic)
582}
583
584fn extract_call<'tree>(
585    call: Node<'tree>,
586    context: &AdapterContext<'_, 'tree>,
587    diagnostics: &mut Vec<ExtractionDiagnostic>,
588) -> Option<StepDefinition> {
589    let source = context.source;
590    let function = call.child_by_field_name("function")?;
591    let (callee, registration, registration_framework) =
592        context.framework_calls.registration(function).or_else(|| {
593            registration_name(function, source, context.registrations)
594                .filter(|(_, _, framework)| !framework_callbacks::is_new_framework(*framework))
595        })?;
596    let arguments = call.child_by_field_name("arguments")?;
597    let mut cursor = arguments.walk();
598    let arguments: Vec<_> = arguments.named_children(&mut cursor).collect();
599    if arguments.len() < 2 {
600        return None;
601    }
602    let matcher_node = arguments[0];
603    let (matcher, mut matcher_kind, matcher_flags) =
604        extract_matcher(matcher_node, context, diagnostics)?;
605    if registration_framework == Framework::JestCucumber
606        && matcher_kind == MatcherKind::CucumberExpression
607    {
608        matcher_kind = MatcherKind::Literal;
609    }
610    let handler = arguments.iter().rev().copied().find(|node| {
611        matches!(
612            node.kind(),
613            "arrow_function"
614                | "function_expression"
615                | "generator_function"
616                | "identifier"
617                | "member_expression"
618                | "call_expression"
619        )
620    });
621    let Some(handler) = handler else {
622        diagnostics.push(ExtractionDiagnostic {
623            level: ExtractionDiagnosticLevel::Warning,
624            location: node_location(context.file, call, source),
625            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
626        });
627        return None;
628    };
629    let bound_arguments = bind_arguments(handler, source);
630    let resolved_handler = resolve_handler(handler, source, context.handler_bindings);
631    if resolved_handler.is_none() && handler.kind() == "call_expression" {
632        diagnostics.push(ExtractionDiagnostic {
633            level: ExtractionDiagnosticLevel::Warning,
634            location: node_location(context.file, handler, source),
635            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
636        });
637    }
638    let comparable = resolved_handler.is_some();
639    let handler = resolved_handler.unwrap_or(handler);
640    Some(step_definition(
641        call,
642        context,
643        diagnostics,
644        (callee, registration, registration_framework),
645        (matcher, matcher_kind, matcher_flags),
646        fingerprint_handler(
647            handler,
648            bound_arguments,
649            source,
650            comparable,
651            context.assertions,
652        ),
653    ))
654}
655
656fn extract_decorator<'tree>(
657    call: Node<'tree>,
658    context: &AdapterContext<'_, 'tree>,
659    diagnostics: &mut Vec<ExtractionDiagnostic>,
660) -> Option<StepDefinition> {
661    let source = context.source;
662    let function = call.child_by_field_name("function")?;
663    let (callee, registration, registration_framework) =
664        decorator_registration_name(function, source, context.registrations)?;
665    // `arguments` is a required field of a tree-sitter call_expression.
666    let arguments = call.child_by_field_name("arguments")?;
667    let mut cursor = arguments.walk();
668    let Some(matcher_node) = arguments.named_children(&mut cursor).next() else {
669        diagnostics.push(ExtractionDiagnostic {
670            level: ExtractionDiagnosticLevel::Warning,
671            location: node_location(context.file, call, source),
672            message: "dynamic or unsupported step matcher cannot be analyzed statically".to_owned(),
673        });
674        return None;
675    };
676    let (matcher, matcher_kind, matcher_flags) =
677        extract_matcher(matcher_node, context, diagnostics)?;
678    let Some(method) = decorated_method(call) else {
679        diagnostics.push(ExtractionDiagnostic {
680            level: ExtractionDiagnosticLevel::Warning,
681            location: node_location(context.file, call, source),
682            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
683        });
684        return None;
685    };
686    let Some(handler) = fingerprint_method_handler(method, source, context.assertions) else {
687        diagnostics.push(ExtractionDiagnostic {
688            level: ExtractionDiagnosticLevel::Warning,
689            location: node_location(context.file, method, source),
690            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
691        });
692        return None;
693    };
694    Some(step_definition(
695        call,
696        context,
697        diagnostics,
698        (callee, registration, registration_framework),
699        (matcher, matcher_kind, matcher_flags),
700        handler,
701    ))
702}
703
704/// Assembles the definition both registration shapes describe once their differing parts are
705/// resolved. A plain call fingerprints its handler argument and a decorator fingerprints the method
706/// it precedes, but the matcher, the resolved registration, and everything the `call` node itself
707/// carries — the location and the inline suppressions — are built the same way for both.
708fn step_definition<'tree>(
709    call: Node<'tree>,
710    context: &AdapterContext<'_, 'tree>,
711    diagnostics: &mut Vec<ExtractionDiagnostic>,
712    (callee, registration, registration_framework): (String, String, Framework),
713    (matcher, matcher_kind, matcher_flags): (String, MatcherKind, String),
714    handler: HandlerFingerprint,
715) -> StepDefinition {
716    StepDefinition {
717        normalized_matcher: normalize_matcher_with_flags(&matcher, matcher_kind, &matcher_flags),
718        matcher,
719        matcher_kind,
720        matcher_flags,
721        handler,
722        framework: effective_registration_framework(registration_framework, context.framework),
723        registration: if registration.is_empty() {
724            callee
725        } else {
726            registration
727        },
728        location: node_location(context.file, call, context.source),
729        inline_suppressions: inline_suppressions(
730            call,
731            context.source_lines,
732            context.file,
733            diagnostics,
734        ),
735    }
736}
737
738/// Resolves a decorator to the class method it decorates without crossing another class member.
739/// Tree-sitter represents decorators as siblings immediately before their member, so skipping
740/// only consecutive decorator siblings keeps the association structural and unambiguous.
741fn decorated_method(call: Node<'_>) -> Option<Node<'_>> {
742    let decorator = call
743        .parent()
744        .filter(|parent| parent.kind() == "decorator")?;
745    if let Some(method) = decorator
746        .parent()
747        .filter(|parent| parent.kind() == "method_definition")
748    {
749        return Some(method);
750    }
751    let mut sibling = decorator.next_named_sibling()?;
752    while sibling.kind() == "decorator" {
753        sibling = sibling.next_named_sibling()?;
754    }
755    (sibling.kind() == "method_definition").then_some(sibling)
756}
757
758fn effective_registration_framework(binding: Framework, file: Framework) -> Framework {
759    if binding == Framework::Unknown {
760        file
761    } else {
762        binding
763    }
764}
765
766fn extract_matcher(
767    matcher_node: Node<'_>,
768    context: &AdapterContext<'_, '_>,
769    diagnostics: &mut Vec<ExtractionDiagnostic>,
770) -> Option<(String, MatcherKind, String)> {
771    let source = context.source;
772    let (matcher, matcher_kind, matcher_flags) = match matcher_value(matcher_node, source) {
773        Some(value) => value,
774        None if matcher_node.kind() == "string" => {
775            diagnostics.push(ExtractionDiagnostic {
776                level: ExtractionDiagnosticLevel::Error,
777                location: node_location(context.file, matcher_node, source),
778                message: "invalid JavaScript escape sequence in step matcher".to_owned(),
779            });
780            return None;
781        }
782        None => {
783            diagnostics.push(ExtractionDiagnostic {
784                level: ExtractionDiagnosticLevel::Warning,
785                location: node_location(context.file, matcher_node, source),
786                message: "dynamic or unsupported step matcher cannot be analyzed statically"
787                    .to_owned(),
788            });
789            return None;
790        }
791    };
792    if matcher_kind == MatcherKind::RegularExpression {
793        match rust_regex_support(&matcher, &matcher_flags) {
794            RegexSupport::Unsupported => diagnostics.push(ExtractionDiagnostic {
795                level: ExtractionDiagnosticLevel::Warning,
796                location: node_location(context.file, matcher_node, source),
797                message: "regular expression uses syntax unsupported by static usage analysis; unused and ambiguity checks will treat this definition as indeterminate".to_owned(),
798            }),
799            // The analysis phase emits one run-level operational error after reports are written.
800            RegexSupport::ResourceLimit | RegexSupport::Supported => {}
801        }
802    }
803    Some((matcher, matcher_kind, matcher_flags))
804}
805
806fn node_location(file: &SourceFile, node: Node<'_>, source: &[u8]) -> SourceLocation {
807    let start = node.start_position();
808    let end = node.end_position();
809    SourceLocation::new(
810        &file.path,
811        start.row + 1,
812        unicode_column(source, node.start_byte(), start.column),
813        end.row + 1,
814        unicode_column(source, node.end_byte(), end.column),
815    )
816}
817
818fn unicode_column(source: &[u8], byte_offset: usize, byte_column: usize) -> usize {
819    let line_start = byte_offset.saturating_sub(byte_column);
820    let end = byte_offset.min(source.len());
821    std::str::from_utf8(&source[line_start.min(end)..end])
822        .map_or(byte_column, |prefix| prefix.chars().count())
823        + 1
824}
825
826fn first_syntax_error(root: Node<'_>) -> Option<Node<'_>> {
827    let mut stack = vec![root];
828    while let Some(node) = stack.pop() {
829        if node.is_error() || node.is_missing() {
830            return Some(node);
831        }
832        let mut cursor = node.walk();
833        let children: Vec<_> = node.named_children(&mut cursor).collect();
834        stack.extend(children.into_iter().rev());
835    }
836    None
837}
838
839fn node_text<'a>(node: Node<'_>, source: &'a [u8]) -> &'a str {
840    std::str::from_utf8(&source[node.byte_range()]).unwrap_or("")
841}
842
843#[cfg(test)]
844mod tests;