use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use super::manifest::LangSpec;
#[derive(Debug, Clone)]
pub struct SourceCache {
base: PathBuf,
}
impl SourceCache {
pub fn new(base: PathBuf) -> Self {
Self { base }
}
pub fn user_default() -> Result<Self> {
let mut p = dirs::cache_dir().context("could not resolve user cache directory")?;
p.push("hjkl/grammars");
Ok(Self::new(p))
}
pub fn base(&self) -> &Path {
&self.base
}
pub fn source_dir(&self, name: &str, spec: &LangSpec) -> PathBuf {
self.base
.join(format!("{name}-{}", short_rev(&spec.git_rev)))
}
pub fn acquire(&self, name: &str, spec: &LangSpec) -> Result<PathBuf> {
let dest = self.source_dir(name, spec);
if dest.exists() {
return Ok(grammar_root(&dest, spec));
}
std::fs::create_dir_all(&self.base)
.with_context(|| format!("create cache base {}", self.base.display()))?;
let staging = self.base.join(format!(
"{name}-{}.tmp-{}",
short_rev(&spec.git_rev),
std::process::id()
));
let _ = std::fs::remove_dir_all(&staging);
match clone_into(&staging, &spec.git_url, &spec.git_rev) {
Ok(()) => {}
Err(e) => {
let _ = std::fs::remove_dir_all(&staging);
return Err(e);
}
}
match std::fs::rename(&staging, &dest) {
Ok(()) => Ok(grammar_root(&dest, spec)),
Err(_) if dest.exists() => {
let _ = std::fs::remove_dir_all(&staging);
Ok(grammar_root(&dest, spec))
}
Err(e) => {
let _ = std::fs::remove_dir_all(&staging);
Err(e)
.with_context(|| format!("rename {} -> {}", staging.display(), dest.display()))
}
}
}
}
fn short_rev(rev: &str) -> &str {
let take = rev.len().min(12);
&rev[..take]
}
fn grammar_root(clone_dir: &Path, spec: &LangSpec) -> PathBuf {
match &spec.subpath {
Some(s) if !s.is_empty() => clone_dir.join(s),
_ => clone_dir.to_path_buf(),
}
}
fn clone_into(dir: &Path, url: &str, rev: &str) -> Result<()> {
std::fs::create_dir_all(dir).with_context(|| format!("create staging {}", dir.display()))?;
run_git(dir, &["init", "--quiet"])?;
run_git(dir, &["remote", "add", "origin", url])?;
if run_git(dir, &["fetch", "--depth=1", "--quiet", "origin", rev]).is_err() {
run_git(dir, &["fetch", "--quiet", "origin", rev])
.with_context(|| format!("fetch {rev} from {url}"))?;
}
run_git(dir, &["checkout", "--quiet", "FETCH_HEAD"])
.with_context(|| format!("checkout {rev}"))?;
Ok(())
}
fn run_git(cwd: &Path, args: &[&str]) -> Result<()> {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.with_context(|| format!("spawn git {}", args.join(" ")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!(
"git {} failed in {}: {}",
args.join(" "),
cwd.display(),
stderr.trim()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_spec(rev: &str, subpath: Option<&str>) -> LangSpec {
LangSpec {
git_url: "https://example/repo".into(),
git_rev: rev.into(),
subpath: subpath.map(String::from),
extensions: vec!["x".into()],
c_files: vec!["src/parser.c".into()],
query_dir: "queries".into(),
source: None,
}
}
#[test]
fn short_rev_truncates_to_12() {
assert_eq!(short_rev("0123456789abcdef"), "0123456789ab");
assert_eq!(short_rev("abc"), "abc");
}
#[test]
fn source_dir_format_includes_short_rev() {
let cache = SourceCache::new(PathBuf::from("/tmp/cache"));
let spec = dummy_spec("0123456789abcdef00112233", None);
assert_eq!(
cache.source_dir("rust", &spec),
PathBuf::from("/tmp/cache/rust-0123456789ab")
);
}
#[test]
fn grammar_root_honors_subpath() {
let clone = PathBuf::from("/tmp/cache/typescript-deadbeef0000");
let spec = dummy_spec("deadbeef00000000", Some("typescript"));
assert_eq!(grammar_root(&clone, &spec), clone.join("typescript"));
}
#[test]
fn grammar_root_no_subpath_returns_clone_dir() {
let clone = PathBuf::from("/tmp/cache/rust-deadbeef0000");
let spec = dummy_spec("deadbeef00000000", None);
assert_eq!(grammar_root(&clone, &spec), clone);
}
#[test]
#[ignore = "network: clones from github"]
fn acquire_clones_real_repo() {
let tmp = tempfile::tempdir().unwrap();
let cache = SourceCache::new(tmp.path().to_path_buf());
let spec = LangSpec {
git_url: "https://github.com/tree-sitter/tree-sitter-c".into(),
git_rev: "2a265d69a4caf57108a73ad2ed1e6922dd2f998c".into(),
subpath: None,
extensions: vec!["c".into()],
c_files: vec!["src/parser.c".into()],
query_dir: "queries".into(),
source: None,
};
let root = cache.acquire("c", &spec).unwrap();
assert!(root.join("src/parser.c").is_file());
let root2 = cache.acquire("c", &spec).unwrap();
assert_eq!(root, root2);
}
}