//! Grammar loader — walks the lookup chain to resolve a language name to a
//! ready-to-`dlopen` parser, installing the highlights query alongside.
//!
//! # ⚠️ Security: step 3 downloads and compiles remote code
//!
//! The **build-on-demand** step below clones an upstream repository over the
//! network and compiles its C/C++ with the system compiler; the resulting
//! shared library is later `dlopen`ed and executed in-process. Both the
//! compile and the load run **arbitrary native code with the caller's
//! privileges** and are not sandboxed. Steps 1 and 2 (system / user dirs) do
//! not fetch or build — they load a `.so` that is already on disk. Use
//! [`GrammarLoader::lookup_only`] when a caller must never trigger the fetch
//! + build path. See the crate-root docs for the full trust model.
//!
//! Lookup order:
//!   1. **System**: `<system_dir>/<name><ext>` — distro-shipped, never built.
//!   2. **User**: `<user_dir>/<name><ext>` — durable user-data layer; this
//!      is also where on-demand builds get installed to so they survive
//!      across runs.
//!   3. **Build on demand**: clone source via [`SourceCache`] (network),
//!      compile via [`GrammarCompiler`] (runs the system C/C++ compiler on the
//!      downloaded source, writing `<source_root>/<name><ext>`), then install
//!      the parser + resolved `highlights.scm` into `<user_dir>/<name><ext>` +
//!      `<user_dir>/<name>.scm`.
//!
//! Layout written by the install step (and expected by [`Grammar::load`]):
//! - `<user_dir>/<name><ext>` — parser
//! - `<user_dir>/<name>.scm`  — highlights query, written verbatim
//!   (un-sanitized) so runtime pre-extraction of `(#set! @cap ...)` directives
//!   works on installed grammars
//! - `<user_dir>/<name>.rev`  — sidecar `<git_rev>:<query_rev>:abi<N>` for
//!   staleness detection. Updating either the grammar rev or the query-source rev
//!   triggers a re-install (overwriting in place). Old two-field sidecars parse
//!   as stale → rebuild (correct behavior across the schema bump).
//!   System dirs are *not* rev-checked: distro packagers own that lifecycle.
//!
//! Distro maintainers shipping pre-built grammars should reproduce the
//! parser + .scm pair under one of `system_dirs`. `cargo xtask
//! build-grammars` does exactly this (and writes the sidecar too).

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};

use super::compile::{GrammarCompiler, shared_lib_ext};
use super::manifest::{LangSpec, ManifestMeta};
use super::source::{QuerySourceCache, SourceCache, is_safe_component};
use super::xdg;

/// Configurable grammar resolver. Construct once, reuse across lookups.
#[derive(Debug, Clone)]
pub struct GrammarLoader {
    system_dirs: Vec<PathBuf>,
    user_dir: PathBuf,
    sources: SourceCache,
    query_sources: QuerySourceCache,
    compiler: GrammarCompiler,
}

impl GrammarLoader {
    /// Build a loader with explicit lookup paths. `user_dir` is both the
    /// place we look first (after `system_dirs`) and the install target
    /// for on-demand builds.
    pub fn new(
        system_dirs: Vec<PathBuf>,
        user_dir: PathBuf,
        sources: SourceCache,
        query_sources: QuerySourceCache,
        compiler: GrammarCompiler,
    ) -> Self {
        Self {
            system_dirs,
            user_dir,
            sources,
            query_sources,
            compiler,
        }
    }

    /// Default loader for end-user installs:
    /// - system: `/usr/share/bonsai/grammars/`,
    ///   `/usr/local/share/bonsai/grammars/` (Unix only)
    /// - user:   `$XDG_DATA_HOME/bonsai/grammars/` (XDG-everywhere; falls back
    ///   to `~/.local/share/bonsai/grammars/` on every platform)
    /// - sources / compile: [`SourceCache::user_default`] +
    ///   [`GrammarCompiler::new`]
    pub fn user_default(_meta: &ManifestMeta) -> Result<Self> {
        let system_dirs = if cfg!(target_family = "unix") {
            vec![
                PathBuf::from("/usr/share/bonsai/grammars"),
                PathBuf::from("/usr/local/share/bonsai/grammars"),
            ]
        } else {
            Vec::new()
        };
        let user = xdg::data_home()?.join("bonsai/grammars");
        Ok(Self::new(
            system_dirs,
            user,
            SourceCache::user_default()?,
            QuerySourceCache::user_default()?,
            GrammarCompiler::new(),
        ))
    }

