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