1mod 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
80pub fn extract_file(file: &SourceFile) -> Result<Vec<StepDefinition>> {
82 Ok(extract_file_detailed(file)?.definitions)
83}
84
85pub fn extract_file_detailed(file: &SourceFile) -> Result<Extraction> {
87 adapter_for_language(file.language).extract_file(file)
88}
89
90pub fn extract(source: &str, file: &SourceFile) -> Result<Vec<StepDefinition>> {
92 Ok(extract_detailed(source, file)?.definitions)
93}
94
95pub 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: ®istrations,
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;