use anyhow::{Result, bail};
use ignore::WalkBuilder;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::path::{Path, PathBuf};
use tracing::debug;
const DEFAULT_IGNORE_NAMES: &[&str] = &[
"Cargo.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Gemfile.lock",
"poetry.lock",
"composer.lock",
"go.sum",
"flake.lock",
];
const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
"graphify-out",
".graphify_*",
"*.scip",
"index.scip",
".scip-typescript",
"*.lsif",
"*.lsif.json",
"dump.lsif",
".lsif-tsc",
".sourcegraph",
];
fn build_default_ignore(root: &Path) -> Result<Gitignore> {
let mut builder = GitignoreBuilder::new(root);
for pattern in DEFAULT_IGNORE_PATTERNS {
builder
.add_line(None, pattern)
.map_err(|e| anyhow::anyhow!("invalid default ignore pattern {pattern:?}: {e}"))?;
}
builder
.build()
.map_err(|e| anyhow::anyhow!("failed to build default ignore set: {e}"))
}
pub struct FileWalker {
max_file_size: u64,
max_file_count: usize,
}
impl FileWalker {
pub fn new(max_file_size: u64, max_file_count: usize) -> Self {
Self {
max_file_size,
max_file_count,
}
}
pub fn walk(&self, root: &Path) -> Result<Vec<PathBuf>> {
if !root.is_dir() {
bail!("Root path is not a directory: {}", root.display());
}
let mut builder = WalkBuilder::new(root);
builder
.hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .require_git(false) .max_filesize(Some(self.max_file_size));
builder.add_custom_ignore_filename(".semantexignore");
let default_gi = build_default_ignore(root)?;
let dir_gi = default_gi.clone();
builder.filter_entry(move |entry| {
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
!dir_gi.matched(entry.path(), is_dir).is_ignore()
});
let walker = builder.build();
let mut paths = Vec::new();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(err) => {
debug!("Skipping entry due to error: {}", err);
continue;
}
};
let Some(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let path = entry.into_path();
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& DEFAULT_IGNORE_NAMES.contains(&name)
{
debug!("Skipping default-ignored file: {}", path.display());
continue;
}
if default_gi.matched(&path, false).is_ignore() {
debug!("Skipping default-ignored pattern: {}", path.display());
continue;
}
if is_binary(&path) {
debug!("Skipping binary file: {}", path.display());
continue;
}
paths.push(path);
if paths.len() >= self.max_file_count {
debug!(
"Reached max file count limit ({}), stopping walk",
self.max_file_count
);
break;
}
}
paths.sort();
Ok(paths)
}
}
fn is_binary(path: &Path) -> bool {
use std::fs::File;
use std::io::Read;
if let Some(ext) = path.extension()
&& ext == "pdf"
{
return false;
}
let Ok(mut file) = File::open(path) else {
return false;
};
let mut buf = [0u8; 8192];
let Ok(n) = file.read(&mut buf) else {
return false;
};
buf[..n].contains(&0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn touch(p: &Path, body: &str) {
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, body).unwrap();
}
#[test]
fn default_ignores_skip_code_intel_outputs() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
touch(&root.join("src/main.rs"), "fn main() {}");
touch(&root.join("README.md"), "# repo");
touch(&root.join("graphify-out/graph.json"), "{}");
touch(&root.join("graphify-out/cache/abc.json"), "{}");
touch(&root.join(".graphify_analysis.json"), "{}");
touch(&root.join(".graphify_semantic_marker"), "ok");
touch(&root.join("index.scip"), "scipdata");
touch(&root.join("dump.lsif"), "{}");
touch(&root.join("subdir/local.lsif.json"), "{}");
let walker = FileWalker::new(10_000_000, 1000);
let paths = walker.walk(root).unwrap();
let rel: Vec<String> = paths
.iter()
.map(|p| {
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert!(
rel.iter().any(|p| p == "src/main.rs"),
"missing main.rs: {rel:?}"
);
assert!(
rel.iter().any(|p| p == "README.md"),
"missing README.md: {rel:?}"
);
for forbidden in [
"graphify-out/graph.json",
"graphify-out/cache/abc.json",
".graphify_analysis.json",
".graphify_semantic_marker",
"index.scip",
"dump.lsif",
"subdir/local.lsif.json",
] {
assert!(
!rel.iter().any(|p| p == forbidden),
"tool output leaked into walk: {forbidden} (got {rel:?})"
);
}
}
#[test]
fn semantexignore_still_applies_for_user_patterns() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
touch(&root.join("src/keep.rs"), "ok");
touch(&root.join("vendor/skip.rs"), "skip me");
touch(&root.join(".semantexignore"), "vendor/\n");
let walker = FileWalker::new(10_000_000, 1000);
let paths = walker.walk(root).unwrap();
let rel: Vec<String> = paths
.iter()
.map(|p| {
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert!(
rel.iter().any(|p| p == "src/keep.rs"),
"missing keep.rs: {rel:?}"
);
assert!(
!rel.iter().any(|p| p.starts_with("vendor/")),
".semantexignore stopped working: {rel:?}"
);
}
}