use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use super::manifest::{LangSpec, ManifestMeta, QuerySource};
use super::xdg;
#[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 p = xdg::cache_home()?.join("bonsai/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 injections_path(&self, grammar_source_root: &Path) -> Result<Option<PathBuf>> {
let injections_path = grammar_source_root.join("queries").join("injections.scm");
match std::fs::metadata(&injections_path) {
Ok(_) => Ok(Some(injections_path)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e)
.with_context(|| format!("stat injections.scm at {}", injections_path.display())),
}
}
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()))
}
}
}
}
pub(crate) 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(),
}
}
#[derive(Debug, Clone)]
pub struct QuerySourceCache {
base: PathBuf,
}
impl QuerySourceCache {
pub fn new(base: PathBuf) -> Self {
Self { base }
}
pub fn user_default() -> Result<Self> {
let p = xdg::cache_home()?.join("bonsai/query-sources");
Ok(Self::new(p))
}
pub fn acquire_source(&self, source: QuerySource, meta: &ManifestMeta) -> Result<PathBuf> {
let (url, rev) = match source {
QuerySource::Helix => (meta.helix_repo.as_str(), meta.helix_rev.as_str()),
QuerySource::NvimTreesitter => (
meta.nvim_treesitter_repo.as_str(),
meta.nvim_treesitter_rev.as_str(),
),
};
let label = match source {
QuerySource::Helix => "helix",
QuerySource::NvimTreesitter => "nvim-treesitter",
};
let dest = self.base.join(format!("{label}-{}", short_rev(rev)));
if dest.exists() {
return Ok(dest);
}
std::fs::create_dir_all(&self.base)
.with_context(|| format!("create query-source base {}", self.base.display()))?;
let staging = self.base.join(format!(
"{label}-{}.tmp-{}",
short_rev(rev),
std::process::id()
));
let _ = std::fs::remove_dir_all(&staging);
let sparse_prefix = source.query_prefix();
match sparse_clone_into(&staging, url, rev, sparse_prefix) {
Ok(()) => {}
Err(e) => {
let _ = std::fs::remove_dir_all(&staging);
return Err(e);
}
}
match std::fs::rename(&staging, &dest) {
Ok(()) => Ok(dest),
Err(_) if dest.exists() => {
let _ = std::fs::remove_dir_all(&staging);
Ok(dest)
}
Err(e) => {
let _ = std::fs::remove_dir_all(&staging);
Err(e)
.with_context(|| format!("rename {} -> {}", staging.display(), dest.display()))
}
}
}
pub fn resolve_highlights(
&self,
source: QuerySource,
meta: &ManifestMeta,
lang_name: &str,
query_subdir: Option<&str>,
) -> Result<PathBuf> {
let repo_root = self.acquire_source(source, meta)?;
let prefix = source.query_prefix();
let subdir = query_subdir.unwrap_or(lang_name);
let resolved_path = self.base.join(format!(
"{}-{}-{lang_name}.resolved.scm",
match source {
QuerySource::Helix => "helix",
QuerySource::NvimTreesitter => "nvim-treesitter",
},
short_rev(match source {
QuerySource::Helix => meta.helix_rev.as_str(),
QuerySource::NvimTreesitter => meta.nvim_treesitter_rev.as_str(),
}),
));
if resolved_path.exists() {
return Ok(resolved_path);
}
let content = resolve_inherits(&repo_root, prefix, subdir, &mut vec![])?;
let mut f = std::fs::File::create(&resolved_path)
.with_context(|| format!("create resolved scm {}", resolved_path.display()))?;
f.write_all(content.as_bytes())
.with_context(|| format!("write resolved scm {}", resolved_path.display()))?;
Ok(resolved_path)
}
}
fn resolve_inherits(
repo_root: &Path,
prefix: &str,
lang: &str,
visited: &mut Vec<String>,
) -> Result<String> {
if visited.iter().any(|v| v == lang) {
return Ok(String::new());
}
visited.push(lang.to_string());
let scm_path = repo_root.join(prefix).join(lang).join("highlights.scm");
if !scm_path.is_file() {
bail!(
"highlights.scm not found at {} for lang `{lang}`",
scm_path.display()
);
}
let raw = std::fs::read_to_string(&scm_path)
.with_context(|| format!("read {}", scm_path.display()))?;
let mut parents: Vec<String> = Vec::new();
for line in raw.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed
.strip_prefix("; inherits:")
.or_else(|| trimmed.strip_prefix(";; inherits:"))
{
for part in rest.split(',') {
let p_raw = part.trim();
if !p_raw.is_empty() {
parents.push(p_raw.to_string());
}
}
}
}
let mut out = String::new();
for parent in &parents {
let resolved = resolve_inherits(repo_root, prefix, parent, visited)
.or_else(|_| {
let stripped = parent.trim_start_matches('_');
if stripped != parent {
resolve_inherits(repo_root, prefix, stripped, visited)
} else {
bail!("no fallback for parent `{parent}`")
}
})
.unwrap_or_default();
if !resolved.is_empty() {
out.push_str(&resolved);
if !out.ends_with('\n') {
out.push('\n');
}
}
}
out.push_str(&raw);
Ok(out)
}
fn sparse_clone_into(dir: &Path, url: &str, rev: &str, sparse_prefix: &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])?;
run_git(dir, &["sparse-checkout", "init", "--no-cone"])?;
run_git(dir, &["sparse-checkout", "set", sparse_prefix])?;
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 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::*;
use crate::runtime::manifest::QuerySource;
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_source: QuerySource::Helix,
query_subdir: None,
source: None,
}
}
fn dummy_meta() -> ManifestMeta {
ManifestMeta {
helix_repo: "https://github.com/helix-editor/helix".into(),
helix_rev: "aaaa0000bbbb1111cccc2222dddd3333eeee4444".into(),
nvim_treesitter_repo: "https://github.com/nvim-treesitter/nvim-treesitter".into(),
nvim_treesitter_rev: "ffff5555aaaa0000bbbb1111cccc2222dddd3333".into(),
}
}
#[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]
fn inherits_chain_resolved_into_single_file() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let prefix = "runtime/queries";
let ecma_dir = repo.join(prefix).join("ecma");
let ts_dir = repo.join(prefix).join("typescript");
std::fs::create_dir_all(&ecma_dir).unwrap();
std::fs::create_dir_all(&ts_dir).unwrap();
std::fs::write(ecma_dir.join("highlights.scm"), "(injection.foo)\n").unwrap();
std::fs::write(
ts_dir.join("highlights.scm"),
"; inherits: ecma\n(typescript.bar)\n",
)
.unwrap();
let mut visited = vec![];
let result = resolve_inherits(&repo, prefix, "typescript", &mut visited).unwrap();
assert!(
result.contains("(injection.foo)"),
"parent ecma content missing: {result}"
);
assert!(
result.contains("(typescript.bar)"),
"child typescript content missing: {result}"
);
let parent_pos = result.find("(injection.foo)").unwrap();
let child_pos = result.find("(typescript.bar)").unwrap();
assert!(parent_pos < child_pos, "parent must precede child");
}
#[test]
fn query_source_helix_picks_helix_layout() {
let tmp = tempfile::tempdir().unwrap();
let cache_base = tmp.path().join("query-sources");
let meta = dummy_meta();
let label = format!("helix-{}", short_rev(&meta.helix_rev));
let repo = cache_base.join(&label);
let qs_dir = repo.join("runtime/queries/rust");
std::fs::create_dir_all(&qs_dir).unwrap();
std::fs::write(qs_dir.join("highlights.scm"), "(rust.id) @variable\n").unwrap();
let qsc = QuerySourceCache::new(cache_base);
let resolved = qsc
.resolve_highlights(QuerySource::Helix, &meta, "rust", None)
.unwrap();
let content = std::fs::read_to_string(&resolved).unwrap();
assert!(content.contains("(rust.id)"), "got: {content}");
}
#[test]
fn query_source_nvim_used_when_helix_absent() {
let tmp = tempfile::tempdir().unwrap();
let cache_base = tmp.path().join("query-sources");
let meta = dummy_meta();
let label = format!("nvim-treesitter-{}", short_rev(&meta.nvim_treesitter_rev));
let repo = cache_base.join(&label);
let qs_dir = repo.join("queries/go");
std::fs::create_dir_all(&qs_dir).unwrap();
std::fs::write(qs_dir.join("highlights.scm"), "(go.func) @function\n").unwrap();
let qsc = QuerySourceCache::new(cache_base);
let resolved = qsc
.resolve_highlights(QuerySource::NvimTreesitter, &meta, "go", None)
.unwrap();
let content = std::fs::read_to_string(&resolved).unwrap();
assert!(content.contains("(go.func)"), "got: {content}");
}
#[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_source: QuerySource::Helix,
query_subdir: None,
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);
}
}