Skip to main content

cuke_dedup/
typescript.rs

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