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 {
165 match diagnostic.kind {
166 ExtractionDiagnosticKind::Incomplete
167 | ExtractionDiagnosticKind::Unparseable
168 | ExtractionDiagnosticKind::Dependency => true,
169 ExtractionDiagnosticKind::Other => false,
170 ExtractionDiagnosticKind::LegacyMessage => {
171 diagnostic
172 .message
173 .starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
174 || matches!(
175 diagnostic.message.as_str(),
176 UNSUPPORTED_MATCHER_DIAGNOSTIC | INVALID_MATCHER_DIAGNOSTIC
177 )
178 || is_unparseable_diagnostic(diagnostic)
179 }
180 }
181}
182
183pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
185 match diagnostic.kind {
186 ExtractionDiagnosticKind::Unparseable => true,
187 ExtractionDiagnosticKind::LegacyMessage => diagnostic
188 .message
189 .starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX),
190 ExtractionDiagnosticKind::Other
191 | ExtractionDiagnosticKind::Incomplete
192 | ExtractionDiagnosticKind::Dependency => false,
193 }
194}
195
196#[non_exhaustive]
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SourceDependency {
200 pub location: SourceLocation,
202 pub target: PathBuf,
204 pub dependency_only: bool,
206}
207
208impl SourceDependency {
209 pub fn with_dependency_only_target(mut self, dependency_only: bool) -> Self {
211 self.dependency_only = dependency_only;
212 self
213 }
214
215 pub fn new(location: SourceLocation, target: PathBuf) -> Self {
217 Self {
218 location,
219 target,
220 dependency_only: false,
221 }
222 }
223}
224
225#[non_exhaustive]
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct DependencyGap {
232 pub location: SourceLocation,
234 pub reason: String,
236}
237
238impl DependencyGap {
239 pub fn new(location: SourceLocation, reason: impl Into<String>) -> Self {
241 Self {
242 location,
243 reason: reason.into(),
244 }
245 }
246}
247
248#[non_exhaustive]
250#[derive(Debug, Clone, Default, PartialEq, Eq)]
251pub struct IndirectStepUsage {
252 pub texts: std::collections::BTreeSet<String>,
254 pub unknown: bool,
256}
257
258impl IndirectStepUsage {
259 pub fn extend(&mut self, other: Option<Self>) {
261 match other {
262 Some(other) => {
263 self.texts.extend(other.texts);
264 self.unknown |= other.unknown;
265 }
266 None => self.unknown = true,
267 }
268 }
269}
270
271#[non_exhaustive]
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct SourceParameterType {
275 pub name: Option<String>,
277 pub expression: Option<String>,
279 pub location: SourceLocation,
281}
282
283impl SourceParameterType {
284 pub fn new(name: Option<String>, expression: Option<String>, location: SourceLocation) -> Self {
286 Self {
287 name,
288 expression,
289 location,
290 }
291 }
292}
293
294#[non_exhaustive]
296#[derive(Debug, Clone, Default, PartialEq)]
297pub struct Extraction {
298 pub definitions: Vec<StepDefinition>,
300 pub parameter_types: Vec<SourceParameterType>,
302 pub indirect_usage: Option<IndirectStepUsage>,
304 pub diagnostics: Vec<ExtractionDiagnostic>,
306}
307
308impl Extraction {
309 pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
311 Self {
312 definitions,
313 diagnostics,
314 indirect_usage: None,
315 parameter_types: Vec::new(),
316 }
317 }
318}
319
320#[derive(Default)]
325pub struct SourceExtractionSession {
326 dependencies: std::collections::BTreeMap<(PathBuf, usize, usize), SourceDependency>,
327 dependency_gaps: std::collections::BTreeMap<(PathBuf, usize, usize), String>,
328 root: Option<PathBuf>,
329 registrations: Vec<String>,
330 assertion_modules: Vec<String>,
331 states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
332}
333
334pub(crate) trait AdapterSessionState:
337 Any + Send + Sync + UnwindSafe + RefUnwindSafe
338{
339 fn initialize(
340 root: Option<&Path>,
341 registrations: &[String],
342 assertion_modules: &[String],
343 ) -> Self;
344}
345
346pub(crate) trait StatefulSourceAdapter: SourceAdapter {
348 type State: AdapterSessionState;
349 fn discover_dependencies(
350 _config: &crate::config::Config,
351 _excludes: &globset::GlobSet,
352 _files: &mut crate::discovery::DiscoveredFiles,
353 ) {
354 }
355}
356
357impl SourceExtractionSession {
358 pub fn with_dependencies(mut self, dependencies: &[SourceDependency]) -> Self {
360 self.dependencies.extend(dependencies.iter().map(|edge| {
361 (
362 (
363 edge.location.path.clone(),
364 edge.location.line,
365 edge.location.column,
366 ),
367 edge.clone(),
368 )
369 }));
370 self
371 }
372
373 pub fn finalize(
378 &mut self,
379 files: &[SourceFile],
380 definitions: &mut Vec<StepDefinition>,
381 ) -> Result<SourceFinalization> {
382 let mut result = SourceFinalization::default();
383 let mut completed = std::collections::BTreeSet::new();
384 for file in files {
385 let adapter = adapter_for_language(file.language);
386 if completed.insert(adapter.name()) {
387 let finalization = adapter.finalize_session(self)?;
388 result.uncertainties.extend(finalization.uncertainties);
389 result.advisories.extend(finalization.advisories);
390 }
391 }
392 result.apply(definitions);
393 Ok(result)
394 }
395
396 pub fn with_dependency_gaps(mut self, gaps: &[DependencyGap]) -> Self {
398 self.dependency_gaps.extend(gaps.iter().map(|gap| {
399 (
400 (
401 gap.location.path.clone(),
402 gap.location.line,
403 gap.location.column,
404 ),
405 gap.reason.clone(),
406 )
407 }));
408 self
409 }
410
411 pub(crate) fn dependency_gap(&self, location: &SourceLocation) -> Option<&str> {
413 self.dependency_gaps
414 .get(&(location.path.clone(), location.line, location.column))
415 .map(String::as_str)
416 }
417
418 pub(crate) fn dependency_resolved(&self, location: &SourceLocation) -> bool {
419 self.dependencies
420 .contains_key(&(location.path.clone(), location.line, location.column))
421 }
422
423 pub(crate) fn dependency_edges(&self) -> Vec<SourceDependency> {
424 self.dependencies.values().cloned().collect()
425 }
426
427 pub fn new(root: &Path) -> Self {
429 Self::with_registrations(root, &[])
430 }
431
432 pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
437 Self::with_options(root, registrations, &[])
438 }
439
440 pub fn with_options(
446 root: &Path,
447 registrations: &[String],
448 assertion_modules: &[String],
449 ) -> Self {
450 let mut session = Self {
451 root: Some(root.to_owned()),
452 registrations: registrations.to_vec(),
453 assertion_modules: assertion_modules.to_vec(),
454 ..Self::default()
455 };
456 for registration in SOURCE_ADAPTER_REGISTRY {
458 if let Some(initialize) = registration.initialize_session {
459 initialize(&mut session);
460 }
461 }
462 session
463 }
464
465 pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
466 self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
467 Box::new(T::initialize(
468 self.root.as_deref(),
469 &self.registrations,
470 &self.assertion_modules,
471 ))
472 });
473 }
474
475 pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
476 if self.root.is_none() {
478 self.initialize::<T>();
479 }
480 self.states
481 .get_mut(&TypeId::of::<T>())
482 .and_then(|state| {
483 let state: &mut dyn Any = state.as_mut();
484 state.downcast_mut::<T>()
485 })
486 .with_context(|| {
487 format!(
488 "source adapter state {} is uninitialized or incompatible",
489 std::any::type_name::<T>()
490 )
491 })
492 }
493}
494
495pub trait SourceAdapter: Sync {
497 fn name(&self) -> &'static str;
499
500 fn language(&self) -> SourceLanguage;
502
503 fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
505
506 fn discover_by_default(&self) -> bool {
508 true
509 }
510
511 fn inspect_source(
513 &self,
514 _file: &SourceFile,
515 _registrations: &[String],
516 ) -> Option<SourceExclusion> {
517 None
518 }
519
520 fn supports_indirect_usage(&self) -> bool {
522 false
523 }
524
525 fn resolve_parameter_types(
527 &self,
528 declarations: Vec<SourceParameterType>,
529 configured: &std::collections::BTreeMap<String, String>,
530 ) -> (std::collections::BTreeMap<String, String>, Vec<String>) {
531 if declarations.is_empty() {
532 (configured.clone(), Vec::new())
533 } else {
534 (
535 Default::default(),
536 vec!["frontend does not support source parameter declarations".into()],
537 )
538 }
539 }
540
541 fn prepare_session(
543 &self,
544 _files: &[SourceFile],
545 _session: &mut SourceExtractionSession,
546 ) -> Result<()> {
547 Ok(())
548 }
549
550 fn finalize_session(
552 &self,
553 _session: &mut SourceExtractionSession,
554 ) -> Result<SourceFinalization> {
555 Ok(SourceFinalization::default())
556 }
557
558 fn extract_with_session(
560 &self,
561 source: &str,
562 file: &SourceFile,
563 _session: &mut SourceExtractionSession,
564 ) -> Result<Extraction> {
565 self.extract(source, file)
566 }
567
568 fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
570 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
571 self.extract(&source, file)
572 }
573
574 fn extract_file_with_session(
576 &self,
577 file: &SourceFile,
578 session: &mut SourceExtractionSession,
579 ) -> Result<Extraction> {
580 let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
581 self.extract_with_session(&source, file, session)
582 }
583}
584
585type DependencyDiscovery =
586 fn(&crate::config::Config, &globset::GlobSet, &mut crate::discovery::DiscoveredFiles);
587
588#[non_exhaustive]
590#[derive(Clone, Copy)]
591pub struct SourceAdapterRegistration {
592 pub suffix: &'static str,
594 pub adapter: &'static dyn SourceAdapter,
596 initialize_session: Option<fn(&mut SourceExtractionSession)>,
597 discover_dependencies: Option<DependencyDiscovery>,
598}
599
600impl SourceAdapterRegistration {
601 pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
603 Self {
604 suffix,
605 adapter,
606 initialize_session: None,
607 discover_dependencies: None,
608 }
609 }
610
611 const fn with_session<A: StatefulSourceAdapter>(
612 suffix: &'static str,
613 adapter: &'static A,
614 ) -> Self {
615 Self {
616 suffix,
617 adapter,
618 initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
619 discover_dependencies: Some(A::discover_dependencies),
620 }
621 }
622}
623
624macro_rules! register_source_adapters {
629 ( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
630 pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
632 $(
633 $(
634 SourceAdapterRegistration::with_session($suffix, &$adapter),
635 )+
636 )+
637 ];
638
639 pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
641 match language {
642 $(SourceLanguage::$language => &$adapter,)+
643 }
644 }
645 };
646}
647
648register_source_adapters! {
649 Ruby => crate::ruby::RUBY_ADAPTER : ".rb";
650 JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
651 TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
652 Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
653}
654
655pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
657 let extension = path.extension()?.to_str()?;
658 if extension == "map" || is_declaration_file(path, extension) {
659 return None;
660 }
661 SOURCE_ADAPTER_REGISTRY
662 .iter()
663 .find(|registration| extension == registration.suffix.trim_start_matches('.'))
664 .map(|registration| registration.adapter)
665}
666
667fn is_declaration_file(path: &Path, extension: &str) -> bool {
668 matches!(extension, "ts" | "mts" | "cts")
669 && path.file_stem().is_some_and(|stem| {
670 stem == ".d"
671 || Path::new(stem)
672 .extension()
673 .is_some_and(|extension| extension == "d")
674 })
675}
676
677pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
679 adapter_for_path(path).map(SourceAdapter::language)
680}
681
682pub fn registered_suffixes() -> Vec<&'static str> {
684 SOURCE_ADAPTER_REGISTRY
685 .iter()
686 .map(|registration| registration.suffix)
687 .collect()
688}
689
690#[cfg(test)]
691mod tests;
692
693pub(crate) fn discover_dependencies(
695 config: &crate::config::Config,
696 excludes: &globset::GlobSet,
697 files: &mut crate::discovery::DiscoveredFiles,
698) {
699 let mut completed = std::collections::BTreeSet::new();
700 let callbacks: Vec<_> = SOURCE_ADAPTER_REGISTRY
701 .iter()
702 .filter(|entry| {
703 files
704 .definitions
705 .iter()
706 .any(|file| file.language == entry.adapter.language())
707 })
708 .filter(|entry| completed.insert(entry.adapter.name()))
709 .filter_map(|entry| entry.discover_dependencies)
710 .collect();
711 for callback in callbacks {
712 callback(config, excludes, files);
713 }
714}