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