use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;
use crate::config::Config;
use crate::lang::{self, Import, Target};
use crate::suppress::{self, Suppression};
#[derive(Debug, Clone)]
pub struct Dep {
pub file: PathBuf,
pub line: usize,
pub specifier: String,
pub target: Target,
}
struct Parsed {
modified: Option<SystemTime>,
len: u64,
imports: Vec<Import>,
dynamic: Vec<usize>,
suppressions: Vec<Suppression>,
}
#[derive(Default)]
pub struct Cache(HashMap<PathBuf, Parsed>);
pub struct Scan {
pub files: usize,
pub dynamic: Vec<String>,
pub paths: Vec<String>,
pub deps: Vec<Dep>,
pub suppressions: Vec<Suppression>,
}
pub fn walker(root: &Path) -> WalkBuilder {
let mut builder = WalkBuilder::new(root);
builder
.require_git(false)
.filter_entry(|e| e.file_name() != "node_modules");
builder
}
pub fn scan(root: &Path, config: &Config, cache: &mut Cache) -> Result<Scan> {
let exclude = exclude_set(&config.exclude)?;
let adapters = lang::adapters(root, config)?;
let mut paths = Vec::new();
let mut deps = Vec::new();
let mut dynamic = Vec::new();
let mut suppressions = Vec::new();
let mut seen = HashSet::new();
for entry in walker(root).build() {
let entry = entry?;
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
let Some(rel) = lang::relative(root, path) else {
continue;
};
if exclude.is_match(&rel) {
continue;
}
let Some(adapter) = adapters.iter().find(|a| a.handles(path)) else {
continue;
};
let meta = entry.metadata()?;
let modified = meta.modified().ok();
let fresh = cache
.0
.get(&rel)
.is_some_and(|p| p.len == meta.len() && p.modified == modified && modified.is_some());
if !fresh {
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => continue, };
let parsed = Parsed {
modified,
len: meta.len(),
imports: adapter.imports(path, &source).with_context(|| {
format!("{} adapter failed on {}", adapter.name(), rel.display())
})?,
dynamic: adapter.dynamic_imports(path, &source).with_context(|| {
format!("{} adapter failed on {}", adapter.name(), rel.display())
})?,
suppressions: suppress::parse(&slash(&rel), &source),
};
cache.0.insert(rel.clone(), parsed);
}
let parsed = &cache.0[&rel];
seen.insert(rel.clone());
paths.push(slash(&rel));
suppressions.extend(parsed.suppressions.iter().cloned());
dynamic.extend(
parsed
.dynamic
.iter()
.map(|l| format!("{}:{l}", slash(&rel))),
);
for import in &parsed.imports {
deps.push(Dep {
file: rel.clone(),
line: import.line,
specifier: import.specifier.clone(),
target: adapter.resolve(path, &import.specifier),
});
}
}
cache.0.retain(|path, _| seen.contains(path));
Ok(Scan {
files: paths.len(),
dynamic,
paths,
deps,
suppressions,
})
}
fn exclude_set(patterns: &[String]) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for p in patterns {
let p = p.trim_end_matches('/');
builder.add(Glob::new(p).with_context(|| format!("bad exclude pattern `{p}`"))?);
builder.add(Glob::new(&format!("{p}/**"))?);
}
Ok(builder.build()?)
}
pub fn slash(path: &Path) -> String {
path.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}