use std::path::{Path, PathBuf};
use anyhow::{Context, Result, ensure};
use chrono::Utc;
use rusqlite::Connection;
use tracing::warn;
fn data_dir() -> PathBuf {
std::env::var_os("CATENARY_DATA_DIR")
.map(PathBuf::from)
.or_else(dirs::data_dir)
.or_else(dirs::data_local_dir)
.unwrap_or_else(|| PathBuf::from("/tmp"))
}
#[must_use]
pub fn grammar_dir() -> PathBuf {
data_dir().join("catenary").join("grammars")
}
#[must_use]
pub fn c_compiler_name() -> String {
std::env::var("CC").unwrap_or_else(|_| "cc".to_string())
}
fn resolve_spec(spec: &str) -> String {
if spec.contains("://") {
spec.to_string()
} else if spec.contains('/') {
format!("https://github.com/{spec}")
} else {
format!("https://github.com/tree-sitter/{spec}")
}
}
fn clone_repo(url: &str, dest: &Path) -> Result<()> {
let output = std::process::Command::new("git")
.args(["clone", "--depth", "1", "--quiet", url])
.arg(dest)
.output()
.context("failed to run git clone — is git installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git clone failed: {}", stderr.trim());
}
Ok(())
}
fn compile_grammar(src_dir: &Path, output_path: &Path) -> Result<()> {
let scanner_cc = src_dir.join("scanner.cc");
if scanner_cc.exists() {
compile_mixed(src_dir, output_path, &scanner_cc)
} else {
compile_c_only(src_dir, output_path)
}
}
fn cc_builder(cpp: bool) -> cc::Build {
let target = env!("TARGET");
let mut build = cc::Build::new();
build
.target(target)
.host(target)
.opt_level(0)
.cpp(cpp)
.cargo_metadata(false);
build
}
fn c_compiler() -> Result<cc::Tool> {
cc_builder(false)
.try_get_compiler()
.map_err(|e| anyhow::anyhow!("failed to find C compiler: {e}"))
}
fn cpp_compiler() -> Result<cc::Tool> {
cc_builder(true)
.try_get_compiler()
.map_err(|e| anyhow::anyhow!("failed to find C++ compiler: {e}"))
}
fn compile_c_only(src_dir: &Path, output_path: &Path) -> Result<()> {
let compiler = c_compiler()?;
let mut cmd = compiler.to_command();
cmd.arg("-shared")
.arg("-fPIC")
.arg("-I")
.arg(src_dir)
.arg("-o")
.arg(output_path)
.arg(src_dir.join("parser.c"));
if src_dir.join("scanner.c").exists() {
cmd.arg(src_dir.join("scanner.c"));
}
let output = cmd.output().context("failed to run C compiler")?;
ensure!(
output.status.success(),
"grammar compilation failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
fn compile_mixed(src_dir: &Path, output_path: &Path, scanner_cc: &Path) -> Result<()> {
let tmpdir = tempfile::tempdir().context("failed to create temp directory for compilation")?;
let cc = c_compiler()?;
let cxx = cpp_compiler()?;
let parser_o = tmpdir.path().join("parser.o");
let output = cc
.to_command()
.args(["-c", "-fPIC"])
.arg("-I")
.arg(src_dir)
.arg("-o")
.arg(&parser_o)
.arg(src_dir.join("parser.c"))
.output()
.context("failed to compile parser.c")?;
ensure!(
output.status.success(),
"parser.c compilation failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let mut objects = vec![parser_o];
let scanner_c = src_dir.join("scanner.c");
if scanner_c.exists() {
let scanner_c_o = tmpdir.path().join("scanner_c.o");
let output = cc
.to_command()
.args(["-c", "-fPIC"])
.arg("-I")
.arg(src_dir)
.arg("-o")
.arg(&scanner_c_o)
.arg(&scanner_c)
.output()
.context("failed to compile scanner.c")?;
ensure!(
output.status.success(),
"scanner.c compilation failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
objects.push(scanner_c_o);
}
let scanner_cc_o = tmpdir.path().join("scanner_cc.o");
let output = cxx
.to_command()
.args(["-c", "-fPIC"])
.arg("-I")
.arg(src_dir)
.arg("-o")
.arg(&scanner_cc_o)
.arg(scanner_cc)
.output()
.context("failed to compile scanner.cc")?;
ensure!(
output.status.success(),
"scanner.cc compilation failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
objects.push(scanner_cc_o);
let mut link_cmd = cxx.to_command();
link_cmd.arg("-shared").arg("-o").arg(output_path);
for obj in &objects {
link_cmd.arg(obj);
}
let output = link_cmd.output().context("failed to link grammar")?;
ensure!(
output.status.success(),
"grammar linking failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}
fn install_from_dir(
repo_dir: &Path,
grammar_base: &Path,
db: &Connection,
repo_url: &str,
) -> Result<()> {
let ts_json_path = repo_dir.join("tree-sitter.json");
let ts_json_content = std::fs::read_to_string(&ts_json_path)
.with_context(|| format!("failed to read {}", ts_json_path.display()))?;
let ts_json: serde_json::Value =
serde_json::from_str(&ts_json_content).context("failed to parse tree-sitter.json")?;
let grammar = ts_json
.get("grammars")
.and_then(|g| g.get(0))
.ok_or_else(|| anyhow::anyhow!("tree-sitter.json missing grammars[0]"))?;
let scope = grammar
.get("scope")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| anyhow::anyhow!("tree-sitter.json missing grammars[0].scope"))?;
let file_types = grammar
.get("file-types")
.ok_or_else(|| anyhow::anyhow!("tree-sitter.json missing grammars[0].file-types"))?;
let file_types_str =
serde_json::to_string(file_types).context("failed to serialize file-types")?;
let tags_src = repo_dir.join("queries").join("tags.scm");
ensure!(
tags_src.exists(),
"Grammar {scope} does not ship tags.scm. The language will use \
the no-grammar path (ripgrep text heatmap) until the grammar \
adds tag query support."
);
let src_dir = repo_dir.join("src");
ensure!(
src_dir.join("parser.c").exists(),
"src/parser.c not found in grammar repository"
);
let scope_dir = grammar_base.join(scope);
std::fs::create_dir_all(&scope_dir)
.with_context(|| format!("failed to create directory: {}", scope_dir.display()))?;
let lib_filename = format!("parser.{}", std::env::consts::DLL_EXTENSION);
let lib_path = scope_dir.join(&lib_filename);
compile_grammar(&src_dir, &lib_path)?;
let tags_path = scope_dir.join("tags.scm");
std::fs::copy(&tags_src, &tags_path)
.with_context(|| format!("failed to copy tags.scm to {}", tags_path.display()))?;
let now = Utc::now().to_rfc3339();
db.execute(
"INSERT OR REPLACE INTO grammars \
(scope, file_types, lib_path, tags_path, repo_url, installed_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
scope,
file_types_str,
lib_path.to_string_lossy().as_ref(),
tags_path.to_string_lossy().as_ref(),
repo_url,
now,
],
)
.context("failed to register grammar in database")?;
Ok(())
}
pub fn install_grammar(spec: &str, db: &Connection) -> Result<()> {
let grammar_base = grammar_dir();
let local_path = Path::new(spec);
if local_path.is_dir() {
return install_from_dir(local_path, &grammar_base, db, spec);
}
let url = resolve_spec(spec);
let tmp = tempfile::tempdir().context("failed to create temp directory")?;
clone_repo(&url, tmp.path())?;
install_from_dir(tmp.path(), &grammar_base, db, &url)
}
#[allow(clippy::print_stdout, reason = "CLI command output")]
pub fn list_grammars(db: &Connection) -> Result<()> {
let mut stmt =
db.prepare("SELECT scope, file_types, installed_at FROM grammars ORDER BY scope")?;
let rows: Vec<(String, String, String)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?
.collect::<Result<Vec<_>, _>>()
.context("failed to query grammars")?;
if rows.is_empty() {
println!("No grammars installed.");
return Ok(());
}
let installed_header = "INSTALLED";
println!("{:<25} {:<20} {installed_header}", "SCOPE", "FILE TYPES");
for (scope, file_types, installed_at) in &rows {
println!("{scope:<25} {file_types:<20} {installed_at}");
}
Ok(())
}
pub fn remove_grammar(scope: &str, db: &Connection) -> Result<()> {
let (lib_path, tags_path): (String, String) = db
.query_row(
"SELECT lib_path, tags_path FROM grammars WHERE scope = ?1",
[scope],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.with_context(|| format!("grammar '{scope}' not found"))?;
if let Err(e) = std::fs::remove_file(&lib_path) {
warn!("failed to delete {lib_path}: {e}");
}
if let Err(e) = std::fs::remove_file(&tags_path) {
warn!("failed to delete {tags_path}: {e}");
}
if let Some(parent) = Path::new(&lib_path).parent() {
let _ = std::fs::remove_dir(parent);
}
db.execute(
"DELETE FROM symbols WHERE file_path IN \
(SELECT file_path FROM file_parse_state WHERE grammar = ?1)",
[scope],
)
.context("failed to delete symbols")?;
db.execute("DELETE FROM file_parse_state WHERE grammar = ?1", [scope])
.context("failed to delete file parse state")?;
db.execute("DELETE FROM grammars WHERE scope = ?1", [scope])
.context("failed to delete grammar")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("test_assets")
.join("mock_grammar")
}
#[allow(clippy::expect_used, reason = "test setup")]
fn test_db() -> (tempfile::TempDir, Connection) {
let dir = tempfile::tempdir().expect("failed to create tempdir");
let conn =
db::open_and_migrate_at(&dir.path().join("test.db")).expect("failed to create test db");
(dir, conn)
}
#[test]
fn test_resolve_spec_bare_name() {
assert_eq!(
resolve_spec("tree-sitter-rust"),
"https://github.com/tree-sitter/tree-sitter-rust"
);
}
#[test]
fn test_resolve_spec_owner_repo() {
assert_eq!(
resolve_spec("MarkWellsDev/tree-sitter-mock"),
"https://github.com/MarkWellsDev/tree-sitter-mock"
);
}
#[test]
fn test_resolve_spec_full_url() {
let url = "https://gitlab.com/user/tree-sitter-custom.git";
assert_eq!(resolve_spec(url), url);
}
#[allow(clippy::expect_used, reason = "test assertions")]
#[test]
fn test_install_and_list() {
let (_db_dir, db) = test_db();
let out_dir = tempfile::tempdir().expect("tempdir");
install_from_dir(
&fixture_dir(),
out_dir.path(),
&db,
"https://github.com/test/mock",
)
.expect("install should succeed");
let scope: String = db
.query_row(
"SELECT scope FROM grammars WHERE scope = 'source.mock'",
[],
|row| row.get(0),
)
.expect("grammar should be in DB");
assert_eq!(scope, "source.mock");
let file_types: String = db
.query_row(
"SELECT file_types FROM grammars WHERE scope = 'source.mock'",
[],
|row| row.get(0),
)
.expect("file_types query");
assert_eq!(file_types, r#"["mock"]"#);
let lib_ext = std::env::consts::DLL_EXTENSION;
let lib = out_dir
.path()
.join("source.mock")
.join(format!("parser.{lib_ext}"));
let tags = out_dir.path().join("source.mock").join("tags.scm");
assert!(
lib.exists(),
"compiled library should exist at {}",
lib.display()
);
assert!(tags.exists(), "tags.scm should exist at {}", tags.display());
}
#[allow(clippy::expect_used, reason = "test assertions")]
#[test]
fn test_install_missing_tags_scm() {
let (_db_dir, db) = test_db();
let out_dir = tempfile::tempdir().expect("tempdir");
let no_tags = tempfile::tempdir().expect("tempdir");
let src = no_tags.path().join("src");
std::fs::create_dir_all(src.join("tree_sitter")).expect("mkdir");
std::fs::copy(
fixture_dir().join("src").join("parser.c"),
src.join("parser.c"),
)
.expect("copy parser.c");
std::fs::copy(
fixture_dir()
.join("src")
.join("tree_sitter")
.join("parser.h"),
src.join("tree_sitter").join("parser.h"),
)
.expect("copy parser.h");
std::fs::copy(
fixture_dir().join("tree-sitter.json"),
no_tags.path().join("tree-sitter.json"),
)
.expect("copy tree-sitter.json");
let result = install_from_dir(no_tags.path(), out_dir.path(), &db, "test");
let err = result
.expect_err("should fail without tags.scm")
.to_string();
assert!(
err.contains("tags.scm"),
"error should mention tags.scm, got: {err}"
);
}
#[allow(clippy::expect_used, reason = "test assertions")]
#[test]
fn test_remove_grammar() {
let (_db_dir, db) = test_db();
let out_dir = tempfile::tempdir().expect("tempdir");
install_from_dir(
&fixture_dir(),
out_dir.path(),
&db,
"https://github.com/test/mock",
)
.expect("install should succeed");
remove_grammar("source.mock", &db).expect("remove should succeed");
let count: i64 = db
.query_row(
"SELECT COUNT(*) FROM grammars WHERE scope = 'source.mock'",
[],
|row| row.get(0),
)
.expect("count query");
assert_eq!(count, 0, "grammar should be removed from DB");
let lib_ext = std::env::consts::DLL_EXTENSION;
let lib = out_dir
.path()
.join("source.mock")
.join(format!("parser.{lib_ext}"));
assert!(!lib.exists(), "compiled library should be deleted");
}
#[allow(clippy::expect_used, reason = "test assertions")]
#[test]
fn test_load_grammar_api() {
let (_db_dir, db) = test_db();
let out_dir = tempfile::tempdir().expect("tempdir");
install_from_dir(
&fixture_dir(),
out_dir.path(),
&db,
"https://github.com/test/mock",
)
.expect("install should succeed");
let lib_path: String = db
.query_row(
"SELECT lib_path FROM grammars WHERE scope = 'source.mock'",
[],
|row| row.get(0),
)
.expect("lib_path query");
let language = catenary_ts::load_grammar(Path::new(&lib_path), "tree_sitter_mock")
.expect("load_grammar should succeed");
assert_eq!(language.version(), 14, "grammar version should be 14");
}
}