use std::collections::BTreeSet;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use crate::scip::{self, Document, Documents};
pub struct Language {
pub name: &'static str,
binary: &'static str,
markers: &'static [&'static str],
outermost: bool,
fallback_root: bool,
extensions: &'static [&'static str],
args: fn(&Path) -> Vec<String>,
install: &'static str,
mise: Option<&'static str>,
skip_dirs: &'static [&'static str],
writes_in_cwd: Option<&'static str>,
needs: fn(&Path, &Path, &str) -> Result<()>,
}
fn nothing_needed(_: &Path, _: &Path, _: &str) -> Result<()> {
Ok(())
}
pub const LANGUAGES: &[Language] = &[
Language {
name: "go",
binary: "scip-go",
markers: &["go.mod"],
outermost: false,
fallback_root: false,
extensions: &["go"],
args: |out| vec!["--output".into(), out.display().to_string()],
install: "mise use -g go:github.com/scip-code/scip-go/cmd/scip-go",
mise: Some("go:github.com/scip-code/scip-go/cmd/scip-go"),
skip_dirs: &[],
writes_in_cwd: None,
needs: nothing_needed,
},
Language {
name: "typescript",
binary: "scip-typescript",
markers: &["tsconfig.json"],
outermost: false,
fallback_root: false,
extensions: &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"],
args: |out| {
vec![
"index".into(),
"--no-progress-bar".into(),
"--output".into(),
out.display().to_string(),
]
},
install: "mise use -g npm:@sourcegraph/scip-typescript",
mise: Some("npm:@sourcegraph/scip-typescript"),
skip_dirs: &[],
writes_in_cwd: None,
needs: require_installed,
},
Language {
name: "python",
binary: "scip-python",
markers: &[
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
],
outermost: false,
fallback_root: true,
extensions: &["py", "pyi"],
args: |out| {
vec![
"index".into(),
".".into(),
"--quiet".into(),
"--project-name".into(),
"project".into(),
"--project-version".into(),
"0".into(),
"--output".into(),
out.display().to_string(),
]
},
install: "mise use -g npm:@sourcegraph/scip-python",
mise: Some("npm:@sourcegraph/scip-python"),
skip_dirs: &[],
writes_in_cwd: None,
needs: nothing_needed,
},
Language {
name: "php",
binary: "scip-php",
markers: &["composer.json"],
outermost: false,
fallback_root: false,
extensions: &["php"],
args: |_| Vec::new(),
install: "composer global require davidrjenni/scip-php",
mise: None,
skip_dirs: &["vendor"],
writes_in_cwd: Some("index.scip"),
needs: require_composer,
},
Language {
name: "java",
binary: "scip-java",
markers: &["pom.xml", "build.gradle", "build.gradle.kts"],
outermost: true,
fallback_root: false,
extensions: &["java"],
args: |out| vec!["index".into(), "--output".into(), out.display().to_string()],
install: "mise use -g github:sourcegraph/scip-java",
mise: Some("github:sourcegraph/scip-java"),
skip_dirs: &["target", "build"],
writes_in_cwd: None,
needs: nothing_needed,
},
Language {
name: "csharp",
binary: "scip-dotnet",
markers: &["*.csproj", "*.sln", "*.slnx"],
outermost: true,
fallback_root: false,
extensions: &["cs"],
args: |out| vec!["index".into(), "--output".into(), out.display().to_string()],
install: "dotnet tool install --global scip-dotnet",
mise: None,
skip_dirs: &["bin", "obj"],
writes_in_cwd: None,
needs: nothing_needed,
},
Language {
name: "rust",
binary: "rust-analyzer",
markers: &["Cargo.toml"],
outermost: true,
fallback_root: false,
extensions: &["rs"],
args: |out| {
vec![
"scip".into(),
".".into(),
"--output".into(),
out.display().to_string(),
]
},
install: "rustup component add rust-analyzer",
mise: None,
skip_dirs: &[],
writes_in_cwd: None,
needs: nothing_needed,
},
];
impl Language {
pub fn covers(&self, file: &str) -> bool {
file.rsplit_once('.')
.is_some_and(|(_, ext)| self.extensions.contains(&ext))
&& !file.ends_with(".d.ts")
&& !self.in_skip_dir(file)
}
fn has_marker(&self, name: &str) -> bool {
self.markers
.iter()
.any(|marker| match marker.strip_prefix('*') {
Some(suffix) => name.ends_with(suffix),
None => *marker == name,
})
}
fn in_skip_dir(&self, file: &str) -> bool {
file.split('/').any(|part| self.skip_dirs.contains(&part))
}
fn roots(&self, files: &[String]) -> Vec<String> {
let mut dirs: Vec<String> = files
.iter()
.filter_map(|f| {
let (dir, name) = f.rsplit_once('/').unwrap_or(("", f));
self.has_marker(name).then(|| dir.to_string())
})
.collect();
dirs.sort();
dirs.dedup();
if self.outermost {
let all = dirs.clone();
dirs.retain(|d| {
!all.iter()
.any(|p| p != d && (p.is_empty() || d.starts_with(&format!("{p}/"))))
});
}
if dirs.is_empty() && self.fallback_root {
dirs.push(String::new());
}
dirs
}
}
pub struct Indexed {
pub documents: Documents,
pub ran: usize,
pub cached: usize,
pub uncovered: Vec<String>,
}
fn environment(language: &Language, go_tags: &[String]) -> Vec<(&'static str, String)> {
if language.name != "go" || go_tags.is_empty() {
return Vec::new();
}
let mut flags = std::env::var("GOFLAGS").unwrap_or_default();
if !flags.is_empty() {
flags.push(' ');
}
flags.push_str(&format!("-tags={}", go_tags.join(",")));
vec![("GOFLAGS", flags)]
}
pub fn run(root: &Path, files: &[String], use_cache: bool, go_tags: &[String]) -> Result<Indexed> {
let (mut ran, mut cached) = (0, 0);
let mut parts = Vec::new();
let mut wanted: Vec<&String> = Vec::new();
for language in LANGUAGES {
wanted.extend(files.iter().filter(|f| language.covers(f)));
if !files.iter().any(|f| language.covers(f)) {
continue;
}
let roots = language.roots(files);
if roots.is_empty() {
continue;
}
for dir in roots {
let (documents, from_cache) =
index_dir(language, root, &dir, files, use_cache, go_tags)?;
parts.push(documents);
if from_cache {
cached += 1;
} else {
ran += 1;
}
}
}
let documents = if parts.is_empty() {
Documents::default()
} else {
Documents::merged(parts)?
};
let indexed: BTreeSet<&str> = documents.0.iter().map(|d| d.path.as_str()).collect();
let uncovered = wanted
.into_iter()
.filter(|f| !indexed.contains(f.as_str()))
.cloned()
.collect();
Ok(Indexed {
documents,
ran,
cached,
uncovered,
})
}
fn cache_dir() -> Option<PathBuf> {
let var = |name: &str| {
std::env::var_os(name)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
};
let base = var("EASEMENT_CACHE_DIR");
if base.is_some() {
return base;
}
let user = var("XDG_CACHE_HOME").or_else(|| {
if cfg!(windows) {
var("LOCALAPPDATA")
} else if cfg!(target_os = "macos") {
var("HOME").map(|home| home.join("Library/Caches"))
} else {
var("HOME").map(|home| home.join(".cache"))
}
})?;
Some(user.join("easement"))
}
fn fingerprint(
language: &Language,
binary: &Path,
root: &Path,
dir: &str,
files: &[String],
env: &[(&str, String)],
) -> u64 {
let mut hasher = DefaultHasher::new();
language.name.hash(&mut hasher);
env.hash(&mut hasher);
(language.args)(Path::new("")).hash(&mut hasher);
if let Ok(meta) = std::fs::metadata(binary) {
(binary, meta.len(), meta.modified().ok()).hash(&mut hasher);
}
let prefix = if dir.is_empty() {
String::new()
} else {
format!("{dir}/")
};
let above = |f: &str| {
let parent = f.rsplit_once('/').map_or("", |(parent, _)| parent);
parent.len() < dir.len() && (parent.is_empty() || dir.starts_with(&format!("{parent}/")))
};
let mut seen: Vec<(&str, Option<u64>)> = files
.iter()
.filter(|f| (f.starts_with(&prefix) && !language.in_skip_dir(f)) || above(f))
.map(|f| {
let content = std::fs::read(root.join(f)).ok().map(|bytes| {
let mut h = DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
});
(f.as_str(), content)
})
.collect();
seen.sort();
seen.hash(&mut hasher);
hasher.finish()
}
fn cache_file(language: &Language, dir: &str, key: u64) -> Option<(PathBuf, String)> {
let mut hasher = DefaultHasher::new();
(language.name, dir).hash(&mut hasher);
let prefix = format!("{:016x}-", hasher.finish());
Some((
cache_dir()?.join(format!("{prefix}{key:016x}.scip")),
prefix,
))
}
#[derive(Debug, serde::Serialize)]
pub struct Diagnosis {
pub language: &'static str,
pub binary: &'static str,
pub found: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub install: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub mise: Option<&'static str>,
pub problems: Vec<String>,
}
impl Diagnosis {
pub fn ok(&self) -> bool {
self.found && self.problems.is_empty()
}
}
pub fn diagnose(root: &Path, files: &[String]) -> Vec<Diagnosis> {
LANGUAGES
.iter()
.filter(|language| files.iter().any(|f| language.covers(f)))
.map(|language| {
let binary = which::which(language.binary).ok();
let version = binary.as_deref().and_then(version_of);
let problems = language
.roots(files)
.iter()
.filter_map(|dir| {
let shown = if dir.is_empty() { "." } else { dir };
(language.needs)(root, &root.join(dir), shown)
.err()
.map(|e| e.to_string())
})
.collect();
Diagnosis {
language: language.name,
binary: language.binary,
found: binary.is_some(),
path: binary.map(|p| p.display().to_string()),
version,
install: language.install,
mise: language.mise,
problems,
}
})
.collect()
}
fn version_of(binary: &Path) -> Option<String> {
let output = Command::new(binary).arg("--version").output().ok()?;
[output.stdout, output.stderr].iter().find_map(|bytes| {
String::from_utf8_lossy(bytes)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
})
}
fn index_dir(
language: &Language,
root: &Path,
dir: &str,
files: &[String],
use_cache: bool,
go_tags: &[String],
) -> Result<(Vec<Document>, bool)> {
let env = environment(language, go_tags);
let cwd = if dir.is_empty() {
root.to_path_buf()
} else {
root.join(dir)
};
let shown = if dir.is_empty() { "." } else { dir };
(language.needs)(root, &cwd, shown)?;
let binary = which::which(language.binary).map_err(|_| {
anyhow::anyhow!(
"`{}` is not installed, and easement needs it for {} files; install it with `{}`",
language.binary,
language.name,
language.install
)
})?;
let key = fingerprint(language, &binary, root, dir, files, &env);
let cached = use_cache.then(|| cache_file(language, dir, key)).flatten();
if let Some((path, _)) = &cached
&& let Ok(bytes) = std::fs::read(path)
&& let Ok(documents) = scip::parse(&bytes, Path::new(dir))
&& !documents.is_empty()
{
if let Ok(file) = std::fs::File::options().write(true).open(path) {
let _ = file.set_modified(std::time::SystemTime::now());
}
return Ok((documents, true));
}
let out = tempfile::tempdir().context("cannot create a temporary directory")?;
let file = match language.writes_in_cwd {
Some(name) => {
let written = cwd.join(name);
if written.exists() {
bail!(
"{} would overwrite {}, which is in the way; move it",
language.binary,
written.display()
);
}
written
}
None => out.path().join("index.scip"),
};
let output = Command::new(binary)
.args((language.args)(&file))
.envs(env.iter().map(|(k, v)| (*k, v)))
.current_dir(&cwd)
.output()
.with_context(|| format!("cannot run {}", language.binary))?;
if !output.status.success() {
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
let excerpt = if lines.len() > 14 {
[&lines[..5], &[" …"][..], &lines[lines.len() - 8..]].concat()
} else {
lines
};
let hint = if text.contains("LAUNCHER_ARGS[@]: unbound variable") {
"\nscip-java's wrapper needs bash 4.4 or newer; the bash of macOS is 3.2, put a newer one first on the PATH (`brew install bash`)"
} else {
""
};
bail!(
"{} failed in {shown} ({}); if that directory is not part of the project, add it to `exclude`:\n{}{hint}",
language.binary,
output.status,
excerpt.join("\n")
);
}
let bytes = std::fs::read(&file)
.with_context(|| format!("{} wrote no index in {shown}", language.binary));
if language.writes_in_cwd.is_some() {
let _ = std::fs::remove_file(&file);
}
let bytes = bytes?;
let documents = scip::parse(&bytes, Path::new(dir))
.with_context(|| format!("cannot read the index of {} in {shown}", language.binary))?;
if documents.is_empty() {
bail!(
"{} indexed no files in {shown}; check what its project file includes",
language.binary
);
}
if let Some((path, prefix)) = cached
&& let Some(parent) = path.parent()
{
let _ = std::fs::create_dir_all(parent);
let _ = tempfile::NamedTempFile::new_in(parent).and_then(|mut file| {
std::io::Write::write_all(&mut file, &bytes)?;
file.persist(&path).map_err(|e| e.error)
});
prune(parent, &prefix);
}
Ok((documents, false))
}
const KEPT_PER_DIR: usize = 32;
fn prune(parent: &Path, prefix: &str) {
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
let mut mine: Vec<_> = entries
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with(prefix))
.filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
.collect();
mine.sort_by(|a, b| b.cmp(a));
for (_, path) in mine.into_iter().skip(KEPT_PER_DIR) {
let _ = std::fs::remove_file(path);
}
}
fn require_composer(_root: &Path, dir: &Path, shown: &str) -> Result<()> {
if !dir.join("composer.lock").is_file() || !dir.join("vendor/autoload.php").is_file() {
bail!(
"dependencies are not installed in {shown}: run `composer install` (scip-php needs composer.lock and vendor/)"
);
}
Ok(())
}
fn require_installed(root: &Path, dir: &Path, shown: &str) -> Result<()> {
let manifest = dir.join("package.json");
let Ok(text) = std::fs::read_to_string(&manifest) else {
return Ok(());
};
let declares = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.is_some_and(|json| {
["dependencies", "devDependencies"].iter().any(|key| {
json.get(key)
.and_then(|v| v.as_object())
.is_some_and(|o| !o.is_empty())
})
});
if !declares {
return Ok(());
}
let mut at: Option<PathBuf> = Some(dir.to_path_buf());
while let Some(current) = at {
if current.join("node_modules").is_dir() {
return Ok(());
}
at = (current != root)
.then(|| current.parent().map(Path::to_path_buf))
.flatten();
}
bail!(
"dependencies are not installed in {shown}: run `npm install` (or pnpm, yarn) first; `easement doctor` lists everything else that is missing"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn languages_cover_their_sources_and_roots() {
let ts = &LANGUAGES[1];
assert!(ts.covers("src/a.tsx") && ts.covers("a.mjs"));
assert!(!ts.covers("src/a.d.ts") && !ts.covers("main.go"));
let files: Vec<String> = [
"go.mod",
"svc/go.mod",
"web/tsconfig.json",
"a.go",
"Cargo.toml",
"crates/x/Cargo.toml",
"tools/Cargo.toml",
"app.py",
]
.map(String::from)
.to_vec();
assert_eq!(LANGUAGES[0].roots(&files), ["", "svc"]);
assert_eq!(ts.roots(&files), ["web"]);
assert_eq!(LANGUAGES[6].roots(&files), [""]);
assert_eq!(LANGUAGES[2].roots(&files), [""]);
let nested: Vec<String> = ["a/Cargo.toml", "b/Cargo.toml", "b/c/Cargo.toml"]
.map(String::from)
.to_vec();
assert_eq!(LANGUAGES[6].roots(&nested), ["a", "b"]);
}
#[test]
fn missing_node_modules_is_an_error_only_when_dependencies_are_declared() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
require_installed(root, root, ".").unwrap();
std::fs::write(root.join("package.json"), r#"{"name":"x"}"#).unwrap();
require_installed(root, root, ".").unwrap();
std::fs::write(
root.join("package.json"),
r#"{"dependencies":{"lodash":"1"}}"#,
)
.unwrap();
let err = require_installed(root, root, ".").unwrap_err();
assert!(err.to_string().contains("not installed"));
assert!(err.to_string().contains("easement doctor"));
std::fs::create_dir(root.join("node_modules")).unwrap();
require_installed(root, root, ".").unwrap();
}
#[test]
fn pruning_keeps_the_most_recently_used_entries_of_one_directory_only() {
let dir = tempfile::tempdir().unwrap();
let now = std::time::SystemTime::now();
let write = |name: String, age: u64| {
let file = std::fs::File::create(dir.path().join(name)).unwrap();
file.set_modified(now - std::time::Duration::from_secs(age))
.unwrap();
};
for i in 0..KEPT_PER_DIR + 3 {
write(format!("aaaa-{i:016x}.scip"), 100 + i as u64);
}
write("bbbb-0.scip".into(), 10_000);
prune(dir.path(), "aaaa-");
let left = |prefix: &str| {
std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with(prefix))
.count()
};
assert_eq!(left("aaaa-"), KEPT_PER_DIR);
assert_eq!(left("bbbb-"), 1);
assert!(dir.path().join(format!("aaaa-{:016x}.scip", 0)).exists());
assert!(
!dir.path()
.join(format!("aaaa-{:016x}.scip", KEPT_PER_DIR + 2))
.exists()
);
}
}