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