use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, anyhow, bail};
use super::recipe::GrammarRecipe;
pub fn dylib_ext() -> &'static str {
if cfg!(target_os = "macos") {
"dylib"
} else if cfg!(target_os = "windows") {
"dll"
} else {
"so"
}
}
pub struct InstallReport {
pub library: PathBuf,
pub queries: Vec<PathBuf>,
}
pub fn install(
recipe: &GrammarRecipe,
grammar_dir: &Path,
query_dir: &Path,
) -> Result<InstallReport> {
ensure_tool("git", "git is required to fetch grammar sources")?;
ensure_tool(
"tree-sitter",
"tree-sitter CLI is required to build grammars (try `cargo install tree-sitter-cli` or `npm i -g tree-sitter-cli`)",
)?;
std::fs::create_dir_all(grammar_dir)
.with_context(|| format!("creating grammar dir {}", grammar_dir.display()))?;
let clone_root = tmp_clone_dir(recipe.name);
let _ = std::fs::remove_dir_all(&clone_root);
clone(recipe, &clone_root)?;
let build_dir = match recipe.subpath {
Some(sub) => clone_root.join(sub),
None => clone_root.clone(),
};
if !build_dir.exists() {
let _ = std::fs::remove_dir_all(&clone_root);
bail!(
"subpath `{}` not found in clone of {}",
recipe.subpath.unwrap_or(""),
recipe.repo
);
}
let library = grammar_dir.join(format!("{}.{}", recipe.name, dylib_ext()));
build(&build_dir, &library)?;
let queries = write_vendored_queries(query_dir, recipe.name)?;
let _ = std::fs::remove_dir_all(&clone_root);
Ok(InstallReport { library, queries })
}
pub fn write_vendored_queries(query_dir: &Path, name: &str) -> Result<Vec<PathBuf>> {
let files = super::assets::files_for(name);
if files.is_empty() {
return Ok(Vec::new());
}
let dest_dir = query_dir.join(name);
std::fs::create_dir_all(&dest_dir)
.with_context(|| format!("creating query dir {}", dest_dir.display()))?;
let mut written = Vec::new();
for file in files {
let Some(filename) = file.path().file_name() else {
continue;
};
if file
.path()
.extension()
.and_then(|s| s.to_str())
!= Some("scm")
{
continue;
}
let dst = dest_dir.join(filename);
std::fs::write(&dst, file.contents())
.with_context(|| format!("writing {}", dst.display()))?;
written.push(dst);
}
Ok(written)
}
pub fn remove(name: &str, grammar_dir: &Path) -> Result<bool> {
let mut removed = false;
for ext in ["so", "dylib", "dll"] {
let p = grammar_dir.join(format!("{}.{}", name, ext));
if p.exists() {
std::fs::remove_file(&p)
.with_context(|| format!("removing {}", p.display()))?;
removed = true;
}
}
Ok(removed)
}
pub fn installed_path(name: &str, grammar_dir: &Path) -> Option<PathBuf> {
for ext in ["so", "dylib", "dll"] {
let p = grammar_dir.join(format!("{}.{}", name, ext));
if p.exists() {
return Some(p);
}
}
None
}
pub fn is_fully_installed(name: &str, grammar_dir: &Path, query_dir: &Path) -> bool {
if installed_path(name, grammar_dir).is_none() {
return false;
}
let bundled = super::assets::bundled_query_names(name);
if bundled.is_empty() {
return true;
}
let installed: std::collections::HashSet<String> =
installed_queries(name, query_dir).into_iter().collect();
bundled.iter().all(|n| installed.contains(n))
}
pub fn installed_queries(name: &str, query_dir: &Path) -> Vec<String> {
let dir = query_dir.join(name);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("scm") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
out.push(stem.to_string());
}
}
out.sort();
out
}
fn tmp_clone_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("vorto-grammar-{}-{}", name, std::process::id()))
}
fn clone(recipe: &GrammarRecipe, dest: &Path) -> Result<()> {
let mut cmd = Command::new("git");
cmd.arg("clone");
if recipe.rev.is_none() {
cmd.args(["--depth", "1"]);
}
cmd.arg(recipe.repo).arg(dest);
let status = cmd
.status()
.with_context(|| format!("spawning `git clone {}`", recipe.repo))?;
if !status.success() {
bail!("git clone failed for {}", recipe.repo);
}
if let Some(rev) = recipe.rev {
let status = Command::new("git")
.args(["checkout", rev])
.current_dir(dest)
.status()
.context("spawning `git checkout`")?;
if !status.success() {
bail!("git checkout {} failed in {}", rev, dest.display());
}
}
Ok(())
}
fn build(build_dir: &Path, out_path: &Path) -> Result<()> {
let status = Command::new("tree-sitter")
.arg("build")
.arg("-o")
.arg(out_path)
.current_dir(build_dir)
.status()
.context("spawning `tree-sitter build`")?;
if !status.success() {
bail!(
"tree-sitter build failed in {} (output: {})",
build_dir.display(),
out_path.display()
);
}
Ok(())
}
fn ensure_tool(name: &str, hint: &str) -> Result<()> {
let path = std::env::var_os("PATH").ok_or_else(|| anyhow!("PATH is unset"))?;
let exe_suffixes: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat", ""]
} else {
&[""]
};
for dir in std::env::split_paths(&path) {
for suf in exe_suffixes {
let candidate = dir.join(format!("{}{}", name, suf));
if candidate.is_file() {
return Ok(());
}
}
}
Err(anyhow!("{} not found in PATH — {}", name, hint))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dylib_ext_is_platform_native() {
let ext = dylib_ext();
assert!(matches!(ext, "so" | "dylib" | "dll"));
}
#[test]
fn installed_path_returns_none_for_missing() {
let dir = std::env::temp_dir();
assert!(installed_path("vorto-test-no-such-grammar-xyz", &dir).is_none());
}
}