use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;
use fallow_config::{DuplicatesConfig, ResolvedConfig, WorkspaceInfo};
use fallow_types::discover::DiscoveredFile;
use fallow_types::extract::ModuleInfo;
use fallow_types::source_fingerprint::SourceFingerprint;
use fallow_types::workspace::WorkspaceDiagnostic;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{
EngineResult, core_backend, duplicates,
project_analysis::{
ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts, ProjectAnalysisOutput,
},
project_config::{ProjectConfig, config_for_project, default_project_config},
results::{
DeadCodeAnalysis, DeadCodeAnalysisArtifacts, DeadCodeAnalysisOutput, DuplicationAnalysis,
},
};
#[derive(Debug)]
pub struct AnalysisSession {
config: ResolvedConfig,
config_path: Option<PathBuf>,
discovery: crate::discover::AnalysisDiscovery,
workspaces: Vec<WorkspaceInfo>,
workspace_diagnostics: Vec<WorkspaceDiagnostic>,
parsed_cache: Mutex<Option<ParsedModuleCache>>,
styling_cache: Mutex<Option<crate::health::StylingAnalysisArtifacts>>,
}
#[derive(Debug)]
struct ParsedModuleCache {
need_complexity: bool,
fingerprints: Vec<SourceFingerprint>,
modules: Vec<ModuleInfo>,
}
#[derive(Debug)]
pub struct AnalysisSessionParts {
pub config: ResolvedConfig,
pub config_path: Option<PathBuf>,
pub files: Vec<DiscoveredFile>,
pub workspaces: Vec<WorkspaceInfo>,
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
}
#[derive(Debug)]
pub struct ParsedAnalysisSessionParts {
pub config: ResolvedConfig,
pub config_path: Option<PathBuf>,
pub files: Vec<DiscoveredFile>,
pub modules: Vec<ModuleInfo>,
pub workspaces: Vec<WorkspaceInfo>,
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
pub parse_ms: f64,
pub cache_update_ms: f64,
pub cache_hits: usize,
pub cache_misses: usize,
pub parse_cpu_ms: f64,
}
#[derive(Debug)]
pub struct AnalysisSessionArtifacts {
pub analysis: DeadCodeAnalysisArtifacts,
pub changed_files: Option<FxHashSet<PathBuf>>,
pub source_fingerprints: FxHashMap<PathBuf, SourceFingerprint>,
}
impl AnalysisSession {
pub fn load(root: &Path, config_path: Option<&Path>) -> EngineResult<Self> {
let project_config = config_for_project(root, config_path)?;
Ok(Self::from_config(project_config))
}
pub fn load_with_config(
root: &Path,
config_path: Option<&Path>,
configure: impl FnOnce(&mut ResolvedConfig),
) -> EngineResult<Self> {
let mut project_config = config_for_project(root, config_path)?;
configure(&mut project_config.config);
project_config.workspaces.clear();
project_config.workspace_diagnostics.clear();
project_config.workspace_discovery_ms = None;
Ok(Self::from_config(project_config))
}
#[must_use]
pub fn load_default(root: &Path) -> Self {
Self::from_config(default_project_config(root))
}
#[must_use]
pub fn from_config(project_config: ProjectConfig) -> Self {
let uses_preloaded_workspaces = project_config.workspace_discovery_ms.is_some();
let discovery = if let Some(workspace_discovery_ms) = project_config.workspace_discovery_ms
{
crate::discover::prepare_analysis_discovery_with_workspaces(
&project_config.config,
&project_config.workspaces,
workspace_discovery_ms,
)
} else {
crate::discover::prepare_analysis_discovery(&project_config.config)
};
let workspaces = if uses_preloaded_workspaces {
project_config.workspaces
} else {
discovery.workspaces().to_vec()
};
let workspace_diagnostics = merge_workspace_diagnostics(
project_config.workspace_diagnostics,
fallow_config::workspace_diagnostics_for(&project_config.config.root),
);
Self {
config: project_config.config,
config_path: project_config.path,
discovery,
workspaces,
workspace_diagnostics,
parsed_cache: Mutex::new(None),
styling_cache: Mutex::new(None),
}
}
#[must_use]
pub fn from_resolved_config(config: ResolvedConfig) -> Self {
Self::from_config(ProjectConfig {
config,
path: None,
workspaces: Vec::new(),
workspace_diagnostics: Vec::new(),
workspace_discovery_ms: None,
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.config.root
}
#[must_use]
pub fn config(&self) -> &ResolvedConfig {
&self.config
}
#[must_use]
pub fn config_path(&self) -> Option<&Path> {
self.config_path.as_deref()
}
#[must_use]
pub fn files(&self) -> &[DiscoveredFile] {
self.discovery.files()
}
#[must_use]
pub fn workspaces(&self) -> &[WorkspaceInfo] {
&self.workspaces
}
#[must_use]
pub fn source_fingerprints(&self) -> FxHashMap<PathBuf, SourceFingerprint> {
self.discovery
.files()
.iter()
.map(|file| {
let fingerprint = std::fs::metadata(&file.path).map_or_else(
|_| SourceFingerprint::new(0, file.size_bytes),
|metadata| SourceFingerprint::from_metadata(&metadata),
);
(file.path.clone(), fingerprint)
})
.collect()
}
pub fn changed_files_since(
&self,
git_ref: &str,
) -> Result<FxHashSet<PathBuf>, crate::changed_files::ChangedFilesError> {
crate::changed_files::changed_files(&self.config.root, git_ref)
}
#[must_use]
pub fn workspace_diagnostics(&self) -> &[WorkspaceDiagnostic] {
&self.workspace_diagnostics
}
pub(crate) fn styling_analysis_artifacts(&self) -> crate::health::StylingAnalysisArtifacts {
if let Ok(cache) = self.styling_cache.lock()
&& let Some(artifacts) = cache.as_ref()
{
return artifacts.clone();
}
let artifacts =
crate::health::build_styling_analysis_artifacts(self.files(), self.config());
if let Ok(mut cache) = self.styling_cache.lock() {
*cache = Some(artifacts.clone());
}
artifacts
}
#[must_use]
pub fn into_parts(self) -> AnalysisSessionParts {
AnalysisSessionParts {
config: self.config,
config_path: self.config_path,
files: self.discovery.into_files(),
workspaces: self.workspaces,
workspace_diagnostics: self.workspace_diagnostics,
}
}
#[must_use]
pub fn into_parsed_parts(self, need_complexity: bool) -> ParsedAnalysisSessionParts {
let AnalysisSessionParts {
config,
config_path,
files,
workspaces,
workspace_diagnostics,
} = self.into_parts();
let ParsedModules { modules, metrics } =
parse_files_with_config(&config, &files, need_complexity);
ParsedAnalysisSessionParts {
config,
config_path,
files,
modules,
workspaces,
workspace_diagnostics,
parse_ms: metrics.parse_ms,
cache_update_ms: metrics.cache_ms,
cache_hits: metrics.cache_hits,
cache_misses: metrics.cache_misses,
parse_cpu_ms: metrics.parse_cpu_ms,
}
}
#[must_use]
pub fn parsed_parts(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
let ParsedModules { modules, metrics } = self.parse_modules(need_complexity);
self.parsed_parts_from_modules(modules, metrics)
}
#[must_use]
pub fn parsed_parts_uncached(&self, need_complexity: bool) -> ParsedAnalysisSessionParts {
let ParsedModules { modules, metrics } =
parse_files_with_config(&self.config, self.files(), need_complexity);
self.parsed_parts_from_modules(modules, metrics)
}
fn parsed_parts_from_modules(
&self,
modules: Vec<ModuleInfo>,
metrics: core_backend::ParseMetrics,
) -> ParsedAnalysisSessionParts {
ParsedAnalysisSessionParts {
config: self.config.clone(),
config_path: self.config_path.clone(),
files: self.discovery.files().to_vec(),
modules,
workspaces: self.workspaces.clone(),
workspace_diagnostics: self.workspace_diagnostics.clone(),
parse_ms: metrics.parse_ms,
cache_update_ms: metrics.cache_ms,
cache_hits: metrics.cache_hits,
cache_misses: metrics.cache_misses,
parse_cpu_ms: metrics.parse_cpu_ms,
}
}
pub fn analyze_dead_code(&self) -> EngineResult<DeadCodeAnalysis> {
self.analyze_dead_code_with_artifacts(false, false)
.map(|output| DeadCodeAnalysis {
results: output.results,
})
}
pub fn analyze_dead_code_with_complexity(&self) -> EngineResult<DeadCodeAnalysisOutput> {
self.analyze_dead_code_with_artifacts(true, false)
.map(|output| DeadCodeAnalysisOutput {
results: output.results,
modules: output.modules,
files: output.files,
})
}
pub fn analyze_dead_code_with_artifacts(
&self,
need_complexity: bool,
retain_graph: bool,
) -> EngineResult<DeadCodeAnalysisArtifacts> {
self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, need_complexity)
}
pub fn analyze_dead_code_retaining_files(
&self,
need_complexity: bool,
retain_graph: bool,
) -> EngineResult<DeadCodeAnalysisArtifacts> {
self.analyze_dead_code_with_reuse_artifacts(need_complexity, retain_graph, true)
}
pub fn analyze_dead_code_with_parsed_modules(
&self,
modules: &[ModuleInfo],
) -> EngineResult<DeadCodeAnalysisArtifacts> {
run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
config: &self.config,
discovery: &self.discovery,
modules: modules.to_vec(),
metrics: reused_parse_metrics(),
collect_usages: true,
retain_graph: true,
retain_modules: false,
retain_files: false,
})
}
fn analyze_dead_code_with_reuse_artifacts(
&self,
need_complexity: bool,
retain_graph: bool,
retain_files: bool,
) -> EngineResult<DeadCodeAnalysisArtifacts> {
let ParsedModules { modules, metrics } = self.parse_modules(need_complexity);
run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
config: &self.config,
discovery: &self.discovery,
modules,
metrics,
collect_usages: true,
retain_graph,
retain_modules: need_complexity,
retain_files,
})
}
pub fn analyze_dead_code_with_session_artifacts(
&self,
need_complexity: bool,
retain_graph: bool,
changed_files: Option<FxHashSet<PathBuf>>,
) -> EngineResult<AnalysisSessionArtifacts> {
Ok(AnalysisSessionArtifacts {
analysis: self.analyze_dead_code_with_artifacts(need_complexity, retain_graph)?,
changed_files,
source_fingerprints: self.source_fingerprints(),
})
}
#[must_use]
pub fn find_duplicates(&self) -> duplicates::DuplicationReport {
duplicates::find_duplicates(&self.config.root, self.files(), &self.config.duplicates)
}
#[must_use]
pub fn find_duplicates_with(&self, config: &DuplicatesConfig) -> duplicates::DuplicationReport {
duplicates::find_duplicates(&self.config.root, self.files(), config)
}
pub fn analyze_project_with(
&self,
duplicates_config: &DuplicatesConfig,
retain_complexity_artifacts: bool,
) -> EngineResult<ProjectAnalysisOutput> {
self.analyze_project_with_artifacts(
duplicates_config,
ProjectAnalysisArtifactOptions {
retain_complexity_artifacts,
..ProjectAnalysisArtifactOptions::default()
},
)
.map(ProjectAnalysisArtifacts::into_output)
}
pub fn analyze_project_with_artifacts(
&self,
duplicates_config: &DuplicatesConfig,
options: ProjectAnalysisArtifactOptions,
) -> EngineResult<ProjectAnalysisArtifacts> {
let cache_dir = (!self.config.no_cache).then_some(self.config.cache_dir.as_path());
let duplication = if let Some(changed_files) = options.changed_files.as_ref() {
let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
self.find_duplicates_touching_files_with_defaults(
duplicates_config,
&changed_files,
cache_dir,
)
.report
} else {
self.find_duplicates_with_defaults(duplicates_config, cache_dir)
.report
};
let source_fingerprints = options
.collect_source_fingerprints
.then(|| self.source_fingerprints());
Ok(ProjectAnalysisArtifacts {
dead_code: self.analyze_dead_code_with_artifacts(
options.retain_complexity_artifacts,
options.retain_graph,
)?,
duplication,
changed_files: options.changed_files,
source_fingerprints,
})
}
#[must_use]
pub fn find_duplicates_with_defaults(
&self,
config: &DuplicatesConfig,
cache_dir: Option<&Path>,
) -> DuplicationAnalysis {
duplicates::find_duplicates_with_defaults(
&self.config.root,
self.files(),
config,
cache_dir,
)
}
#[must_use]
pub fn find_duplicates_touching_files_with_defaults(
&self,
config: &DuplicatesConfig,
changed_files: &[PathBuf],
cache_dir: Option<&Path>,
) -> DuplicationAnalysis {
duplicates::find_duplicates_touching_files_with_defaults(
&self.config.root,
self.files(),
config,
changed_files,
cache_dir,
)
}
fn parse_modules(&self, need_complexity: bool) -> ParsedModules {
let fingerprints = source_fingerprints_for_files(self.files());
if let Some(fingerprints) = fingerprints.as_ref()
&& let Some(modules) = self.cached_modules(need_complexity, fingerprints)
{
return ParsedModules {
modules,
metrics: core_backend::ParseMetrics {
parse_ms: 0.0,
cache_ms: 0.0,
cache_hits: 0,
cache_misses: 0,
parse_cpu_ms: 0.0,
},
};
}
let parsed = parse_files_with_config(&self.config, self.files(), need_complexity);
if let Some(fingerprints) = fingerprints
&& let Ok(mut cache) = self.parsed_cache.lock()
{
*cache = Some(ParsedModuleCache {
need_complexity,
fingerprints,
modules: parsed.modules.clone(),
});
}
parsed
}
fn cached_modules(
&self,
need_complexity: bool,
fingerprints: &[SourceFingerprint],
) -> Option<Vec<ModuleInfo>> {
let Ok(cache) = self.parsed_cache.lock() else {
return None;
};
let cache = cache.as_ref()?;
let complexity_mode_satisfies_request = cache.need_complexity || !need_complexity;
if complexity_mode_satisfies_request && cache.fingerprints == fingerprints {
return Some(cache.modules.clone());
}
None
}
}
fn merge_workspace_diagnostics(
primary: Vec<WorkspaceDiagnostic>,
secondary: Vec<WorkspaceDiagnostic>,
) -> Vec<WorkspaceDiagnostic> {
let mut merged = Vec::with_capacity(primary.len() + secondary.len());
let mut seen: FxHashSet<(String, PathBuf)> = FxHashSet::default();
for diagnostic in primary.into_iter().chain(secondary) {
let key = (diagnostic.kind.id().to_owned(), diagnostic.path.clone());
if seen.insert(key) {
merged.push(diagnostic);
}
}
merged
}
struct ParsedModules {
modules: Vec<ModuleInfo>,
metrics: core_backend::ParseMetrics,
}
fn parse_files_with_config(
config: &ResolvedConfig,
files: &[DiscoveredFile],
need_complexity: bool,
) -> ParsedModules {
let parse_start = Instant::now();
let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
let mut cache = if config.no_cache {
None
} else {
fallow_extract::cache::CacheStore::load(
&config.cache_dir,
config.cache_config_hash,
cache_max_size_bytes,
)
};
let parse_result = crate::source::parse_all_files(files, cache.as_ref(), need_complexity);
let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
let cache_ms = update_parse_cache_if_enabled(config, &mut cache, &parse_result.modules, files);
let metrics = core_backend::ParseMetrics {
parse_ms,
cache_ms,
cache_hits: parse_result.cache_hits,
cache_misses: parse_result.cache_misses,
parse_cpu_ms: parse_result.parse_cpu_ms,
};
ParsedModules {
modules: parse_result.modules,
metrics,
}
}
fn reused_parse_metrics() -> core_backend::ParseMetrics {
core_backend::ParseMetrics {
parse_ms: 0.0,
cache_ms: 0.0,
cache_hits: 0,
cache_misses: 0,
parse_cpu_ms: 0.0,
}
}
fn source_fingerprints_for_files(files: &[DiscoveredFile]) -> Option<Vec<SourceFingerprint>> {
files
.iter()
.map(|file| {
std::fs::metadata(&file.path)
.ok()
.map(|metadata| SourceFingerprint::from_metadata(&metadata))
.filter(|fingerprint| fingerprint.has_known_mtime())
})
.collect()
}
fn update_parse_cache_if_enabled(
config: &ResolvedConfig,
cache: &mut Option<fallow_extract::cache::CacheStore>,
modules: &[ModuleInfo],
files: &[DiscoveredFile],
) -> f64 {
let start = Instant::now();
if config.no_cache {
return start.elapsed().as_secs_f64() * 1000.0;
}
let cache_max_size_bytes = crate::project_config::resolve_cache_max_size_bytes(config);
let store = cache.get_or_insert_with(fallow_extract::cache::CacheStore::new);
if update_parse_cache(store, modules, files)
&& let Err(error) = store.save(
&config.cache_dir,
config.cache_config_hash,
cache_max_size_bytes,
)
{
tracing::warn!("Failed to save cache: {error}");
}
start.elapsed().as_secs_f64() * 1000.0
}
fn update_parse_cache(
store: &mut fallow_extract::cache::CacheStore,
modules: &[ModuleInfo],
files: &[DiscoveredFile],
) -> bool {
let mut dirty = false;
for module in modules {
if let Some(file) = files.get(module.file_id.0 as usize) {
let fingerprint = source_fingerprint(&file.path);
if let Some(cached) = store.get_by_path_only(&file.path)
&& cached.content_hash == module.content_hash
{
if cached.source_fingerprint() != fingerprint {
let preserved_last_access = cached.last_access_secs;
let mut refreshed =
fallow_extract::cache::module_to_cached(module, fingerprint);
refreshed.last_access_secs = preserved_last_access;
store.insert(&file.path, refreshed);
dirty = true;
}
continue;
}
store.insert(
&file.path,
fallow_extract::cache::module_to_cached(module, fingerprint),
);
dirty = true;
}
}
store.retain_paths(files) || dirty
}
fn source_fingerprint(path: &Path) -> SourceFingerprint {
std::fs::metadata(path).map_or_else(
|_| SourceFingerprint::new(0, 0),
|metadata| SourceFingerprint::from_metadata(&metadata),
)
}
struct EngineDeadCodePipelineInput<'a> {
config: &'a ResolvedConfig,
discovery: &'a crate::discover::AnalysisDiscovery,
modules: Vec<ModuleInfo>,
metrics: core_backend::ParseMetrics,
collect_usages: bool,
retain_graph: bool,
retain_modules: bool,
retain_files: bool,
}
fn run_engine_owned_dead_code_pipeline(
input: EngineDeadCodePipelineInput<'_>,
) -> EngineResult<DeadCodeAnalysisArtifacts> {
let EngineDeadCodePipelineInput {
config,
discovery,
mut modules,
metrics,
collect_usages,
retain_graph,
retain_modules,
retain_files,
} = input;
let prelude = core_backend::prepare_dead_code_backend_prelude(config, discovery)?;
let prelude_timings = prelude.timings();
let entry_points = core_backend::discover_dead_code_entry_points(&prelude);
let (resolved, graph) = resolve_or_build_dead_code_graph(&prelude, &entry_points, &modules);
for module in &mut modules {
module.release_resolution_payload();
}
let detector = core_backend::run_dead_code_detectors(
&prelude,
&graph.graph,
&resolved.resolved,
&modules,
collect_usages,
&entry_points,
);
let profile =
core_backend::dead_code_pipeline_profile(core_backend::DeadCodePipelineProfileInput {
retain_timings: retain_graph,
prelude: &prelude,
prelude_timings,
parse_metrics: metrics,
module_count: modules.len(),
entry_points: &entry_points,
resolved: &resolved,
graph: &graph,
detector: &detector,
file_count: discovery.files().len(),
workspace_count: discovery.workspaces().len(),
});
let script_used_packages = prelude.script_used_packages();
prelude.finish();
let file_hashes = collect_file_hashes(&modules, discovery.files());
Ok(DeadCodeAnalysisArtifacts {
results: detector.results,
timings: profile.timings,
graph: retain_graph.then_some(graph.graph),
modules: retain_modules.then_some(modules),
files: retain_files.then(|| discovery.files().to_vec()),
script_used_packages,
file_hashes,
})
}
fn resolve_or_build_dead_code_graph(
prelude: &core_backend::DeadCodeBackendPrelude,
entry_points: &core_backend::DeadCodeEntryPoints,
modules: &[ModuleInfo],
) -> (
core_backend::DeadCodeResolvedModules,
core_backend::DeadCodeGraphRun,
) {
if let Some((resolved, graph)) =
core_backend::try_load_dead_code_graph_cache(prelude, entry_points, modules)
{
return (resolved, graph);
}
let resolved = core_backend::resolve_dead_code_imports(prelude, modules);
let graph =
core_backend::build_dead_code_graph(prelude, &resolved.resolved, entry_points, modules);
(resolved, graph)
}
fn collect_file_hashes(
modules: &[ModuleInfo],
files: &[DiscoveredFile],
) -> FxHashMap<PathBuf, u64> {
modules
.iter()
.filter_map(|module| {
files
.get(module.file_id.0 as usize)
.map(|file| (file.path.clone(), module.content_hash))
})
.collect()
}
pub(crate) fn analyze_dead_code_with_parse_result_from_config(
config: &ResolvedConfig,
modules: &[ModuleInfo],
) -> EngineResult<DeadCodeAnalysisArtifacts> {
let discovery = crate::discover::prepare_analysis_discovery(config);
run_engine_owned_dead_code_pipeline(EngineDeadCodePipelineInput {
config,
discovery: &discovery,
modules: modules.to_vec(),
metrics: reused_parse_metrics(),
collect_usages: true,
retain_graph: true,
retain_modules: false,
retain_files: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_retains_workspace_metadata_from_config_load() {
let project = tempfile::tempdir().expect("project");
let root = project.path();
std::fs::write(
root.join("package.json"),
r#"{"name":"root","workspaces":["packages/*"]}"#,
)
.expect("write root package");
std::fs::create_dir_all(root.join("packages/a")).expect("create workspace");
std::fs::write(
root.join("packages/a/package.json"),
r#"{"name":"pkg-a","type":"module"}"#,
)
.expect("write workspace package");
let session = AnalysisSession::load(root, None).expect("session loads");
assert!(
session
.workspaces()
.iter()
.any(|workspace| workspace.name == "pkg-a"),
"session must retain workspace metadata discovered during config load"
);
}
}