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