    /// User install dir. Parser + queries install here on demand.
    pub fn user_dir(&self) -> &Path {
        &self.user_dir
    }

    /// Resolve `name` (with its [`LangSpec`]) to a shared library path,
    /// triggering a clone + compile + install when no fresh shipped
    /// artifact exists. A user-dir hit whose `<name>.rev` sidecar
    /// disagrees with `spec.git_rev` / current ABI is treated as stale
    /// and recompiled (overwriting in place).
    ///
    /// ⚠️ **Security:** on a cache miss this clones the upstream repo over
    /// the network and compiles its C/C++ with the system compiler — i.e. it
    /// **downloads and builds remote code** that is subsequently `dlopen`ed
    /// and run in-process. Only call this for grammars whose manifest entry
    /// you trust. Use [`Self::lookup_only`] to resolve without ever fetching
    /// or building.
    pub fn load(&self, name: &str, spec: &LangSpec, meta: &ManifestMeta) -> Result<PathBuf> {
        // `name` is joined into system/user/cache paths (`<name>.so`, `<name>.scm`,
        // `<name>.rev`, the source clone dir); reject traversal up front so an
        // untrusted manifest key can't escape those directories.
        if !is_safe_component(name) {
            bail!("unsafe grammar name {name:?}: must be a single path component");
        }
        if let Some(p) = self.lookup_fresh(name, spec, meta) {
            return Ok(p);
        }

        // Two hjkl instances can reach this point for the same grammar at the
        // same moment, and everything past it is shared: one clone dir, one
        // compile output, one set of installed files. Serialize the whole
        // decide → clone → compile → install sequence on the parser's own
        // destination path. The lock has to span the *decision* too, not just
        // the writes — the loser of the race re-checks `lookup_fresh` once it
        // gets in and returns the winner's install instead of rebuilding it.
        //
        // The locks are re-entrant per thread, so the atomic writes inside the
        // install (which lock their own targets) nest safely under this one.
        let dest = self.user_dir.join(format!("{name}{}", shared_lib_ext()));
        std::fs::create_dir_all(&self.user_dir)
            .with_context(|| format!("create user dir {}", self.user_dir.display()))?;
        hjkl_fs::with_lock_exclusive(&dest, || Ok(self.build_and_install(name, spec, meta)))
            .with_context(|| format!("lock grammar install {name}"))?
    }

    /// The fetch → compile → install tail of [`Self::load`], run while holding
    /// the install lock on the destination parser path.
    fn build_and_install(
        &self,
        name: &str,
        spec: &LangSpec,
        meta: &ManifestMeta,
    ) -> Result<PathBuf> {
        // Re-check under the lock: a concurrent installer may have finished
        // between our first miss and our acquiring the lock, and rebuilding on
        // top of its files buys nothing.
        if let Some(p) = self.lookup_fresh(name, spec, meta) {
            return Ok(p);
        }

        let source_root = self
            .sources
            .acquire(name, spec)
            .with_context(|| format!("acquire source for {name}"))?;
        let built = self
            .compiler
            .compile(name, spec, &source_root)
            .with_context(|| format!("compile grammar {name}"))?;

        let highlights_src = self
            .query_sources
            .resolve_highlights(spec.query_source, meta, name, spec.query_subdir.as_deref())
            .with_context(|| format!("resolve highlights for {name}"))?;

        // Injections come from the grammar's own source tree (not the curated
        // query repos — those use non-standard predicates). Optional: None when
        // the grammar does not ship injections.scm.
        let injections_src = self
            .sources
            .injections_path(&source_root)
            .with_context(|| format!("check injections for {name}"))?;

        let query_rev = match spec.query_source {
            crate::runtime::manifest::QuerySource::Helix => meta.helix_rev.as_str(),
            crate::runtime::manifest::QuerySource::NvimTreesitter => {
                meta.nvim_treesitter_rev.as_str()
            }
        };
        install_into_user_dir(
            name,
            spec,
            &built,
            &highlights_src,
            injections_src.as_deref(),
            query_rev,
            &self.user_dir,
        )
        .with_context(|| format!("install grammar {name}"))
    }

