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