1mod config;
4mod finalization;
5pub(crate) use config::FrontendConfig;
6pub(crate) mod semantics;
7pub(crate) mod suppression;
8pub use finalization::{
9 SourceAdvisory, SourceFinalization, SourceUncertainty, UncertaintyCause, UncertaintyScope,
10};
11
12use crate::model::{SourceLocation, StepDefinition};
13use crate::resource_limits::{read_utf8, MAX_PROJECT_INPUT_BYTES};
14use anyhow::{Context, Result};
15use std::any::{Any, TypeId};
16use std::collections::HashMap;
17use std::panic::{RefUnwindSafe, UnwindSafe};
18use std::path::{Path, PathBuf};
19
20pub(crate) const UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX: &str =
21 "unresolved step-registration calls:";
22
23pub(crate) const UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX: &str =
27 "source contains JavaScript/TypeScript syntax errors";
28
29pub(crate) const UNSUPPORTED_MATCHER_DIAGNOSTIC: &str =
31 "dynamic or unsupported step matcher cannot be analyzed statically";
32pub(crate) const INVALID_MATCHER_DIAGNOSTIC: &str =
33 "invalid JavaScript escape sequence in step matcher";
34
35#[non_exhaustive]
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum SourceLanguage {
39 Ruby,
41 JavaScript,
43 TypeScript,
45 Tsx,
47}
48
49pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
51 match language {
52 SourceLanguage::Ruby => tree_sitter_ruby::LANGUAGE.into(),
53 SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
54 SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
55 SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
56 }
57}
58
59#[non_exhaustive]
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SourceExclusion {
63 Compressed,
65 Binary,
67 Minified,
69}
70
71impl SourceExclusion {
72 pub fn as_str(self) -> &'static str {
74 match self {
75 Self::Compressed => "compressed",
76 Self::Binary => "binary",
77 Self::Minified => "minified",
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SourceFile {
85 pub path: PathBuf,
87 pub language: SourceLanguage,
89}
90
91#[non_exhaustive]
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum ExtractionDiagnosticLevel {
95 Warning,
97 Error,
99}
100
101#[non_exhaustive]
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum ExtractionDiagnosticKind {
105 LegacyMessage,
107 Dependency,
109 Other,
111 Incomplete,
113 Unparseable,
115}
116
117#[non_exhaustive]
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ExtractionDiagnostic {
121 pub kind: ExtractionDiagnosticKind,
123 pub level: ExtractionDiagnosticLevel,
125 pub location: SourceLocation,
127 pub message: String,
129}
130
131impl ExtractionDiagnostic {
132 pub fn new(
136 level: ExtractionDiagnosticLevel,
137 location: SourceLocation,
138 message: impl Into<String>,
139 ) -> Self {
140 Self::with_kind(
141 ExtractionDiagnosticKind::LegacyMessage,
142 level,
143 location,
144 message,
145 )
146 }
147
148 pub fn with_kind(
150 kind: ExtractionDiagnosticKind,
151 level: ExtractionDiagnosticLevel,
152 location: SourceLocation,
153 message: impl Into<String>,
154 ) -> Self {
155 Self {
156 kind,
157 level,
158 location,
159 message: message.into(),
160 }
161 }
162}
163
164pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
167 match diagnostic.kind {
168 ExtractionDiagnosticKind::Incomplete
169 | ExtractionDiagnosticKind::Unparseable
170 | ExtractionDiagnosticKind::Dependency => true,
171 ExtractionDiagnosticKind::Other => false,
172 ExtractionDiagnosticKind::LegacyMessage => {
173 diagnostic
174 .message
175 .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
176 || matches!(
177 diagnostic.message.as_str(),
178 UNSUPPORTED_MATCHER_DIAGNOSTIC | INVALID_MATCHER_DIAGNOSTIC
179 )
180 || is_unparseable_diagnostic(diagnostic)
181 }
182 }
183}
184
185pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
187 match diagnostic.kind {
188 ExtractionDiagnosticKind::Unparseable => true,
189 ExtractionDiagnosticKind::LegacyMessage => diagnostic
190 .message
191 .starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX),
192 ExtractionDiagnosticKind::Other
193 | ExtractionDiagnosticKind::Incomplete
194 | ExtractionDiagnosticKind::Dependency => false,
195 }
196}
197
198#[non_exhaustive]
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SourceDependency {
202 pub location: SourceLocation,
204 pub target: PathBuf,
206 pub dependency_only: bool,
208}
209
210impl SourceDependency {
211 pub fn with_dependency_only_target(mut self, dependency_only: bool) -> Self {
213 self.dependency_only = dependency_only;
214 self
215 }
216
217 pub fn new(location: SourceLocation, target: PathBuf) -> Self {
219 Self {
220 location,
221 target,
222 dependency_only: false,
223 }
224 }
225}
226
227#[non_exhaustive]
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct DependencyGap {
234 pub location: SourceLocation,
236 pub reason: String,
238}
239
240impl DependencyGap {
241 pub fn new(location: SourceLocation, reason: impl Into<String>) -> Self {
243 Self {
244 location,
245 reason: reason.into(),
246 }
247 }
248}
249
250#[non_exhaustive]
252#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct IndirectStepUsage {
254 pub texts: std::collections::BTreeSet<String>,
256 pub unknown: bool,
258}
259
260impl IndirectStepUsage {
261 pub fn extend(&mut self, other: Option<Self>) {
263 match other {
264 Some(other) => {
265 self.texts.extend(other.texts);
266 self.unknown |= other.unknown;
267 }
268 None => self.unknown = true,
269 }
270 }
271}
272
273#[non_exhaustive]
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct SourceParameterType {
277 pub name: Option<String>,
279 pub expression: Option<String>,
281 pub location: SourceLocation,
283}
284
285impl SourceParameterType {
286 pub fn new(name: Option<String>, expression: Option<String>, location: SourceLocation) -> Self {
288 Self {
289 name,
290 expression,
291 location,
292 }
293 }
294}
295
296#[non_exhaustive]
298#[derive(Debug, Clone, Default, PartialEq)]
299pub struct Extraction {
300 pub definitions: Vec<StepDefinition>,
302 pub parameter_types: Vec<SourceParameterType>,
304 pub indirect_usage: Option<IndirectStepUsage>,
306 pub diagnostics: Vec<ExtractionDiagnostic>,
308}
309
310impl Extraction {
311 pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
313 Self {
314 definitions,
315 diagnostics,
316 indirect_usage: None,
317 parameter_types: Vec::new(),
318 }
319 }
320}
321
322#[derive(Default)]
327pub struct SourceExtractionSession {
328 dependencies: std::collections::BTreeMap<(PathBuf, usize, usize), SourceDependency>,
329 dependency_gaps: std::collections::BTreeMap<(PathBuf, usize, usize), String>,
330 root: Option<PathBuf>,
331 registrations: Vec<String>,
332 assertion_modules: Vec<String>,
333 states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
334}
335
336pub(crate) trait AdapterSessionState:
339 Any + Send + Sync + UnwindSafe + RefUnwindSafe
340{
341 fn initialize(
345 root: Option<&Path>,
346 registrations: &[String],
347 assertion_modules: &[String],
348 ) -> Self;
349}
350
351pub(crate) trait StatefulSourceAdapter: SourceAdapter {
353 type State: AdapterSessionState;
354 fn discover_dependencies(
358 _config: &crate::config::Config,
359 _excludes: &globset::GlobSet,
360 _files: &mut crate::discovery::DiscoveredFiles,
361 ) {
362 }
363}
364
365impl SourceExtractionSession {
366 pub fn with_dependencies(mut self, dependencies: &[SourceDependency]) -> Self {
368 self.dependencies.extend(dependencies.iter().map(|edge| {
369 (
370 (
371 edge.location.path.clone(),
372 edge.location.line,
373 edge.location.column,
374 ),
375 edge.clone(),
376 )
377 }));
378 self
379 }
380
381 pub fn finalize(
386 &mut self,
387 files: &[SourceFile],
388 definitions: &mut Vec<StepDefinition>,
389 ) -> Result<SourceFinalization> {
390 let mut result = SourceFinalization::default();
391 let mut completed = std::collections::BTreeSet::new();
392 for file in files {
393 let adapter = adapter_for_language(file.language);
394 if completed.insert(adapter.name()) {
395 let finalization = adapter.finalize_session(self)?;
396 result.uncertainties.extend(finalization.uncertainties);
397 result.advisories.extend(finalization.advisories);
398 }
399 }
400 result.apply(definitions);
401 Ok(result)
402 }
403
404 pub fn with_dependency_gaps(mut self, gaps: &[DependencyGap]) -> Self {
406 self.dependency_gaps.extend(gaps.iter().map(|gap| {
407 (
408 (
409 gap.location.path.clone(),
410 gap.location.line,
411 gap.location.column,
412 ),
413 gap.reason.clone(),
414 )
415 }));
416 self
417 }
418
419 pub(crate) fn dependency_gap(&self, location: &SourceLocation) -> Option<&str> {
421 self.dependency_gaps
422 .get(&(location.path.clone(), location.line, location.column))
423 .map(String::as_str)
424 }
425
426 pub(crate) fn dependency_resolved(&self, location: &SourceLocation) -> bool {
429 self.dependencies
430 .contains_key(&(location.path.clone(), location.line, location.column))
431 }
432
433 pub(crate) fn dependency_edges(&self) -> Vec<SourceDependency> {
435 self.dependencies.values().cloned().collect()
436 }
437
438 pub fn new(root: &Path) -> Self {
440 Self::with_registrations(root, &[])
441 }
442
443 pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
448 Self::with_options(root, registrations, &[])
449 }
450
451 pub fn with_options(
457 root: &Path,
458 registrations: &[String],
459 assertion_modules: &[String],
460 ) -> Self {
461 let mut session = Self {
462 root: Some(root.to_owned()),
463 registrations: registrations.to_vec(),
464 assertion_modules: assertion_modules.to_vec(),
465 ..Self::default()
466 };
467 for registration in SOURCE_ADAPTER_REGISTRY {
469 if let Some(initialize) = registration.initialize_session {
470 initialize(&mut session);
471 }
472 }
473 session
474 }
475
476 pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
478 self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
479 Box::new(T::initialize(
480 self.root.as_deref(),
481 &self.registrations,
482 &self.assertion_modules,
483 ))
484 });
485 }
486
487 pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
491 if self.root.is_none() {
493 self.initialize::<T>();
494 }
495 self.states
496 .get_mut(&TypeId::of::<T>())
497 .and_then(|state| {
498 let state: &mut dyn Any = state.as_mut();
499 state.downcast_mut::<T>()
500 })
501 .with_context(|| {
502 format!(
503 "source adapter state {} is uninitialized or incompatible",
504 std::any::type_name::<T>()
505 )
506 })
507 }
508}
509
510pub trait SourceAdapter: Sync {
512 fn name(&self) -> &'static str;
514
515 fn language(&self) -> SourceLanguage;
517
518 fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
520
521 fn discover_by_default(&self) -> bool {
523 true
524 }
525
526 fn inspect_source(
528 &self,
529 _file: &SourceFile,
530 _registrations: &[String],
531 ) -> Option<SourceExclusion> {
532 None
533 }
534
535 fn supports_indirect_usage(&self) -> bool {
537 false
538 }
539
540 fn resolve_parameter_types(
542 &self,
543 declarations: Vec<SourceParameterType>,
544 configured: &std::collections::BTreeMap<String, String>,
545 ) -> (std::collections::BTreeMap<String, String>, Vec<String>) {
546 if declarations.is_empty() {
547 (configured.clone(), Vec::new())
548 } else {
549 (
550 Default::default(),
551 vec!["frontend does not support source parameter declarations".into()],
552 )
553 }
554 }
555
556 fn prepare_session(
558 &self,
559 _files: &[SourceFile],
560 _session: &mut SourceExtractionSession,
561 ) -> Result<()> {
562 Ok(())
563 }
564
565 fn finalize_session(
567 &self,
568 _session: &mut SourceExtractionSession,
569 ) -> Result<SourceFinalization> {
570 Ok(SourceFinalization::default())
571 }
572
573 fn extract_with_session(
575 &self,
576 source: &str,
577 file: &SourceFile,
578 _session: &mut SourceExtractionSession,
579 ) -> Result<Extraction> {
580 self.extract(source, file)
581 }
582
583 fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
585 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
586 self.extract(&source, file)
587 }
588
589 fn extract_file_with_session(
591 &self,
592 file: &SourceFile,
593 session: &mut SourceExtractionSession,
594 ) -> Result<Extraction> {
595 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
596 self.extract_with_session(&source, file, session)
597 }
598}
599
600type DependencyDiscovery =
601 fn(&crate::config::Config, &globset::GlobSet, &mut crate::discovery::DiscoveredFiles);
602
603#[non_exhaustive]
605#[derive(Clone, Copy)]
606pub struct SourceAdapterRegistration {
607 pub suffix: &'static str,
609 pub adapter: &'static dyn SourceAdapter,
611 initialize_session: Option<fn(&mut SourceExtractionSession)>,
612 discover_dependencies: Option<DependencyDiscovery>,
613}
614
615impl SourceAdapterRegistration {
616 pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
618 Self {
619 suffix,
620 adapter,
621 initialize_session: None,
622 discover_dependencies: None,
623 }
624 }
625
626 const fn with_session<A: StatefulSourceAdapter>(
629 suffix: &'static str,
630 adapter: &'static A,
631 ) -> Self {
632 Self {
633 suffix,
634 adapter,
635 initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
636 discover_dependencies: Some(A::discover_dependencies),
637 }
638 }
639}
640
641macro_rules! register_source_adapters {
646 ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
647 pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
649 $(
650 $(
651 SourceAdapterRegistration::with_session($suffix, &$adapter),
652 )+
653 )+
654 ];
655
656 pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
658 match language {
659 $(SourceLanguage::$language => &$adapter,)+
660 }
661 }
662 };
663}
664
665register_source_adapters! {
666 Ruby => crate::ruby::RUBY_ADAPTER : ".rb";
667 JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
668 TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
669 Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
670}
671
672pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
674 let extension = path.extension()?.to_str()?;
675 if extension == "map" || is_declaration_file(path, extension) {
676 return None;
677 }
678 SOURCE_ADAPTER_REGISTRY
679 .iter()
680 .find(|registration| extension == registration.suffix.trim_start_matches('.'))
681 .map(|registration| registration.adapter)
682}
683
684fn is_declaration_file(path: &Path, extension: &str) -> bool {
687 matches!(extension, "ts" | "mts" | "cts")
688 && path.file_stem().is_some_and(|stem| {
689 stem == ".d"
690 || Path::new(stem)
691 .extension()
692 .is_some_and(|extension| extension == "d")
693 })
694}
695
696pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
698 adapter_for_path(path).map(SourceAdapter::language)
699}
700
701pub fn registered_suffixes() -> Vec<&'static str> {
703 SOURCE_ADAPTER_REGISTRY
704 .iter()
705 .map(|registration| registration.suffix)
706 .collect()
707}
708
709#[cfg(test)]
710mod tests;
711
712pub(crate) fn discover_dependencies(
714 config: &crate::config::Config,
715 excludes: &globset::GlobSet,
716 files: &mut crate::discovery::DiscoveredFiles,
717) {
718 let mut completed = std::collections::BTreeSet::new();
719 let callbacks: Vec<_> = SOURCE_ADAPTER_REGISTRY
720 .iter()
721 .filter(|entry| {
722 files
723 .definitions
724 .iter()
725 .any(|file| file.language == entry.adapter.language())
726 })
727 .filter(|entry| completed.insert(entry.adapter.name()))
728 .filter_map(|entry| entry.discover_dependencies)
729 .collect();
730 for callback in callbacks {
731 callback(config, excludes, files);
732 }
733}