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