1mod ast;
4mod handler;
5mod matcher;
6mod module_resolver;
7mod project_resolution;
8mod registrations;
9mod suppression;
10
11use self::handler::{
12 bind_arguments, collect_handler_bindings, fingerprint_handler, fingerprint_method_handler,
13 resolve_handler, HandlerBinding,
14};
15#[cfg(test)]
16use self::handler::{bounded_source_snippet, MAX_HANDLER_SNIPPET_CHARS};
17pub use self::matcher::normalize_matcher;
18#[cfg(test)]
19use self::matcher::normalize_regular_expression;
20pub(crate) use self::matcher::rust_regex_expression;
21use self::matcher::{matcher_value, normalize_matcher_with_flags};
22use self::matcher::{rust_regex_support, RegexSupport};
23use self::module_resolver::RegistrationResolver;
24use self::registrations::{
25 decorator_registration_name, detect_framework, detect_registrations, registration_callee,
26 registration_name, unresolved_registration_module, RegistrationCallee, RegistrationNames,
27};
28use self::suppression::inline_suppressions;
29use crate::model::{Framework, MatcherKind, SourceLocation, StepDefinition};
30use crate::source_adapter::{
31 adapter_for_language, grammar_for_language, SourceAdapter, SourceExtractionSession,
32 UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX,
33};
34pub use crate::source_adapter::{
35 Extraction, ExtractionDiagnostic, ExtractionDiagnosticLevel, SourceFile, SourceLanguage,
36};
37use anyhow::{Context, Result};
38use std::collections::{BTreeMap, BTreeSet};
39use tree_sitter::{Node, Parser};
40
41pub(crate) struct TreeSitterSourceAdapter {
42 name: &'static str,
43 language: SourceLanguage,
44}
45
46pub(crate) static JAVASCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
47 name: "javascript",
48 language: SourceLanguage::JavaScript,
49};
50pub(crate) static TYPESCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
51 name: "typescript",
52 language: SourceLanguage::TypeScript,
53};
54pub(crate) static TSX_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
55 name: "tsx",
56 language: SourceLanguage::Tsx,
57};
58
59#[derive(Default)]
60pub(crate) struct TypeScriptExtractionSession {
61 resolver: RegistrationResolver,
62 configured_registrations: std::collections::BTreeSet<String>,
63}
64
65impl TypeScriptExtractionSession {
66 pub(crate) fn for_root(root: &std::path::Path, registrations: &[String]) -> Self {
67 Self {
68 resolver: RegistrationResolver::for_root(root),
69 configured_registrations: registrations.iter().cloned().collect(),
70 }
71 }
72}
73
74impl SourceAdapter for TreeSitterSourceAdapter {
75 fn name(&self) -> &'static str {
76 self.name
77 }
78
79 fn language(&self) -> SourceLanguage {
80 self.language
81 }
82
83 fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction> {
84 let mut session = SourceExtractionSession::default();
85 self.extract_with_session(source, file, &mut session)
86 }
87
88 fn extract_with_session(
89 &self,
90 source: &str,
91 file: &SourceFile,
92 session: &mut SourceExtractionSession,
93 ) -> Result<Extraction> {
94 if file.language != self.language {
95 anyhow::bail!(
96 "{} adapter cannot parse {:?} source {}",
97 self.name,
98 file.language,
99 file.path.display()
100 );
101 }
102 extract_detailed_impl(source, file, &mut session.typescript)
103 }
104}
105
106struct AdapterContext<'source, 'tree> {
107 source: &'source [u8],
108 source_lines: &'source [&'source str],
109 file: &'source SourceFile,
110 framework: Framework,
111 registrations: &'source RegistrationNames,
112 handler_bindings: &'source BTreeMap<String, Vec<HandlerBinding<'tree>>>,
113}
114
115#[derive(Default)]
116struct UnresolvedRegistrationCalls {
117 count: usize,
118 first_location: Option<SourceLocation>,
119 modules: BTreeMap<String, (usize, SourceLocation)>,
120}
121
122impl UnresolvedRegistrationCalls {
123 fn record(&mut self, location: SourceLocation) {
124 self.count = self.count.saturating_add(1);
125 self.first_location.get_or_insert(location);
126 }
127
128 fn record_module(&mut self, module: &str, location: SourceLocation) {
129 let entry = self
130 .modules
131 .entry(module.to_owned())
132 .or_insert_with(|| (0, location));
133 entry.0 = entry.0.saturating_add(1);
134 }
135}
136
137pub fn extract_file(file: &SourceFile) -> Result<Vec<StepDefinition>> {
139 Ok(extract_file_detailed(file)?.definitions)
140}
141
142pub fn extract_file_detailed(file: &SourceFile) -> Result<Extraction> {
144 adapter_for_language(file.language).extract_file(file)
145}
146
147pub fn extract(source: &str, file: &SourceFile) -> Result<Vec<StepDefinition>> {
149 Ok(extract_detailed(source, file)?.definitions)
150}
151
152pub fn extract_detailed(source: &str, file: &SourceFile) -> Result<Extraction> {
154 adapter_for_language(file.language).extract(source, file)
155}
156
157fn extract_detailed_impl(
158 source: &str,
159 file: &SourceFile,
160 session: &mut TypeScriptExtractionSession,
161) -> Result<Extraction> {
162 let mut parser = Parser::new();
163 parser
164 .set_language(&grammar_for_language(file.language))
165 .context("failed to initialize the JavaScript/TypeScript parser")?;
166 let tree = parser
167 .parse(source, None)
168 .context("Tree-sitter did not return a syntax tree")?;
169
170 let root = tree.root_node();
171 let source_bytes = source.as_bytes();
172 let source_lines: Vec<_> = source.lines().collect();
173 let framework = detect_framework(root, source_bytes);
174 let registrations = detect_registrations(
175 root,
176 source_bytes,
177 framework,
178 &file.path,
179 &mut session.resolver,
180 &session.configured_registrations,
181 )?;
182 let framework = registrations.framework;
183 let mut handler_bindings = BTreeMap::new();
184 collect_handler_bindings(root, source_bytes, &mut handler_bindings);
185 let context = AdapterContext {
186 source: source_bytes,
187 source_lines: &source_lines,
188 file,
189 framework,
190 registrations: ®istrations,
191 handler_bindings: &handler_bindings,
192 };
193 let mut definitions = Vec::new();
194 let mut diagnostics = Vec::new();
195 let mut unresolved_registration_calls = UnresolvedRegistrationCalls::default();
196 if root.has_error() {
197 let syntax_node = first_syntax_error(root).unwrap_or(root);
198 diagnostics.push(ExtractionDiagnostic {
199 level: ExtractionDiagnosticLevel::Error,
200 location: node_location(file, syntax_node, source_bytes),
201 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(),
202 });
203 }
204 collect_calls(
205 root,
206 &context,
207 &mut definitions,
208 &mut diagnostics,
209 &mut unresolved_registration_calls,
210 );
211 if let Some(location) = unresolved_registration_calls.first_location {
212 diagnostics.push(ExtractionDiagnostic {
213 level: ExtractionDiagnosticLevel::Warning,
214 location,
215 message: format!(
216 "{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",
217 unresolved_registration_calls.count
218 ),
219 });
220 }
221 let mut explained_modules = BTreeSet::new();
222 for (module, (count, location)) in unresolved_registration_calls.modules {
223 let cause = registrations::unresolved_module_reason(®istrations, &module)
224 .map(|reason| format!(" ({reason})"))
225 .unwrap_or_default();
226 diagnostics.push(ExtractionDiagnostic {
227 level: ExtractionDiagnosticLevel::Warning,
228 location,
229 message: format!(
230 "{UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX} {count} call(s) import a known registration through unresolved module `{module}`{cause}; definitions may be missing"
231 ),
232 });
233 explained_modules.insert(module);
234 }
235 for (module, recorded) in registrations::unresolved_module_reasons(®istrations) {
240 if explained_modules.contains(module) {
241 continue;
242 }
243 let line = recorded.row + 1;
244 let column = unicode_column(source_bytes, recorded.byte_offset, recorded.byte_column);
245 diagnostics.push(ExtractionDiagnostic {
246 level: ExtractionDiagnosticLevel::Warning,
247 location: SourceLocation::new(&file.path, line, column, line, column),
248 message: format!(
249 "{UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX} module `{module}` could not be resolved statically ({}); definitions may be missing",
250 recorded.reason
251 ),
252 });
253 }
254 definitions.sort_by(|left, right| {
255 left.location
256 .line
257 .cmp(&right.location.line)
258 .then(left.location.column.cmp(&right.location.column))
259 });
260 Ok(Extraction {
261 definitions,
262 diagnostics,
263 })
264}
265
266fn collect_calls<'tree>(
267 node: Node<'tree>,
268 context: &AdapterContext<'_, 'tree>,
269 definitions: &mut Vec<StepDefinition>,
270 diagnostics: &mut Vec<ExtractionDiagnostic>,
271 unresolved_registration_calls: &mut UnresolvedRegistrationCalls,
272) {
273 let mut stack = vec![node];
274 while let Some(node) = stack.pop() {
275 if node.kind() == "call_expression" {
276 if let Some(unresolved) = unresolved_registration_call(node, context) {
277 let location = node_location(context.file, node, context.source);
278 match unresolved {
279 UnresolvedRegistration::Generic => {
280 unresolved_registration_calls.record(location)
281 }
282 UnresolvedRegistration::Module(module) => {
283 unresolved_registration_calls.record_module(module, location)
284 }
285 }
286 }
287 let definition = if node
288 .parent()
289 .is_some_and(|parent| parent.kind() == "decorator")
290 {
291 extract_decorator(node, context, diagnostics)
292 } else {
293 extract_call(node, context, diagnostics)
294 };
295 if let Some(definition) = definition {
296 definitions.push(definition);
297 }
298 }
299 let mut cursor = node.walk();
300 let children: Vec<_> = node.named_children(&mut cursor).collect();
301 stack.extend(children.into_iter().rev());
302 }
303}
304
305enum UnresolvedRegistration<'a> {
306 Generic,
307 Module(&'a str),
308}
309
310fn unresolved_registration_call<'a>(
311 call: Node<'_>,
312 context: &'a AdapterContext<'_, '_>,
313) -> Option<UnresolvedRegistration<'a>> {
314 let function = call.child_by_field_name("function")?;
315 if registration_name(function, context.source, context.registrations).is_some()
316 || decorator_registration_name(function, context.source, context.registrations).is_some()
317 {
318 return None;
319 }
320 if let Some(module) =
321 unresolved_registration_module(function, context.source, context.registrations)
322 {
323 return Some(UnresolvedRegistration::Module(module));
324 }
325 let looks_like_registration = match registration_callee(function, context.source) {
326 Some(RegistrationCallee::Identifier(name)) => matches!(
327 name,
328 "Given"
329 | "When"
330 | "Then"
331 | "And"
332 | "But"
333 | "Step"
334 | "defineStep"
335 | "given"
336 | "when"
337 | "then"
338 ),
339 Some(RegistrationCallee::Property { name, .. }) => matches!(
342 name.as_ref(),
343 "Given" | "When" | "Then" | "And" | "But" | "Step" | "defineStep"
344 ),
345 _ => false,
346 };
347 looks_like_registration.then_some(UnresolvedRegistration::Generic)
348}
349
350fn extract_call<'tree>(
351 call: Node<'tree>,
352 context: &AdapterContext<'_, 'tree>,
353 diagnostics: &mut Vec<ExtractionDiagnostic>,
354) -> Option<StepDefinition> {
355 let source = context.source;
356 let function = call.child_by_field_name("function")?;
357 let (callee, registration, registration_framework) =
358 registration_name(function, source, context.registrations)?;
359 let arguments = call.child_by_field_name("arguments")?;
360 let mut cursor = arguments.walk();
361 let arguments: Vec<_> = arguments.named_children(&mut cursor).collect();
362 if arguments.len() < 2 {
363 return None;
364 }
365 let matcher_node = arguments[0];
366 let (matcher, matcher_kind, matcher_flags) =
367 extract_matcher(matcher_node, context, diagnostics)?;
368 let handler = arguments.iter().rev().copied().find(|node| {
369 matches!(
370 node.kind(),
371 "arrow_function"
372 | "function_expression"
373 | "generator_function"
374 | "identifier"
375 | "member_expression"
376 | "call_expression"
377 )
378 });
379 let Some(handler) = handler else {
380 diagnostics.push(ExtractionDiagnostic {
381 level: ExtractionDiagnosticLevel::Warning,
382 location: node_location(context.file, call, source),
383 message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
384 });
385 return None;
386 };
387 let bound_arguments = bind_arguments(handler, source);
388 let resolved_handler = resolve_handler(handler, source, context.handler_bindings);
389 if resolved_handler.is_none() && handler.kind() == "call_expression" {
390 diagnostics.push(ExtractionDiagnostic {
391 level: ExtractionDiagnosticLevel::Warning,
392 location: node_location(context.file, handler, source),
393 message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
394 });
395 }
396 let comparable = resolved_handler.is_some();
397 let handler = resolved_handler.unwrap_or(handler);
398 Some(StepDefinition {
399 normalized_matcher: normalize_matcher_with_flags(&matcher, matcher_kind, &matcher_flags),
400 matcher,
401 matcher_kind,
402 matcher_flags,
403 handler: fingerprint_handler(handler, bound_arguments, source, comparable),
404 framework: effective_registration_framework(registration_framework, context.framework),
405 registration: if registration.is_empty() {
406 callee
407 } else {
408 registration
409 },
410 location: node_location(context.file, call, source),
411 inline_suppressions: inline_suppressions(
412 call,
413 context.source_lines,
414 context.file,
415 diagnostics,
416 ),
417 })
418}
419
420fn extract_decorator<'tree>(
421 call: Node<'tree>,
422 context: &AdapterContext<'_, 'tree>,
423 diagnostics: &mut Vec<ExtractionDiagnostic>,
424) -> Option<StepDefinition> {
425 let source = context.source;
426 let function = call.child_by_field_name("function")?;
427 let (callee, registration, registration_framework) =
428 decorator_registration_name(function, source, context.registrations)?;
429 let arguments = call.child_by_field_name("arguments")?;
431 let mut cursor = arguments.walk();
432 let Some(matcher_node) = arguments.named_children(&mut cursor).next() else {
433 diagnostics.push(ExtractionDiagnostic {
434 level: ExtractionDiagnosticLevel::Warning,
435 location: node_location(context.file, call, source),
436 message: "dynamic or unsupported step matcher cannot be analyzed statically".to_owned(),
437 });
438 return None;
439 };
440 let (matcher, matcher_kind, matcher_flags) =
441 extract_matcher(matcher_node, context, diagnostics)?;
442 let Some(method) = decorated_method(call) else {
443 diagnostics.push(ExtractionDiagnostic {
444 level: ExtractionDiagnosticLevel::Warning,
445 location: node_location(context.file, call, source),
446 message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
447 });
448 return None;
449 };
450 let Some(handler) = fingerprint_method_handler(method, source) else {
451 diagnostics.push(ExtractionDiagnostic {
452 level: ExtractionDiagnosticLevel::Warning,
453 location: node_location(context.file, method, source),
454 message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
455 });
456 return None;
457 };
458 Some(StepDefinition {
459 normalized_matcher: normalize_matcher_with_flags(&matcher, matcher_kind, &matcher_flags),
460 matcher,
461 matcher_kind,
462 matcher_flags,
463 handler,
464 framework: effective_registration_framework(registration_framework, context.framework),
465 registration: if registration.is_empty() {
466 callee
467 } else {
468 registration
469 },
470 location: node_location(context.file, call, source),
471 inline_suppressions: inline_suppressions(
472 call,
473 context.source_lines,
474 context.file,
475 diagnostics,
476 ),
477 })
478}
479
480fn decorated_method(call: Node<'_>) -> Option<Node<'_>> {
484 let decorator = call
485 .parent()
486 .filter(|parent| parent.kind() == "decorator")?;
487 if let Some(method) = decorator
488 .parent()
489 .filter(|parent| parent.kind() == "method_definition")
490 {
491 return Some(method);
492 }
493 let mut sibling = decorator.next_named_sibling()?;
494 while sibling.kind() == "decorator" {
495 sibling = sibling.next_named_sibling()?;
496 }
497 (sibling.kind() == "method_definition").then_some(sibling)
498}
499
500fn effective_registration_framework(binding: Framework, file: Framework) -> Framework {
501 if binding == Framework::Unknown {
502 file
503 } else {
504 binding
505 }
506}
507
508fn extract_matcher(
509 matcher_node: Node<'_>,
510 context: &AdapterContext<'_, '_>,
511 diagnostics: &mut Vec<ExtractionDiagnostic>,
512) -> Option<(String, MatcherKind, String)> {
513 let source = context.source;
514 let (matcher, matcher_kind, matcher_flags) = match matcher_value(matcher_node, source) {
515 Some(value) => value,
516 None if matcher_node.kind() == "string" => {
517 diagnostics.push(ExtractionDiagnostic {
518 level: ExtractionDiagnosticLevel::Error,
519 location: node_location(context.file, matcher_node, source),
520 message: "invalid JavaScript escape sequence in step matcher".to_owned(),
521 });
522 return None;
523 }
524 None => {
525 diagnostics.push(ExtractionDiagnostic {
526 level: ExtractionDiagnosticLevel::Warning,
527 location: node_location(context.file, matcher_node, source),
528 message: "dynamic or unsupported step matcher cannot be analyzed statically"
529 .to_owned(),
530 });
531 return None;
532 }
533 };
534 if matcher_kind == MatcherKind::RegularExpression {
535 match rust_regex_support(&matcher, &matcher_flags) {
536 RegexSupport::Unsupported => diagnostics.push(ExtractionDiagnostic {
537 level: ExtractionDiagnosticLevel::Warning,
538 location: node_location(context.file, matcher_node, source),
539 message: "regular expression uses syntax unsupported by static usage analysis; unused and ambiguity checks will treat this definition as indeterminate".to_owned(),
540 }),
541 RegexSupport::ResourceLimit | RegexSupport::Supported => {}
543 }
544 }
545 Some((matcher, matcher_kind, matcher_flags))
546}
547
548fn node_location(file: &SourceFile, node: Node<'_>, source: &[u8]) -> SourceLocation {
549 let start = node.start_position();
550 let end = node.end_position();
551 SourceLocation::new(
552 &file.path,
553 start.row + 1,
554 unicode_column(source, node.start_byte(), start.column),
555 end.row + 1,
556 unicode_column(source, node.end_byte(), end.column),
557 )
558}
559
560fn unicode_column(source: &[u8], byte_offset: usize, byte_column: usize) -> usize {
561 let line_start = byte_offset.saturating_sub(byte_column);
562 let end = byte_offset.min(source.len());
563 std::str::from_utf8(&source[line_start.min(end)..end])
564 .map_or(byte_column, |prefix| prefix.chars().count())
565 + 1
566}
567
568fn first_syntax_error(root: Node<'_>) -> Option<Node<'_>> {
569 let mut stack = vec![root];
570 while let Some(node) = stack.pop() {
571 if node.is_error() || node.is_missing() {
572 return Some(node);
573 }
574 let mut cursor = node.walk();
575 let children: Vec<_> = node.named_children(&mut cursor).collect();
576 stack.extend(children.into_iter().rev());
577 }
578 None
579}
580
581fn node_text<'a>(node: Node<'_>, source: &'a [u8]) -> &'a str {
582 std::str::from_utf8(&source[node.byte_range()]).unwrap_or("")
583}
584
585#[cfg(test)]
586mod tests;