    /// Look up an installed parser **with** the freshness check applied
    /// to the user-dir tier. System dirs are returned on first hit
    /// regardless of rev (distro's responsibility). User-dir hits are
    /// filtered against the sidecar — stale installs read as `None` so
    /// the caller falls through to a recompile.
    pub fn lookup_fresh(
        &self,
        name: &str,
        spec: &LangSpec,
        meta: &ManifestMeta,
    ) -> Option<PathBuf> {
        let flat = format!("{name}{}", shared_lib_ext());
        for dir in &self.system_dirs {
            let candidate = dir.join(&flat);
            if candidate.is_file() {
                return Some(candidate);
            }
        }
        let user_candidate = self.user_dir.join(&flat);
        if user_candidate.is_file() && is_user_install_fresh(&self.user_dir, name, spec, meta) {
            return Some(user_candidate);
        }
        None
    }

    /// Lower-level lookup that ignores the sidecar — returns the first
    /// `<name><ext>` found under any lookup dir even if it's stale. Use
    /// this when you want to know "is anything installed at all" rather
    /// than "is there something we can use right now."
    ///
    /// This is the **fetch-free** resolution path: it only inspects
    /// already-installed files and never clones or compiles remote code.
    /// Prefer it (over [`Self::load`]) in contexts that must not download and
    /// build untrusted grammars; treat a `None` as "no highlighting."
    pub fn lookup_only(&self, name: &str) -> Option<PathBuf> {
        let flat = format!("{name}{}", shared_lib_ext());
        for dir in self
            .system_dirs
            .iter()
            .chain(std::iter::once(&self.user_dir))
        {
            let candidate = dir.join(&flat);
            if candidate.is_file() {
                return Some(candidate);
            }
        }
        None
    }
}

/// Read `<user_dir>/<name>.rev` and check it matches the spec's pinned
/// rev + query-source rev + the tree-sitter ABI we're built against. Missing
/// or unparseable sidecars count as stale.
fn is_user_install_fresh(
    user_dir: &Path,
    name: &str,
    spec: &LangSpec,
    meta: &ManifestMeta,
) -> bool {
    let rev_path = user_dir.join(format!("{name}.rev"));
    let Ok(content) = std::fs::read_to_string(&rev_path) else {
        return false;
    };
    let Some((grammar_rev, query_rev, abi)) = parse_rev_sidecar(content.trim()) else {
        return false;
    };
    let expected_query_rev = match spec.query_source {
        crate::runtime::manifest::QuerySource::Helix => meta.helix_rev.as_str(),
        crate::runtime::manifest::QuerySource::NvimTreesitter => meta.nvim_treesitter_rev.as_str(),
    };
    grammar_rev == spec.git_rev
        && query_rev == expected_query_rev
        && abi == tree_sitter::LANGUAGE_VERSION
}

/// Parse the three-field `<git_rev>:<query_rev>:abi<N>` payload.
/// `None` when malformed (including old two-field format → stale → rebuild).
fn parse_rev_sidecar(s: &str) -> Option<(&str, &str, usize)> {
    let mut parts = s.splitn(3, ':');
    let grammar_rev = parts.next()?;
    let query_rev = parts.next()?;
    let abi_part = parts.next()?;
    let abi_str = abi_part.strip_prefix("abi")?;
    let abi = abi_str.parse().ok()?;
    Some((grammar_rev, query_rev, abi))
}

