use std::path::{Path, PathBuf};
use rustc_hash::FxHashSet;
pub use fallow_engine::changed_files::{
ChangedFilesError, resolve_git_toplevel, try_get_changed_files_with_toplevel,
};
pub mod editor_duplicates {
pub use fallow_engine::duplicates::*;
}
pub mod editor_extract {
pub use fallow_types::extract::*;
}
pub mod editor_results {
pub use fallow_types::output_dead_code::{
BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
DynamicSegmentNameConflictFinding, EmptyCatalogGroupFinding, InvalidClientExportFinding,
MisconfiguredDependencyOverrideFinding, MisplacedDirectiveFinding,
MixedClientServerBarrelFinding, PolicyViolationFinding, PrivateTypeLeakFinding,
ReExportCycleFinding, RouteCollisionFinding, TestOnlyDependencyFinding,
TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
UnusedSvelteEventFinding, UnusedTypeFinding,
};
pub use fallow_types::results::*;
}
pub mod editor_security {
pub use fallow_engine::security::security_catalogue_title;
}
pub mod editor_suppress {
pub use fallow_engine::suppress::{IssueKind, is_suppressed};
}
pub type EditorAnalysisResults = fallow_types::results::AnalysisResults;
pub type EditorDeadCodeAnalysisOutput = fallow_engine::DeadCodeAnalysisOutput;
pub type EditorDuplicationReport = fallow_engine::DuplicationReport;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorInlineComplexityFinding {
pub path: PathBuf,
pub name: String,
pub line: u32,
pub col: u32,
pub cyclomatic: u16,
pub cognitive: u16,
pub exceeded: EditorInlineComplexityExceeded,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorInlineComplexityExceeded {
Cyclomatic,
Cognitive,
CyclomaticAndCognitive,
}
#[must_use]
pub fn collect_inline_complexity(
config: &fallow_config::ResolvedConfig,
output: &EditorDeadCodeAnalysisOutput,
) -> Vec<EditorInlineComplexityFinding> {
let Some(modules) = output.modules.as_ref() else {
return Vec::new();
};
let Some(files) = output.files.as_ref() else {
return Vec::new();
};
let file_paths: rustc_hash::FxHashMap<_, _> =
files.iter().map(|file| (file.id, &file.path)).collect();
let ignore_set = build_health_ignore_set(&config.health.ignore);
let mut findings = Vec::new();
for module in modules {
let Some(path) = file_paths.get(&module.file_id) else {
continue;
};
let relative = path.strip_prefix(&config.root).unwrap_or(path);
if ignore_set
.as_ref()
.is_some_and(|set| set.is_match(relative))
{
continue;
}
for function in &module.complexity {
if fallow_engine::suppress::is_suppressed(
&module.suppressions,
function.line,
fallow_engine::suppress::IssueKind::Complexity,
) {
continue;
}
let exceeds_cyclomatic = function.cyclomatic > config.health.max_cyclomatic;
let exceeds_cognitive = function.cognitive > config.health.max_cognitive;
let exceeded = match (exceeds_cyclomatic, exceeds_cognitive) {
(true, true) => EditorInlineComplexityExceeded::CyclomaticAndCognitive,
(true, false) => EditorInlineComplexityExceeded::Cyclomatic,
(false, true) => EditorInlineComplexityExceeded::Cognitive,
(false, false) => continue,
};
findings.push(EditorInlineComplexityFinding {
path: (*path).clone(),
name: function.name.clone(),
line: function.line,
col: function.col,
cyclomatic: function.cyclomatic,
cognitive: function.cognitive,
exceeded,
});
}
}
findings
}
#[allow(
clippy::implicit_hasher,
reason = "editor analysis changed-file sets use the workspace FxHashSet convention"
)]
pub fn filter_inline_complexity_by_changed_files(
findings: &mut Vec<EditorInlineComplexityFinding>,
changed_files: &FxHashSet<PathBuf>,
) {
findings.retain(|finding| changed_files.contains(&finding.path));
}
fn build_health_ignore_set(patterns: &[String]) -> Option<globset::GlobSet> {
if patterns.is_empty() {
return None;
}
let mut builder = globset::GlobSetBuilder::new();
for pattern in patterns {
let Ok(glob) = globset::Glob::new(pattern) else {
continue;
};
builder.add(glob);
}
builder.build().ok()
}
#[derive(Debug)]
pub struct EditorAnalysisSession {
inner: fallow_engine::AnalysisSession,
}
impl EditorAnalysisSession {
pub fn load(root: &Path, config_path: Option<&Path>) -> fallow_engine::EngineResult<Self> {
fallow_engine::AnalysisSession::load(root, config_path).map(Self::from_engine)
}
pub fn load_with_config(
root: &Path,
config_path: Option<&Path>,
configure: impl FnOnce(&mut fallow_config::ResolvedConfig),
) -> fallow_engine::EngineResult<Self> {
fallow_engine::AnalysisSession::load_with_config(root, config_path, configure)
.map(Self::from_engine)
}
#[must_use]
pub fn load_default(root: &Path) -> Self {
Self::from_engine(fallow_engine::AnalysisSession::load_default(root))
}
#[must_use]
pub fn config(&self) -> &fallow_config::ResolvedConfig {
self.inner.config()
}
#[must_use]
pub fn config_path(&self) -> Option<&Path> {
self.inner.config_path()
}
pub fn analyze_project_with(
&self,
duplicates_config: &fallow_config::DuplicatesConfig,
retain_complexity_artifacts: bool,
) -> fallow_engine::EngineResult<EditorProjectAnalysisOutput> {
self.inner
.analyze_project_with(duplicates_config, retain_complexity_artifacts)
.map(EditorProjectAnalysisOutput::from_engine)
}
const fn from_engine(inner: fallow_engine::AnalysisSession) -> Self {
Self { inner }
}
}
#[derive(Debug)]
pub struct EditorProjectAnalysisOutput {
pub dead_code: EditorDeadCodeAnalysisOutput,
pub duplication: EditorDuplicationReport,
}
impl EditorProjectAnalysisOutput {
fn from_engine(output: fallow_engine::ProjectAnalysisOutput) -> Self {
Self {
dead_code: output.dead_code,
duplication: output.duplication,
}
}
}
#[derive(Debug, Default)]
pub struct EditorAnalysisOutput {
pub results: EditorAnalysisResults,
pub duplication: EditorDuplicationReport,
}
impl EditorAnalysisOutput {
#[must_use]
pub const fn new(results: EditorAnalysisResults, duplication: EditorDuplicationReport) -> Self {
Self {
results,
duplication,
}
}
#[must_use]
pub fn from_project_output(output: EditorProjectAnalysisOutput) -> Self {
Self::new(output.dead_code.results, output.duplication)
}
pub fn merge_project_output(&mut self, output: EditorProjectAnalysisOutput) {
self.merge_results(output.dead_code.results);
self.merge_duplication(output.duplication);
}
pub fn merge_results(&mut self, source: EditorAnalysisResults) {
self.results.merge_into(source);
}
pub fn merge_duplication(&mut self, source: EditorDuplicationReport) {
self.duplication.clone_groups.extend(source.clone_groups);
self.duplication
.clone_families
.extend(source.clone_families);
self.duplication
.mirrored_directories
.extend(source.mirrored_directories);
self.duplication.stats.clone_groups += source.stats.clone_groups;
self.duplication.stats.clone_instances += source.stats.clone_instances;
self.duplication.stats.total_files += source.stats.total_files;
self.duplication.stats.files_with_clones += source.stats.files_with_clones;
self.duplication.stats.total_lines += source.stats.total_lines;
self.duplication.stats.duplicated_lines += source.stats.duplicated_lines;
self.duplication.stats.total_tokens += source.stats.total_tokens;
self.duplication.stats.duplicated_tokens += source.stats.duplicated_tokens;
self.duplication.stats.clone_groups_below_min_occurrences +=
source.stats.clone_groups_below_min_occurrences;
self.duplication.stats.duplication_percentage = if self.duplication.stats.total_lines > 0 {
(self.duplication.stats.duplicated_lines as f64
/ self.duplication.stats.total_lines as f64)
* 100.0
} else {
0.0
};
}
pub fn filter_by_changed_files(&mut self, changed_files: &FxHashSet<PathBuf>, root: &Path) {
fallow_engine::changed_files::filter_results_by_changed_files(
&mut self.results,
changed_files,
);
fallow_engine::changed_files::filter_duplication_by_changed_files(
&mut self.duplication,
changed_files,
root,
);
}
pub fn filter_by_changed_since(
&mut self,
root: &Path,
toplevel: &Path,
git_ref: &str,
) -> Result<usize, ChangedFilesError> {
let changed = try_get_changed_files_with_toplevel(root, toplevel, git_ref)?;
let changed_count = changed.len();
self.filter_by_changed_files(&changed, root);
Ok(changed_count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_engine::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
#[test]
fn merges_duplication_stats_and_recomputes_percentage() {
let mut output = EditorAnalysisOutput {
duplication: EditorDuplicationReport {
clone_groups: vec![CloneGroup {
instances: vec![CloneInstance {
file: PathBuf::from("src/a.ts"),
start_line: 1,
end_line: 4,
start_col: 0,
end_col: 10,
fragment: "const a = 1;".to_string(),
}],
token_count: 8,
line_count: 4,
}],
clone_families: Vec::new(),
mirrored_directories: Vec::new(),
stats: DuplicationStats {
clone_groups: 1,
clone_instances: 1,
total_files: 1,
files_with_clones: 1,
total_lines: 20,
duplicated_lines: 4,
total_tokens: 80,
duplicated_tokens: 8,
duplication_percentage: 20.0,
clone_groups_below_min_occurrences: 1,
},
},
..Default::default()
};
output.merge_duplication(EditorDuplicationReport {
clone_groups: Vec::new(),
clone_families: Vec::new(),
mirrored_directories: Vec::new(),
stats: DuplicationStats {
clone_groups: 0,
clone_instances: 0,
total_files: 1,
files_with_clones: 0,
total_lines: 30,
duplicated_lines: 6,
total_tokens: 120,
duplicated_tokens: 12,
duplication_percentage: 20.0,
clone_groups_below_min_occurrences: 2,
},
});
assert_eq!(output.duplication.stats.total_lines, 50);
assert_eq!(output.duplication.stats.duplicated_lines, 10);
assert_eq!(
output.duplication.stats.clone_groups_below_min_occurrences,
3
);
assert!((output.duplication.stats.duplication_percentage - 20.0).abs() < f64::EPSILON);
}
#[test]
fn editor_session_returns_api_owned_project_output() {
let temp = tempfile::tempdir().expect("temp project");
let root = temp.path();
std::fs::create_dir_all(root.join("src")).expect("src dir");
std::fs::write(
root.join("package.json"),
r#"{"name":"editor-api-session","main":"src/index.ts"}"#,
)
.expect("package.json");
std::fs::write(
root.join("src/index.ts"),
"export const used = 1;\nconsole.log(used);\n",
)
.expect("source");
let session = EditorAnalysisSession::load(root, None).expect("session loads");
let output = session
.analyze_project_with(&fallow_config::DuplicatesConfig::default(), true)
.expect("analysis runs");
assert!(output.dead_code.modules.is_some());
assert!(
output
.dead_code
.files
.as_ref()
.is_some_and(|files| !files.is_empty())
);
}
#[test]
fn build_health_ignore_set_returns_none_for_empty_patterns() {
assert!(
build_health_ignore_set(&[]).is_none(),
"empty ignore pattern list should avoid building a matcher"
);
}
#[test]
fn build_health_ignore_set_matches_glob_patterns() {
let set =
build_health_ignore_set(&["**/*.test.ts".to_string(), "src/generated/**".to_string()])
.expect("valid patterns build a glob set");
assert!(set.is_match(Path::new("src/foo.test.ts")));
assert!(set.is_match(Path::new("src/generated/client.ts")));
assert!(!set.is_match(Path::new("src/app.ts")));
}
#[test]
fn build_health_ignore_set_skips_invalid_patterns() {
let result = build_health_ignore_set(&["[invalid-glob".to_string()]);
match result {
None => {}
Some(set) => assert!(
!set.is_match(Path::new("any/path.ts")),
"set built from only invalid patterns must not match anything"
),
}
}
fn make_inline_finding(path: PathBuf) -> EditorInlineComplexityFinding {
EditorInlineComplexityFinding {
path,
name: "myFn".to_string(),
line: 1,
col: 0,
cyclomatic: 5,
cognitive: 4,
exceeded: EditorInlineComplexityExceeded::Cyclomatic,
}
}
#[test]
fn filter_inline_complexity_keeps_findings_in_changed_set() {
let changed: FxHashSet<PathBuf> = [PathBuf::from("/src/a.ts"), PathBuf::from("/src/b.ts")]
.into_iter()
.collect();
let mut findings = vec![
make_inline_finding(PathBuf::from("/src/a.ts")),
make_inline_finding(PathBuf::from("/src/c.ts")),
];
filter_inline_complexity_by_changed_files(&mut findings, &changed);
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].path.to_string_lossy().replace('\\', "/"),
"/src/a.ts"
);
}
#[test]
fn filter_inline_complexity_removes_all_when_changed_set_empty() {
let changed: FxHashSet<PathBuf> = FxHashSet::default();
let mut findings = vec![make_inline_finding(PathBuf::from("/src/a.ts"))];
filter_inline_complexity_by_changed_files(&mut findings, &changed);
assert!(
findings.is_empty(),
"empty changed-files set must drop all inline complexity findings"
);
}
#[test]
fn filter_inline_complexity_keeps_all_when_all_in_changed_set() {
let path_a = PathBuf::from("/src/a.ts");
let path_b = PathBuf::from("/src/b.ts");
let changed: FxHashSet<PathBuf> = [path_a.clone(), path_b.clone()].into_iter().collect();
let mut findings = vec![make_inline_finding(path_a), make_inline_finding(path_b)];
filter_inline_complexity_by_changed_files(&mut findings, &changed);
assert_eq!(
findings.len(),
2,
"all findings in the changed set must be retained"
);
}
}