mod config;
mod finalization;
pub(crate) use config::FrontendConfig;
pub(crate) mod semantics;
pub(crate) mod suppression;
pub use finalization::{
SourceAdvisory, SourceFinalization, SourceUncertainty, UncertaintyCause, UncertaintyScope,
};
use crate::model::{SourceLocation, StepDefinition};
use crate::resource_limits::{read_utf8, MAX_PROJECT_INPUT_BYTES};
use anyhow::{Context, Result};
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::panic::{RefUnwindSafe, UnwindSafe};
use std::path::{Path, PathBuf};
pub(crate) const UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX: &str =
"unresolved step-registration calls:";
pub(crate) const UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX: &str =
"source contains JavaScript/TypeScript syntax errors";
pub(crate) const UNSUPPORTED_MATCHER_DIAGNOSTIC: &str =
"dynamic or unsupported step matcher cannot be analyzed statically";
pub(crate) const INVALID_MATCHER_DIAGNOSTIC: &str =
"invalid JavaScript escape sequence in step matcher";
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceLanguage {
Ruby,
JavaScript,
TypeScript,
Tsx,
}
pub(crate) fn grammar_for_language(language: SourceLanguage) -> tree_sitter::Language {
match language {
SourceLanguage::Ruby => tree_sitter_ruby::LANGUAGE.into(),
SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceExclusion {
Compressed,
Binary,
Minified,
}
impl SourceExclusion {
pub fn as_str(self) -> &'static str {
match self {
Self::Compressed => "compressed",
Self::Binary => "binary",
Self::Minified => "minified",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceFile {
pub path: PathBuf,
pub language: SourceLanguage,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractionDiagnosticLevel {
Warning,
Error,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractionDiagnosticKind {
LegacyMessage,
Dependency,
Other,
Incomplete,
Unparseable,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionDiagnostic {
pub kind: ExtractionDiagnosticKind,
pub level: ExtractionDiagnosticLevel,
pub location: SourceLocation,
pub message: String,
}
impl ExtractionDiagnostic {
pub fn new(
level: ExtractionDiagnosticLevel,
location: SourceLocation,
message: impl Into<String>,
) -> Self {
Self::with_kind(
ExtractionDiagnosticKind::LegacyMessage,
level,
location,
message,
)
}
pub fn with_kind(
kind: ExtractionDiagnosticKind,
level: ExtractionDiagnosticLevel,
location: SourceLocation,
message: impl Into<String>,
) -> Self {
Self {
kind,
level,
location,
message: message.into(),
}
}
}
pub(crate) fn is_completeness_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
match diagnostic.kind {
ExtractionDiagnosticKind::Incomplete
| ExtractionDiagnosticKind::Unparseable
| ExtractionDiagnosticKind::Dependency => true,
ExtractionDiagnosticKind::Other => false,
ExtractionDiagnosticKind::LegacyMessage => {
diagnostic
.message
.starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)
|| matches!(
diagnostic.message.as_str(),
UNSUPPORTED_MATCHER_DIAGNOSTIC | INVALID_MATCHER_DIAGNOSTIC
)
|| is_unparseable_diagnostic(diagnostic)
}
}
}
pub(crate) fn is_unparseable_diagnostic(diagnostic: &ExtractionDiagnostic) -> bool {
match diagnostic.kind {
ExtractionDiagnosticKind::Unparseable => true,
ExtractionDiagnosticKind::LegacyMessage => diagnostic
.message
.starts_with(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX),
ExtractionDiagnosticKind::Other
| ExtractionDiagnosticKind::Incomplete
| ExtractionDiagnosticKind::Dependency => false,
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceDependency {
pub location: SourceLocation,
pub target: PathBuf,
pub dependency_only: bool,
}
impl SourceDependency {
pub fn with_dependency_only_target(mut self, dependency_only: bool) -> Self {
self.dependency_only = dependency_only;
self
}
pub fn new(location: SourceLocation, target: PathBuf) -> Self {
Self {
location,
target,
dependency_only: false,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyGap {
pub location: SourceLocation,
pub reason: String,
}
impl DependencyGap {
pub fn new(location: SourceLocation, reason: impl Into<String>) -> Self {
Self {
location,
reason: reason.into(),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IndirectStepUsage {
pub texts: std::collections::BTreeSet<String>,
pub unknown: bool,
}
impl IndirectStepUsage {
pub fn extend(&mut self, other: Option<Self>) {
match other {
Some(other) => {
self.texts.extend(other.texts);
self.unknown |= other.unknown;
}
None => self.unknown = true,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceParameterType {
pub name: Option<String>,
pub expression: Option<String>,
pub location: SourceLocation,
}
impl SourceParameterType {
pub fn new(name: Option<String>, expression: Option<String>, location: SourceLocation) -> Self {
Self {
name,
expression,
location,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Extraction {
pub definitions: Vec<StepDefinition>,
pub parameter_types: Vec<SourceParameterType>,
pub indirect_usage: Option<IndirectStepUsage>,
pub diagnostics: Vec<ExtractionDiagnostic>,
}
impl Extraction {
pub fn new(definitions: Vec<StepDefinition>, diagnostics: Vec<ExtractionDiagnostic>) -> Self {
Self {
definitions,
diagnostics,
indirect_usage: None,
parameter_types: Vec::new(),
}
}
}
#[derive(Default)]
pub struct SourceExtractionSession {
dependencies: std::collections::BTreeMap<(PathBuf, usize, usize), SourceDependency>,
dependency_gaps: std::collections::BTreeMap<(PathBuf, usize, usize), String>,
root: Option<PathBuf>,
registrations: Vec<String>,
assertion_modules: Vec<String>,
states: HashMap<TypeId, Box<dyn Any + Send + Sync + UnwindSafe + RefUnwindSafe>>,
}
pub(crate) trait AdapterSessionState:
Any + Send + Sync + UnwindSafe + RefUnwindSafe
{
fn initialize(
root: Option<&Path>,
registrations: &[String],
assertion_modules: &[String],
) -> Self;
}
pub(crate) trait StatefulSourceAdapter: SourceAdapter {
type State: AdapterSessionState;
fn discover_dependencies(
_config: &crate::config::Config,
_excludes: &globset::GlobSet,
_files: &mut crate::discovery::DiscoveredFiles,
) {
}
}
impl SourceExtractionSession {
pub fn with_dependencies(mut self, dependencies: &[SourceDependency]) -> Self {
self.dependencies.extend(dependencies.iter().map(|edge| {
(
(
edge.location.path.clone(),
edge.location.line,
edge.location.column,
),
edge.clone(),
)
}));
self
}
pub fn finalize(
&mut self,
files: &[SourceFile],
definitions: &mut Vec<StepDefinition>,
) -> Result<SourceFinalization> {
let mut result = SourceFinalization::default();
let mut completed = std::collections::BTreeSet::new();
for file in files {
let adapter = adapter_for_language(file.language);
if completed.insert(adapter.name()) {
let finalization = adapter.finalize_session(self)?;
result.uncertainties.extend(finalization.uncertainties);
result.advisories.extend(finalization.advisories);
}
}
result.apply(definitions);
Ok(result)
}
pub fn with_dependency_gaps(mut self, gaps: &[DependencyGap]) -> Self {
self.dependency_gaps.extend(gaps.iter().map(|gap| {
(
(
gap.location.path.clone(),
gap.location.line,
gap.location.column,
),
gap.reason.clone(),
)
}));
self
}
pub(crate) fn dependency_gap(&self, location: &SourceLocation) -> Option<&str> {
self.dependency_gaps
.get(&(location.path.clone(), location.line, location.column))
.map(String::as_str)
}
pub(crate) fn dependency_resolved(&self, location: &SourceLocation) -> bool {
self.dependencies
.contains_key(&(location.path.clone(), location.line, location.column))
}
pub(crate) fn dependency_edges(&self) -> Vec<SourceDependency> {
self.dependencies.values().cloned().collect()
}
pub fn new(root: &Path) -> Self {
Self::with_registrations(root, &[])
}
pub fn with_registrations(root: &Path, registrations: &[String]) -> Self {
Self::with_options(root, registrations, &[])
}
pub fn with_options(
root: &Path,
registrations: &[String],
assertion_modules: &[String],
) -> Self {
let mut session = Self {
root: Some(root.to_owned()),
registrations: registrations.to_vec(),
assertion_modules: assertion_modules.to_vec(),
..Self::default()
};
for registration in SOURCE_ADAPTER_REGISTRY {
if let Some(initialize) = registration.initialize_session {
initialize(&mut session);
}
}
session
}
pub(crate) fn initialize<T: AdapterSessionState>(&mut self) {
self.states.entry(TypeId::of::<T>()).or_insert_with(|| {
Box::new(T::initialize(
self.root.as_deref(),
&self.registrations,
&self.assertion_modules,
))
});
}
pub(crate) fn state<T: AdapterSessionState>(&mut self) -> Result<&mut T> {
if self.root.is_none() {
self.initialize::<T>();
}
self.states
.get_mut(&TypeId::of::<T>())
.and_then(|state| {
let state: &mut dyn Any = state.as_mut();
state.downcast_mut::<T>()
})
.with_context(|| {
format!(
"source adapter state {} is uninitialized or incompatible",
std::any::type_name::<T>()
)
})
}
}
pub trait SourceAdapter: Sync {
fn name(&self) -> &'static str;
fn language(&self) -> SourceLanguage;
fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction>;
fn discover_by_default(&self) -> bool {
true
}
fn inspect_source(
&self,
_file: &SourceFile,
_registrations: &[String],
) -> Option<SourceExclusion> {
None
}
fn supports_indirect_usage(&self) -> bool {
false
}
fn resolve_parameter_types(
&self,
declarations: Vec<SourceParameterType>,
configured: &std::collections::BTreeMap<String, String>,
) -> (std::collections::BTreeMap<String, String>, Vec<String>) {
if declarations.is_empty() {
(configured.clone(), Vec::new())
} else {
(
Default::default(),
vec!["frontend does not support source parameter declarations".into()],
)
}
}
fn prepare_session(
&self,
_files: &[SourceFile],
_session: &mut SourceExtractionSession,
) -> Result<()> {
Ok(())
}
fn finalize_session(
&self,
_session: &mut SourceExtractionSession,
) -> Result<SourceFinalization> {
Ok(SourceFinalization::default())
}
fn extract_with_session(
&self,
source: &str,
file: &SourceFile,
_session: &mut SourceExtractionSession,
) -> Result<Extraction> {
self.extract(source, file)
}
fn extract_file(&self, file: &SourceFile) -> Result<Extraction> {
let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
self.extract(&source, file)
}
fn extract_file_with_session(
&self,
file: &SourceFile,
session: &mut SourceExtractionSession,
) -> Result<Extraction> {
let source = read_utf8(&file.path, "definition source", MAX_PROJECT_INPUT_BYTES)?;
self.extract_with_session(&source, file, session)
}
}
type DependencyDiscovery =
fn(&crate::config::Config, &globset::GlobSet, &mut crate::discovery::DiscoveredFiles);
#[non_exhaustive]
#[derive(Clone, Copy)]
pub struct SourceAdapterRegistration {
pub suffix: &'static str,
pub adapter: &'static dyn SourceAdapter,
initialize_session: Option<fn(&mut SourceExtractionSession)>,
discover_dependencies: Option<DependencyDiscovery>,
}
impl SourceAdapterRegistration {
pub const fn new(suffix: &'static str, adapter: &'static dyn SourceAdapter) -> Self {
Self {
suffix,
adapter,
initialize_session: None,
discover_dependencies: None,
}
}
const fn with_session<A: StatefulSourceAdapter>(
suffix: &'static str,
adapter: &'static A,
) -> Self {
Self {
suffix,
adapter,
initialize_session: Some(SourceExtractionSession::initialize::<A::State>),
discover_dependencies: Some(A::discover_dependencies),
}
}
}
macro_rules! register_source_adapters {
( $( $language:ident => $adapter:path : $($suffix:literal),+ );+ ; ) => {
pub static SOURCE_ADAPTER_REGISTRY: &[SourceAdapterRegistration] = &[
$(
$(
SourceAdapterRegistration::with_session($suffix, &$adapter),
)+
)+
];
pub fn adapter_for_language(language: SourceLanguage) -> &'static dyn SourceAdapter {
match language {
$(SourceLanguage::$language => &$adapter,)+
}
}
};
}
register_source_adapters! {
Ruby => crate::ruby::RUBY_ADAPTER : ".rb";
JavaScript => crate::typescript::JAVASCRIPT_ADAPTER : ".mjs", ".cjs", ".jsx", ".js";
TypeScript => crate::typescript::TYPESCRIPT_ADAPTER : ".mts", ".cts", ".ts";
Tsx => crate::typescript::TSX_ADAPTER : ".tsx";
}
pub fn adapter_for_path(path: &Path) -> Option<&'static dyn SourceAdapter> {
let extension = path.extension()?.to_str()?;
if extension == "map" || is_declaration_file(path, extension) {
return None;
}
SOURCE_ADAPTER_REGISTRY
.iter()
.find(|registration| extension == registration.suffix.trim_start_matches('.'))
.map(|registration| registration.adapter)
}
fn is_declaration_file(path: &Path, extension: &str) -> bool {
matches!(extension, "ts" | "mts" | "cts")
&& path.file_stem().is_some_and(|stem| {
stem == ".d"
|| Path::new(stem)
.extension()
.is_some_and(|extension| extension == "d")
})
}
pub fn language_for_path(path: &Path) -> Option<SourceLanguage> {
adapter_for_path(path).map(SourceAdapter::language)
}
pub fn registered_suffixes() -> Vec<&'static str> {
SOURCE_ADAPTER_REGISTRY
.iter()
.map(|registration| registration.suffix)
.collect()
}
#[cfg(test)]
mod tests;
pub(crate) fn discover_dependencies(
config: &crate::config::Config,
excludes: &globset::GlobSet,
files: &mut crate::discovery::DiscoveredFiles,
) {
let mut completed = std::collections::BTreeSet::new();
let callbacks: Vec<_> = SOURCE_ADAPTER_REGISTRY
.iter()
.filter(|entry| {
files
.definitions
.iter()
.any(|file| file.language == entry.adapter.language())
})
.filter(|entry| completed.insert(entry.adapter.name()))
.filter_map(|entry| entry.discover_dependencies)
.collect();
for callback in callbacks {
callback(config, excludes, files);
}
}