Skip to main content

cuke_dedup/
typescript.rs

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