/// Copy `built_so` to `<user_dir>/<name><ext>`, write `highlights_src` to
/// `<user_dir>/<name>.scm` **verbatim** (un-sanitized — capture-form
/// `(#set! @cap ...)` directives must survive for the runtime's pre-extraction;
/// see [`super::super::highlighter`]'s `compile_query`), optionally copy
/// `injections_src` to `<user_dir>/<name>.injections.scm`, then write the
/// `<user_dir>/<name>.rev` sidecar **last** so an interrupted install leaves no
/// partial set of files. Returns the installed parser path.
fn install_into_user_dir(
    name: &str,
    spec: &LangSpec,
    built_so: &Path,
    highlights_src: &Path,
    injections_src: Option<&Path>,
    query_rev: &str,
    user_dir: &Path,
) -> Result<PathBuf> {
    if !highlights_src.is_file() {
        bail!(
            "highlights.scm missing in source clone: {}",
            highlights_src.display()
        );
    }

    std::fs::create_dir_all(user_dir)
        .with_context(|| format!("create user dir {}", user_dir.display()))?;

    let dest = user_dir.join(format!("{name}{}", shared_lib_ext()));
    copy_atomic(built_so, &dest)?;

    let highlights_dest = user_dir.join(format!("{name}.scm"));
    let highlights_raw = std::fs::read_to_string(highlights_src)
        .with_context(|| format!("read {}", highlights_src.display()))?;
    // Write the query VERBATIM — never sanitize. Capture-form `(#set! @cap ...)`
    // directives must survive to runtime, where `compile_query` pre-extracts
    // them and applies them as per-capture metadata; the sanitizer strips those
    // forms, which would kill pre-extraction for every installed grammar.
    write_atomic(&highlights_dest, highlights_raw.as_bytes())?;

    if let Some(inj_src) = injections_src {
        let inj_dest = user_dir.join(format!("{name}.injections.scm"));
        copy_atomic(inj_src, &inj_dest)?;
    }

    let rev_dest = user_dir.join(format!("{name}.rev"));
    let rev_payload = format!(
        "{}:{}:abi{}",
        spec.git_rev,
        query_rev,
        tree_sitter::LANGUAGE_VERSION
    );
    write_atomic(&rev_dest, rev_payload.as_bytes())?;

    Ok(dest)
}

/// Write `bytes` to `to` through the disk-I/O seam. The seam's staging name
/// carries the pid and a process counter, so two installers of the same grammar
/// never share a staging file and neither one's cleanup can delete the other's
/// in-flight write.
fn write_atomic(to: &Path, bytes: &[u8]) -> Result<()> {
    hjkl_fs::write_atomic(to, bytes, &hjkl_fs::WriteOptions::default())
        .with_context(|| format!("write {}", to.display()))
}

