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