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(
"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);
let used_tarball = match try_release_tarball(recipe, &clone_root) {
Ok(true) => true,
Ok(false) => false,
Err(_) => {
let _ = std::fs::remove_dir_all(&clone_root);
false
}
};
if !used_tarball {
ensure_tool("git", "git is required to fetch grammar sources")?;
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 try_release_tarball(recipe: &GrammarRecipe, dest: &Path) -> Result<bool> {
let Some((owner, repo)) = parse_github_url(recipe.repo) else {
return Ok(false);
};
if which("curl").is_none() || which("tar").is_none() {
return Ok(false);
}
let api_url = match recipe.rev {
Some(rev) => format!(
"https://api.github.com/repos/{}/{}/releases/tags/{}",
owner, repo, rev
),
None => format!(
"https://api.github.com/repos/{}/{}/releases/latest",
owner, repo
),
};
let json = match curl_text(&api_url) {
Ok(s) => s,
Err(_) => return Ok(false),
};
let v: serde_json::Value = match serde_json::from_str(&json) {
Ok(v) => v,
Err(_) => return Ok(false),
};
let Some(assets) = v.get("assets").and_then(|a| a.as_array()) else {
return Ok(false);
};
let Some(asset_url) = pick_source_tarball(assets, &repo) else {
return Ok(false);
};
std::fs::create_dir_all(dest)
.with_context(|| format!("creating tarball dest {}", dest.display()))?;
let status = Command::new("sh")
.arg("-c")
.arg(format!(
"curl -fsSL '{}' | tar -xz --strip-components=0 -C '{}'",
asset_url.replace('\'', "'\\''"),
dest.display(),
))
.status()
.context("spawning curl|tar pipeline")?;
if !status.success() {
bail!("downloading or extracting tarball {} failed", asset_url);
}
flatten_single_subdir(dest)?;
Ok(true)
}
fn parse_github_url(url: &str) -> Option<(String, String)> {
let rest = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
.or_else(|| url.strip_prefix("git@github.com:"))?;
let trimmed = rest.trim_end_matches('/').trim_end_matches(".git");
let mut parts = trimmed.splitn(2, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
if owner.is_empty() || repo.is_empty() {
return None;
}
Some((owner, repo))
}
fn curl_text(url: &str) -> Result<String> {
let mut cmd = Command::new("curl");
cmd.args([
"-fsSL",
"-H",
"User-Agent: vorto-grammar-installer",
"-H",
"Accept: application/vnd.github+json",
]);
if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN"))
&& !token.is_empty()
{
cmd.args(["-H", &format!("Authorization: Bearer {}", token)]);
}
cmd.arg(url);
let output = cmd.output().context("spawning curl")?;
if !output.status.success() {
bail!("curl {} exited with {}", url, output.status);
}
String::from_utf8(output.stdout).context("curl output not utf-8")
}
fn pick_source_tarball(assets: &[serde_json::Value], repo: &str) -> Option<String> {
let mut tarballs: Vec<(&str, &str)> = assets
.iter()
.filter_map(|a| {
let name = a.get("name")?.as_str()?;
let url = a.get("browser_download_url")?.as_str()?;
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
Some((name, url))
} else {
None
}
})
.collect();
tarballs.sort_by_key(|(name, _)| if name.starts_with(repo) { 0 } else { 1 });
tarballs.first().map(|(_, url)| url.to_string())
}
fn flatten_single_subdir(dir: &Path) -> Result<()> {
let entries: Vec<_> = std::fs::read_dir(dir)
.with_context(|| format!("reading {}", dir.display()))?
.collect::<std::io::Result<Vec<_>>>()
.with_context(|| format!("listing {}", dir.display()))?;
if entries.len() != 1 {
return Ok(());
}
let only = &entries[0];
if !only.file_type().map(|t| t.is_dir()).unwrap_or(false) {
return Ok(());
}
let inner = only.path();
for child in std::fs::read_dir(&inner)? {
let child = child?;
let from = child.path();
let to = dir.join(child.file_name());
std::fs::rename(&from, &to)
.with_context(|| format!("moving {} -> {}", from.display(), to.display()))?;
}
std::fs::remove_dir(&inner).ok();
Ok(())
}
fn which(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
let suffixes: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat", ""]
} else {
&[""]
};
for dir in std::env::split_paths(&path) {
for suf in suffixes {
let candidate = dir.join(format!("{}{}", name, suf));
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
fn clone(recipe: &GrammarRecipe, dest: &Path) -> Result<()> {
let mut cmd = Command::new("git");
cmd.args(["clone", "--quiet"]);
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<()> {
if !build_dir.join("src/parser.c").exists() && build_dir.join("grammar.js").exists() {
let status = Command::new("tree-sitter")
.arg("generate")
.current_dir(build_dir)
.status()
.context("spawning `tree-sitter generate`")?;
if !status.success() {
bail!("tree-sitter generate failed in {}", build_dir.display());
}
}
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());
}
}