/// Copy `from` to `to` through the disk-I/O seam ([`hjkl_fs::copy_atomic`]:
/// temp → fsync → rename → fsync parent, with the source's mode carried over
/// exactly as `fs::copy` did). Tolerates a concurrent install winning the race
/// (treats existing `to` as success).
fn copy_atomic(from: &Path, to: &Path) -> Result<()> {
    match hjkl_fs::copy_atomic(from, to, &hjkl_fs::WriteOptions::default()) {
        Ok(()) => Ok(()),
        // A concurrent installer renamed its own copy into place first. Unix
        // `rename` replaces the destination so this is rare there, but Windows
        // fails the rename when the name is taken — and the file that is now at
        // `to` is the same artifact we were installing, so this is success, not
        // a failed install. Restricted to the error kinds a lost rename race
        // actually produces: a mid-copy `StorageFull` or a `NotFound` source
        // must still surface, or a stale `to` would be certified fresh by the
        // `.rev` sidecar written afterwards.
        Err(e)
            if matches!(
                e.kind(),
                std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
            ) && to.exists() =>
        {
            Ok(())
        }
        Err(e) => Err(e).with_context(|| format!("copy {} -> {}", from.display(), to.display())),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::runtime::manifest::QuerySource;
    use crate::runtime::source::QuerySourceCache;

    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(),
        }
    }

    fn dummy_spec(rev: &str) -> LangSpec {
        LangSpec {
            git_url: "https://example/repo".into(),
            git_rev: rev.into(),
            subpath: None,
            extensions: vec!["x".into()],
            c_files: vec!["src/parser.c".into()],
            query_source: QuerySource::Helix,
            query_subdir: None,
            source: None,
        }
    }

    fn touch(path: &std::path::Path) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, b"\0\0\0\0").unwrap();
    }

    fn loader_with(
        system_dirs: Vec<PathBuf>,
        user_dir: PathBuf,
        cache_root: PathBuf,
        query_cache_root: PathBuf,
    ) -> GrammarLoader {
        GrammarLoader::new(
            system_dirs,
            user_dir,
            SourceCache::new(cache_root),
            QuerySourceCache::new(query_cache_root),
            GrammarCompiler::new(),
        )
    }

    fn write_rev_sidecar(
        user_dir: &Path,
        name: &str,
        grammar_rev: &str,
        query_rev: &str,
        abi: usize,
    ) {
        std::fs::create_dir_all(user_dir).unwrap();
        std::fs::write(
            user_dir.join(format!("{name}.rev")),
            format!("{grammar_rev}:{query_rev}:abi{abi}"),
        )
        .unwrap();
    }

    #[test]
    fn system_dir_wins_over_user() {
        let tmp = tempfile::tempdir().unwrap();
        let sys = tmp.path().join("sys");
        let user = tmp.path().join("user");
        let loader = loader_with(
            vec![sys.clone()],
            user.clone(),
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );

        let name = "rust";
        let want = sys.join(format!("{name}{}", shared_lib_ext()));
        let other = user.join(format!("{name}{}", shared_lib_ext()));
        touch(&want);
        touch(&other);

        assert_eq!(loader.lookup_only(name), Some(want));
    }

    #[test]
    fn user_dir_used_when_no_system_match() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");
        let loader = loader_with(
            vec![],
            user.clone(),
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );

        let name = "python";
        let want = user.join(format!("{name}{}", shared_lib_ext()));
        touch(&want);

        assert_eq!(loader.lookup_only(name), Some(want));
    }

    #[test]
    fn lookup_only_returns_none_when_nothing_exists() {
        let tmp = tempfile::tempdir().unwrap();
        let loader = loader_with(
            vec![],
            tmp.path().join("user"),
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );
        assert!(loader.lookup_only("nope").is_none());
    }

    #[test]
    fn load_short_circuits_on_fresh_user_install() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");
        let loader = loader_with(
            vec![],
            user.clone(),
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );
        let meta = dummy_meta();

        let name = "fake";
        let rev = "0000000000000000";
        let spec = LangSpec {
            git_url: "https://invalid.invalid/should-never-be-fetched".into(),
            git_rev: rev.into(),
            subpath: None,
            extensions: vec!["x".into()],
            c_files: vec!["src/parser.c".into()],
            query_source: QuerySource::Helix,
            query_subdir: None,
            source: None,
        };
        let pre = user.join(format!("{name}{}", shared_lib_ext()));
        touch(&pre);
        write_rev_sidecar(
            &user,
            name,
            rev,
            &meta.helix_rev,
            tree_sitter::LANGUAGE_VERSION,
        );

        let resolved = loader.load(name, &spec, &meta).unwrap();
        assert_eq!(resolved, pre);
    }

    #[test]
    fn lookup_fresh_misses_when_sidecar_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");
        let loader = loader_with(
            vec![],
            user.clone(),
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );
        let meta = dummy_meta();

        let spec = dummy_spec("aaaaaaaaaaaa");
        let pre = user.join(format!("rust{}", shared_lib_ext()));
        touch(&pre);

        assert!(loader.lookup_fresh("rust", &spec, &meta).is_none());
        assert_eq!(loader.lookup_only("rust"), Some(pre));
    }

    #[test]
    fn lookup_fresh_misses_when_rev_changed() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");
        let loader = loader_with(
            vec![],
            user.clone(),
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );
        let meta = dummy_meta();

        let spec = dummy_spec("new-rev-aaaa");
        let pre = user.join(format!("rust{}", shared_lib_ext()));
        touch(&pre);
        write_rev_sidecar(
            &user,
            "rust",
            "old-rev-bbbb",
            &meta.helix_rev,
            tree_sitter::LANGUAGE_VERSION,
        );

        assert!(loader.lookup_fresh("rust", &spec, &meta).is_none());
    }

    #[test]
    fn lookup_fresh_misses_when_abi_changed() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");
        let loader = loader_with(
            vec![],
            user.clone(),
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );
        let meta = dummy_meta();

        let rev = "deadbeef0000";
        let spec = dummy_spec(rev);
        let pre = user.join(format!("rust{}", shared_lib_ext()));
        touch(&pre);
        let stale_abi = tree_sitter::LANGUAGE_VERSION + 1;
        write_rev_sidecar(&user, "rust", rev, &meta.helix_rev, stale_abi);

        assert!(loader.lookup_fresh("rust", &spec, &meta).is_none());
    }

    #[test]
    fn lookup_fresh_skips_sidecar_check_for_system_dirs() {
        let tmp = tempfile::tempdir().unwrap();
        let sys = tmp.path().join("sys");
        let user = tmp.path().join("user");
        let loader = loader_with(
            vec![sys.clone()],
            user,
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );
        let meta = dummy_meta();

        let spec = dummy_spec("aaaaaaaaaaaa");
        let want = sys.join(format!("rust{}", shared_lib_ext()));
        touch(&want);

        assert_eq!(loader.lookup_fresh("rust", &spec, &meta), Some(want));
    }

    #[test]
    fn parse_rev_sidecar_parses_normal_payload() {
        assert_eq!(
            parse_rev_sidecar("abc123:deadbeef0000:abi15"),
            Some(("abc123", "deadbeef0000", 15))
        );
    }

    #[test]
    fn parse_rev_sidecar_rejects_old_two_field_format() {
        // Old format "rev:abiN" must parse as stale (None) so stale installs rebuild.
        assert!(parse_rev_sidecar("abc123:abi15").is_none());
    }

    #[test]
    fn parse_rev_sidecar_rejects_malformed() {
        assert!(parse_rev_sidecar("no-colon").is_none());
        assert!(parse_rev_sidecar("rev:qrev:abi").is_none()); // missing number
        assert!(parse_rev_sidecar("rev:qrev:15").is_none()); // missing abi prefix
    }

    #[test]
    fn install_copies_parser_highlights_and_writes_rev_sidecar() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");

        // Pre-resolved highlights.scm (the loader hands in an already-resolved path).
        let highlights_src = tmp.path().join("rust.resolved.scm");
        std::fs::write(&highlights_src, "; highlights").unwrap();

        let built = tmp.path().join(format!("rust{}", shared_lib_ext()));
        std::fs::write(&built, b"fake parser bytes").unwrap();

        let rev = "deadbeef00000000";
        let query_rev = "aaaa0000bbbb1111cccc2222dddd3333eeee4444";
        let spec = dummy_spec(rev);
        let installed = install_into_user_dir(
            "rust",
            &spec,
            &built,
            &highlights_src,
            None,
            query_rev,
            &user,
        )
        .unwrap();

        assert_eq!(installed, user.join(format!("rust{}", shared_lib_ext())));
        assert!(installed.is_file());
        assert!(user.join("rust.scm").is_file());
        assert!(!user.join("rust").exists());

        // The query is written verbatim, never sanitized.
        let scm = std::fs::read_to_string(user.join("rust.scm")).unwrap();
        assert_eq!(scm, "; highlights");

        let rev_payload = std::fs::read_to_string(user.join("rust.rev")).unwrap();
        assert_eq!(
            rev_payload,
            format!("{rev}:{query_rev}:abi{}", tree_sitter::LANGUAGE_VERSION)
        );
    }

    #[test]
    fn install_preserves_capture_form_set_directives_verbatim() {
        // Regression: the installer used to run `sanitize_highlights` on the
        // query before writing `<name>.scm`, which strips capture-form
        // `(#set! @cap ...)` directives — killing the highlighter's
        // pre-extraction feature for every installed grammar. The installed
        // file must carry the raw query text, and neither the `.scm.raw`
        // backup nor the `.query_sanitized` flag may be written.
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");

        let raw_query = concat!(
            "((tag_name) @tag)\n",
            "((tag_name) @_u\n",
            "  (#set! @_u url @_u))\n",
        );
        let highlights_src = tmp.path().join("html.resolved.scm");
        std::fs::write(&highlights_src, raw_query).unwrap();

        let built = tmp.path().join(format!("html{}", shared_lib_ext()));
        std::fs::write(&built, b"fake parser bytes").unwrap();

        let spec = dummy_spec("deadbeef00000000");
        install_into_user_dir("html", &spec, &built, &highlights_src, None, "qrev", &user).unwrap();

        let installed = std::fs::read_to_string(user.join("html.scm")).unwrap();
        assert_eq!(
            installed, raw_query,
            "installed .scm must be the raw query verbatim"
        );
        assert!(
            installed.contains("(#set! @_u url @_u)"),
            "capture-form directive must survive install; got: {installed}"
        );
        assert!(
            !user.join("html.scm.raw").exists(),
            "dead .scm.raw backup must not be written"
        );
        assert!(
            !user.join("html.query_sanitized").exists(),
            "dead .query_sanitized flag must not be written"
        );
    }

    #[test]
    fn install_errors_when_no_highlights_present() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");
        let highlights_src = tmp.path().join("ghost.scm"); // does not exist
        let built = tmp.path().join(format!("rust{}", shared_lib_ext()));
        std::fs::write(&built, b"x").unwrap();

        let spec = dummy_spec("deadbeef00000000");
        let err =
            install_into_user_dir("rust", &spec, &built, &highlights_src, None, "qrev", &user)
                .unwrap_err();
        assert!(
            err.to_string().contains("highlights.scm missing"),
            "got: {err:#}"
        );
    }

    #[test]
    fn install_with_preresolved_path_produces_all_three_files() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");

        let highlights_src = tmp.path().join("xml.resolved.scm");
        std::fs::write(&highlights_src, "; xml highlights").unwrap();

        let built = tmp.path().join(format!("xml{}", shared_lib_ext()));
        std::fs::write(&built, b"fake xml parser bytes").unwrap();

        let rev = "0d9a8099c963ed53";
        let query_rev = "aaaa0000bbbb1111cccc2222dddd3333eeee4444";
        let spec = LangSpec {
            git_url: "https://github.com/tree-sitter-grammars/tree-sitter-xml".into(),
            git_rev: rev.into(),
            subpath: Some("xml".into()),
            extensions: vec!["xml".into()],
            c_files: vec!["src/parser.c".into(), "src/scanner.c".into()],
            query_source: QuerySource::Helix,
            query_subdir: None,
            source: Some("helix+nvim-treesitter".into()),
        };

        let installed = install_into_user_dir(
            "xml",
            &spec,
            &built,
            &highlights_src,
            None,
            query_rev,
            &user,
        )
        .unwrap();

        assert_eq!(
            installed,
            user.join(format!("xml{}", shared_lib_ext())),
            ".so path mismatch"
        );
        assert!(installed.is_file(), ".so missing from user_dir");
        assert!(user.join("xml.scm").is_file(), ".scm missing from user_dir");
        assert!(user.join("xml.rev").is_file(), ".rev missing from user_dir");

        let rev_payload = std::fs::read_to_string(user.join("xml.rev")).unwrap();
        assert_eq!(
            rev_payload,
            format!("{rev}:{query_rev}:abi{}", tree_sitter::LANGUAGE_VERSION)
        );
    }

    #[test]
    fn install_leaves_no_so_in_user_dir_when_highlights_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");
        let highlights_src = tmp.path().join("ghost.scm"); // absent
        let built = tmp.path().join(format!("xml{}", shared_lib_ext()));
        std::fs::write(&built, b"fake parser bytes").unwrap();

        let spec = dummy_spec("deadbeef00000000");
        let err = install_into_user_dir("xml", &spec, &built, &highlights_src, None, "qrev", &user)
            .unwrap_err();
        assert!(
            err.to_string().contains("highlights.scm missing"),
            "got: {err:#}"
        );

        let so_path = user.join(format!("xml{}", shared_lib_ext()));
        assert!(
            !so_path.exists(),
            ".so must not be written when highlights.scm is missing; found {so_path:?}"
        );
    }

    #[test]
    fn old_sidecar_format_counts_as_stale() {
        let tmp = tempfile::tempdir().unwrap();
        let user = tmp.path().join("user");
        let loader = loader_with(
            vec![],
            user.clone(),
            tmp.path().join("cache"),
            tmp.path().join("qcache"),
        );
        let meta = dummy_meta();

        let rev = "deadbeef0000";
        let spec = dummy_spec(rev);
        let pre = user.join(format!("rust{}", shared_lib_ext()));
        touch(&pre);
        // Write old two-field format — must be treated as stale.
        std::fs::create_dir_all(&user).unwrap();
        std::fs::write(
            user.join("rust.rev"),
            format!("{rev}:abi{}", tree_sitter::LANGUAGE_VERSION),
        )
        .unwrap();

        assert!(
            loader.lookup_fresh("rust", &spec, &meta).is_none(),
            "old sidecar must count as stale"
        );
    }
}
