Skip to main content

cuke_dedup/
typescript.rs

1//! Tree-sitter based JavaScript and TypeScript step-definition extraction.
2
3mod handler;
4mod matcher;
5mod module_resolver;
6mod registrations;
7mod suppression;
8
9use self::handler::{
10    bind_arguments, collect_handler_bindings, fingerprint_handler, resolve_handler, HandlerBinding,
11};
12#[cfg(test)]
13use self::handler::{bounded_source_snippet, MAX_HANDLER_SNIPPET_CHARS};
14pub use self::matcher::normalize_matcher;
15#[cfg(test)]
16use self::matcher::normalize_regular_expression;
17use self::matcher::{matcher_value, normalize_matcher_with_flags, rust_regex_supported};
18use self::registrations::{
19    detect_framework, detect_registrations, registration_name, RegistrationNames,
20};
21use self::suppression::inline_suppressions;
22use crate::model::{Framework, MatcherKind, SourceLocation, StepDefinition};
23use crate::source_adapter::{adapter_for_language, SourceAdapter};
24pub use crate::source_adapter::{
25    Extraction, ExtractionDiagnostic, ExtractionDiagnosticLevel, SourceFile, SourceLanguage,
26};
27use anyhow::{Context, Result};
28use std::collections::BTreeMap;
29use tree_sitter::{Node, Parser};
30
31pub(crate) struct TreeSitterSourceAdapter {
32    name: &'static str,
33    language: SourceLanguage,
34}
35
36pub(crate) static JAVASCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
37    name: "javascript",
38    language: SourceLanguage::JavaScript,
39};
40pub(crate) static TYPESCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
41    name: "typescript",
42    language: SourceLanguage::TypeScript,
43};
44pub(crate) static TSX_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
45    name: "tsx",
46    language: SourceLanguage::Tsx,
47};
48
49impl SourceAdapter for TreeSitterSourceAdapter {
50    fn name(&self) -> &'static str {
51        self.name
52    }
53
54    fn language(&self) -> SourceLanguage {
55        self.language
56    }
57
58    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction> {
59        if file.language != self.language {
60            anyhow::bail!(
61                "{} adapter cannot parse {:?} source {}",
62                self.name,
63                file.language,
64                file.path.display()
65            );
66        }
67        extract_detailed_impl(source, file)
68    }
69}
70
71struct AdapterContext<'source, 'tree> {
72    source: &'source [u8],
73    source_lines: &'source [&'source str],
74    file: &'source SourceFile,
75    framework: Framework,
76    registrations: &'source RegistrationNames,
77    handler_bindings: &'source BTreeMap<String, Vec<HandlerBinding<'tree>>>,
78}
79
80/// Reads and extracts step definitions from a discovered JavaScript or TypeScript file.
81pub fn extract_file(file: &SourceFile) -> Result<Vec<StepDefinition>> {
82    Ok(extract_file_detailed(file)?.definitions)
83}
84
85/// Reads a source file and returns definitions plus localized extraction diagnostics.
86pub fn extract_file_detailed(file: &SourceFile) -> Result<Extraction> {
87    adapter_for_language(file.language).extract_file(file)
88}
89
90/// Extracts step definitions from in-memory source using `file` for syntax and locations.
91pub fn extract(source: &str, file: &SourceFile) -> Result<Vec<StepDefinition>> {
92    Ok(extract_detailed(source, file)?.definitions)
93}
94
95/// Extracts definitions and localized diagnostics from in-memory source.
96pub fn extract_detailed(source: &str, file: &SourceFile) -> Result<Extraction> {
97    adapter_for_language(file.language).extract(source, file)
98}
99
100fn extract_detailed_impl(source: &str, file: &SourceFile) -> Result<Extraction> {
101    let mut parser = Parser::new();
102    let language = match file.language {
103        SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
104        SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
105        SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
106    };
107    parser
108        .set_language(&language)
109        .context("failed to initialize the JavaScript/TypeScript parser")?;
110    let tree = parser
111        .parse(source, None)
112        .context("Tree-sitter did not return a syntax tree")?;
113
114    let root = tree.root_node();
115    let source_bytes = source.as_bytes();
116    let source_lines: Vec<_> = source.lines().collect();
117    let framework = detect_framework(root, source_bytes);
118    let registrations = detect_registrations(root, source_bytes, framework, &file.path);
119    let mut handler_bindings = BTreeMap::new();
120    collect_handler_bindings(root, source_bytes, &mut handler_bindings);
121    let context = AdapterContext {
122        source: source_bytes,
123        source_lines: &source_lines,
124        file,
125        framework,
126        registrations: &registrations,
127        handler_bindings: &handler_bindings,
128    };
129    let mut definitions = Vec::new();
130    let mut diagnostics = Vec::new();
131    if root.has_error() {
132        let syntax_node = first_syntax_error(root).unwrap_or(root);
133        diagnostics.push(ExtractionDiagnostic {
134            level: ExtractionDiagnosticLevel::Error,
135            location: node_location(file, syntax_node, source_bytes),
136            message: "source contains JavaScript/TypeScript syntax errors, so analysis is incomplete; fix the syntax, use a .tsx extension for JSX, or narrow definition discovery".to_owned(),
137        });
138    }
139    collect_calls(root, &context, &mut definitions, &mut diagnostics);
140    definitions.sort_by(|left, right| {
141        left.location
142            .line
143            .cmp(&right.location.line)
144            .then(left.location.column.cmp(&right.location.column))
145    });
146    Ok(Extraction {
147        definitions,
148        diagnostics,
149    })
150}
151
152fn collect_calls<'tree>(
153    node: Node<'tree>,
154    context: &AdapterContext<'_, 'tree>,
155    definitions: &mut Vec<StepDefinition>,
156    diagnostics: &mut Vec<ExtractionDiagnostic>,
157) {
158    let mut stack = vec![node];
159    while let Some(node) = stack.pop() {
160        if node.kind() == "call_expression" {
161            if let Some(definition) = extract_call(node, context, diagnostics) {
162                definitions.push(definition);
163            }
164        }
165        let mut cursor = node.walk();
166        let children: Vec<_> = node.named_children(&mut cursor).collect();
167        stack.extend(children.into_iter().rev());
168    }
169}
170
171fn extract_call<'tree>(
172    call: Node<'tree>,
173    context: &AdapterContext<'_, 'tree>,
174    diagnostics: &mut Vec<ExtractionDiagnostic>,
175) -> Option<StepDefinition> {
176    let source = context.source;
177    let function = call.child_by_field_name("function")?;
178    let (callee, registration) = registration_name(function, source, context.registrations)?;
179    let arguments = call.child_by_field_name("arguments")?;
180    let mut cursor = arguments.walk();
181    let arguments: Vec<_> = arguments.named_children(&mut cursor).collect();
182    if arguments.len() < 2 {
183        return None;
184    }
185    let matcher_node = arguments[0];
186    let (matcher, matcher_kind, matcher_flags) = match matcher_value(matcher_node, source) {
187        Some(value) => value,
188        None if matcher_node.kind() == "string" => {
189            diagnostics.push(ExtractionDiagnostic {
190                level: ExtractionDiagnosticLevel::Error,
191                location: node_location(context.file, matcher_node, source),
192                message: "invalid JavaScript escape sequence in step matcher".to_owned(),
193            });
194            return None;
195        }
196        None => {
197            diagnostics.push(ExtractionDiagnostic {
198                level: ExtractionDiagnosticLevel::Warning,
199                location: node_location(context.file, matcher_node, source),
200                message: "dynamic or unsupported step matcher cannot be analyzed statically"
201                    .to_owned(),
202            });
203            return None;
204        }
205    };
206    if matcher_kind == MatcherKind::RegularExpression
207        && !rust_regex_supported(&matcher, &matcher_flags)
208    {
209        diagnostics.push(ExtractionDiagnostic {
210            level: ExtractionDiagnosticLevel::Warning,
211            location: node_location(context.file, matcher_node, source),
212            message: "regular expression uses syntax unsupported by static usage analysis; unused and ambiguity checks will treat this definition as indeterminate".to_owned(),
213        });
214    }
215    let handler = arguments.iter().rev().copied().find(|node| {
216        matches!(
217            node.kind(),
218            "arrow_function"
219                | "function_expression"
220                | "generator_function"
221                | "identifier"
222                | "member_expression"
223                | "call_expression"
224        )
225    });
226    let Some(handler) = handler else {
227        diagnostics.push(ExtractionDiagnostic {
228            level: ExtractionDiagnosticLevel::Warning,
229            location: node_location(context.file, call, source),
230            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
231        });
232        return None;
233    };
234    let bound_arguments = bind_arguments(handler, source);
235    let resolved_handler = resolve_handler(handler, source, context.handler_bindings);
236    if resolved_handler.is_none() && handler.kind() == "call_expression" {
237        diagnostics.push(ExtractionDiagnostic {
238            level: ExtractionDiagnosticLevel::Warning,
239            location: node_location(context.file, handler, source),
240            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
241        });
242    }
243    let comparable = resolved_handler.is_some();
244    let handler = resolved_handler.unwrap_or(handler);
245    Some(StepDefinition {
246        normalized_matcher: normalize_matcher_with_flags(&matcher, matcher_kind, &matcher_flags),
247        matcher,
248        matcher_kind,
249        matcher_flags,
250        handler: fingerprint_handler(handler, bound_arguments, source, comparable),
251        framework: context.framework,
252        registration: if registration.is_empty() {
253            callee
254        } else {
255            registration
256        },
257        location: node_location(context.file, call, source),
258        inline_suppressions: inline_suppressions(
259            call,
260            context.source_lines,
261            context.file,
262            diagnostics,
263        ),
264    })
265}
266
267fn node_location(file: &SourceFile, node: Node<'_>, source: &[u8]) -> SourceLocation {
268    let start = node.start_position();
269    let end = node.end_position();
270    SourceLocation::new(
271        &file.path,
272        start.row + 1,
273        unicode_column(source, node.start_byte(), start.column),
274        end.row + 1,
275        unicode_column(source, node.end_byte(), end.column),
276    )
277}
278
279fn unicode_column(source: &[u8], byte_offset: usize, byte_column: usize) -> usize {
280    let line_start = byte_offset.saturating_sub(byte_column);
281    let end = byte_offset.min(source.len());
282    std::str::from_utf8(&source[line_start.min(end)..end])
283        .map_or(byte_column, |prefix| prefix.chars().count())
284        + 1
285}
286
287fn first_syntax_error(root: Node<'_>) -> Option<Node<'_>> {
288    let mut stack = vec![root];
289    while let Some(node) = stack.pop() {
290        if node.is_error() || node.is_missing() {
291            return Some(node);
292        }
293        let mut cursor = node.walk();
294        let children: Vec<_> = node.named_children(&mut cursor).collect();
295        stack.extend(children.into_iter().rev());
296    }
297    None
298}
299
300fn node_text<'a>(node: Node<'_>, source: &'a [u8]) -> &'a str {
301    std::str::from_utf8(&source[node.byte_range()]).unwrap_or("")
302}
303
304#[cfg(test)]
305mod tests;