//! Pure grammar registry types without I/O operations.
//!
//! This module contains the `GrammarRegistry` struct and all syntax lookup methods
//! that don't require filesystem access. This enables WASM compatibility and easier testing.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use syntect::parsing::{SyntaxDefinition, SyntaxReference, SyntaxSet, SyntaxSetBuilder};

// Re-export glob matching utilities for use by other modules
pub use crate::primitives::glob_match::{
    filename_glob_matches, is_glob_pattern, is_path_pattern, path_glob_matches,
};

/// A grammar specification: language name, path to grammar file, and associated file extensions.
///
/// Used to pass grammar information between the plugin layer, loader, and registry
/// without relying on anonymous tuples.
#[derive(Clone, Debug)]
pub struct GrammarSpec {
    /// Language identifier (e.g., "elixir")
    pub language: String,
    /// Path to the grammar file (.sublime-syntax)
    pub path: PathBuf,
    /// File extensions to associate with this grammar (e.g., ["ex", "exs"])
    pub extensions: Vec<String>,
}

/// Where a grammar was loaded from.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type")]
pub enum GrammarSource {
    /// Built-in to Fresh (pre-compiled syntect defaults + embedded grammars)
    #[serde(rename = "built-in")]
    BuiltIn,
    /// Installed from a user grammar directory (~/.config/fresh/grammars/)
    #[serde(rename = "user")]
    User { path: PathBuf },
    /// From a language pack (~/.config/fresh/languages/packages/)
    #[serde(rename = "language-pack")]
    LanguagePack { name: String, path: PathBuf },
    /// From a bundle package (~/.config/fresh/bundles/packages/)
    #[serde(rename = "bundle")]
    Bundle { name: String, path: PathBuf },
    /// Registered by a plugin at runtime
    #[serde(rename = "plugin")]
    Plugin { plugin: String, path: PathBuf },
}

impl std::fmt::Display for GrammarSource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            GrammarSource::BuiltIn => write!(f, "built-in"),
            GrammarSource::User { path } => write!(f, "user ({})", path.display()),
            GrammarSource::LanguagePack { name, .. } => write!(f, "language-pack ({})", name),
            GrammarSource::Bundle { name, .. } => write!(f, "bundle ({})", name),
            GrammarSource::Plugin { plugin, .. } => write!(f, "plugin ({})", plugin),
        }
    }
}

/// Information about an available grammar, including its provenance.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GrammarInfo {
    /// The grammar name as used in config files (case-insensitive matching)
    pub name: String,
    /// Where this grammar was loaded from
    pub source: GrammarSource,
    /// File extensions associated with this grammar
    pub file_extensions: Vec<String>,
    /// Optional short name alias (e.g., "bash" for "Bourne Again Shell (bash)")
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub short_name: Option<String>,
}

/// Bridge between syntect display names and `fresh_languages::Language`.
///
/// Most syntect grammars map one-to-one: "Rust" → `Language::Rust`. A few
/// have verbose display names that don't match the tree-sitter enum's
/// `display_name()`, and `Language::from_name` has fuzzy "contains shell"
/// fallbacks that would wrongly tag Nushell as tree-sitter Bash. This is
/// the one place we spell the exceptions out explicitly.
const SYNTECT_TO_TREE_SITTER_ALIASES: &[(&str, fresh_languages::Language)] =
    &[("Bourne Again Shell (bash)", fresh_languages::Language::Bash)];

/// Resolve a syntect syntax display name to a tree-sitter language, using
/// strict equality against the alias table and `Language::display_name()`.
fn tree_sitter_for_syntect_name(display_name: &str) -> Option<fresh_languages::Language> {
    for (syntect_name, lang) in SYNTECT_TO_TREE_SITTER_ALIASES {
        if *syntect_name == display_name {
            return Some(*lang);
        }
    }
    fresh_languages::Language::all()
        .iter()
        .find(|l| l.display_name() == display_name)
        .copied()
}

/// Which highlighters can serve a given `GrammarEntry`.
///
/// A catalog entry may come from syntect (a TextMate grammar indexed into
/// `SyntaxSet`), tree-sitter (a `fresh_languages::Language`), or both.
#[derive(Clone, Debug, Default)]
pub struct GrammarEngines {
    /// Index into `GrammarRegistry::syntax_set().syntaxes()`, if a syntect
    /// grammar is available.
    pub syntect: Option<usize>,
    /// Tree-sitter language, if one is registered for this grammar.
    pub tree_sitter: Option<fresh_languages::Language>,
}

/// A single entry in the unified grammar catalog.
///
/// Each entry represents one logical language (e.g. "Rust", "TypeScript") and
/// records which highlighting engines can serve it, plus the names/extensions
/// used to look it up. The catalog is the single source of truth for grammar
/// lookups — `find_by_name`, `find_by_path`, `find_by_extension` all return
/// entries from here, and both `HighlightEngine::from_entry` and
/// `DetectedLanguage::from_entry` consume them.
#[derive(Clone, Debug)]
pub struct GrammarEntry {
    /// Human-readable display name (e.g. "TypeScript", "Bourne Again Shell (bash)").
    pub display_name: String,
    /// Canonical language ID used in config and LSP (e.g. "typescript", "csharp").
    pub language_id: String,
    /// Short alias, if one exists (e.g. "ts" for TypeScript).
    pub short_name: Option<String>,
    /// File extensions (without leading dot).
    pub extensions: Vec<String>,
    /// Exact filenames that map to this grammar (e.g. "Dockerfile").
    pub filenames: Vec<String>,
    /// Filename globs from user config (e.g. "*.conf", "/etc/**/rc.*").
    pub filename_globs: Vec<String>,
    /// Where this grammar was loaded from.
    pub source: GrammarSource,
    /// Highlighters that can serve this entry.
    pub engines: GrammarEngines,
}

/// Embedded TOML grammar (syntect doesn't include one)
pub const TOML_GRAMMAR: &str = include_str!("../../grammars/toml.sublime-syntax");

/// Embedded Odin grammar (syntect doesn't include one)
/// From: https://github.com/Tetralux/sublime-odin (MIT License)
pub const ODIN_GRAMMAR: &str = include_str!("../../grammars/odin/Odin.sublime-syntax");

/// Embedded Zig grammar (syntect doesn't include one)
pub const ZIG_GRAMMAR: &str = include_str!("../../grammars/zig.sublime-syntax");

/// Embedded GDScript grammar
/// Based on https://github.com/beefsack/GDScript-sublime (MIT License)
pub const GDSCRIPT_GRAMMAR: &str = include_str!("../../grammars/gdscript.sublime-syntax");

/// Embedded Git Rebase Todo grammar for interactive rebase
pub const GIT_REBASE_GRAMMAR: &str = include_str!("../../grammars/git-rebase.sublime-syntax");

/// Embedded Git Commit Message grammar for COMMIT_EDITMSG, MERGE_MSG, etc.
pub const GIT_COMMIT_GRAMMAR: &str = include_str!("../../grammars/git-commit.sublime-syntax");

/// Embedded Gitignore grammar for .gitignore and similar files
pub const GITIGNORE_GRAMMAR: &str = include_str!("../../grammars/gitignore.sublime-syntax");

/// Embedded Git Config grammar for .gitconfig, .gitmodules
pub const GITCONFIG_GRAMMAR: &str = include_str!("../../grammars/gitconfig.sublime-syntax");

/// Embedded Git Attributes grammar for .gitattributes
pub const GITATTRIBUTES_GRAMMAR: &str = include_str!("../../grammars/gitattributes.sublime-syntax");

/// Embedded Typst grammar (syntect doesn't include one)
pub const TYPST_GRAMMAR: &str = include_str!("../../grammars/typst.sublime-syntax");

/// Embedded Dockerfile grammar
pub const DOCKERFILE_GRAMMAR: &str = include_str!("../../grammars/dockerfile.sublime-syntax");
/// Embedded INI grammar (also handles .env, .cfg, .editorconfig, etc.)
pub const INI_GRAMMAR: &str = include_str!("../../grammars/ini.sublime-syntax");
/// Embedded CMake grammar
pub const CMAKE_GRAMMAR: &str = include_str!("../../grammars/cmake.sublime-syntax");
/// Embedded CMake cache grammar
pub const CMAKE_CACHE_GRAMMAR: &str = include_str!("../../grammars/cmake-cache.sublime-syntax");
/// Embedded pkg-config metadata grammar
pub const PKG_CONFIG_GRAMMAR: &str = include_str!("../../grammars/pkg-config.sublime-syntax");
/// Embedded Wavefront OBJ mesh grammar
pub const WAVEFRONT_OBJ_GRAMMAR: &str = include_str!("../../grammars/wavefront-obj.sublime-syntax");
/// Embedded Doxygen documentation grammar
pub const DOXYGEN_GRAMMAR: &str = include_str!("../../grammars/doxygen.sublime-syntax");
/// Embedded Doxygen configuration grammar
pub const DOXYGEN_CONFIG_GRAMMAR: &str =
    include_str!("../../grammars/doxygen-config.sublime-syntax");
/// Embedded BibTeX style language grammar
pub const BIBTEX_STYLE_GRAMMAR: &str = include_str!("../../grammars/bibtex-style.sublime-syntax");
/// Embedded Windows resource script grammar
pub const WINDOWS_RESOURCE_GRAMMAR: &str =
    include_str!("../../grammars/windows-resource.sublime-syntax");
/// Embedded SCSS grammar
pub const SCSS_GRAMMAR: &str = include_str!("../../grammars/scss.sublime-syntax");
/// Embedded LESS grammar
pub const LESS_GRAMMAR: &str = include_str!("../../grammars/less.sublime-syntax");
/// Embedded PowerShell grammar
pub const POWERSHELL_GRAMMAR: &str = include_str!("../../grammars/powershell.sublime-syntax");
/// Embedded Kotlin grammar
pub const KOTLIN_GRAMMAR: &str = include_str!("../../grammars/kotlin.sublime-syntax");
/// Embedded Swift grammar
pub const SWIFT_GRAMMAR: &str = include_str!("../../grammars/swift.sublime-syntax");
/// Embedded Dart grammar
pub const DART_GRAMMAR: &str = include_str!("../../grammars/dart.sublime-syntax");
/// Embedded Elixir grammar
pub const ELIXIR_GRAMMAR: &str = include_str!("../../grammars/elixir.sublime-syntax");
/// Embedded F# grammar
pub const FSHARP_GRAMMAR: &str = include_str!("../../grammars/fsharp.sublime-syntax");
/// Embedded Nix grammar
pub const NIX_GRAMMAR: &str = include_str!("../../grammars/nix.sublime-syntax");
/// Embedded HCL/Terraform grammar
pub const HCL_GRAMMAR: &str = include_str!("../../grammars/hcl.sublime-syntax");
/// Embedded Protocol Buffers grammar
pub const PROTOBUF_GRAMMAR: &str = include_str!("../../grammars/protobuf.sublime-syntax");
/// Embedded Apache Thrift grammar
pub const THRIFT_GRAMMAR: &str = include_str!("../../grammars/thrift.sublime-syntax");
/// Embedded GraphQL grammar
pub const GRAPHQL_GRAMMAR: &str = include_str!("../../grammars/graphql.sublime-syntax");
/// Embedded Julia grammar
pub const JULIA_GRAMMAR: &str = include_str!("../../grammars/julia.sublime-syntax");
/// Embedded Nim grammar
pub const NIM_GRAMMAR: &str = include_str!("../../grammars/nim.sublime-syntax");
/// Embedded Gleam grammar
pub const GLEAM_GRAMMAR: &str = include_str!("../../grammars/gleam.sublime-syntax");
/// Embedded V language grammar
pub const VLANG_GRAMMAR: &str = include_str!("../../grammars/vlang.sublime-syntax");
/// Embedded Solidity grammar
pub const SOLIDITY_GRAMMAR: &str = include_str!("../../grammars/solidity.sublime-syntax");
/// Embedded KDL grammar
pub const KDL_GRAMMAR: &str = include_str!("../../grammars/kdl.sublime-syntax");
/// Embedded Nushell grammar
pub const NUSHELL_GRAMMAR: &str = include_str!("../../grammars/nushell.sublime-syntax");
/// Embedded Smali grammar
pub const SMALI_GRAMMAR: &str = include_str!("../../grammars/smali.sublime-syntax");
/// Embedded Gettext PO grammar
pub const GETTEXT_GRAMMAR: &str = include_str!("../../grammars/gettext.sublime-syntax");
/// Embedded GNU m4 grammar
pub const M4_GRAMMAR: &str = include_str!("../../grammars/m4.sublime-syntax");
/// Embedded Xcode project grammar
pub const PBXPROJ_GRAMMAR: &str = include_str!("../../grammars/pbxproj.sublime-syntax");
/// Embedded Fish shell grammar
pub const FISH_GRAMMAR: &str = include_str!("../../grammars/fish.sublime-syntax");
/// Embedded Starlark/Bazel grammar
pub const STARLARK_GRAMMAR: &str = include_str!("../../grammars/starlark.sublime-syntax");
/// Embedded Justfile grammar
pub const JUSTFILE_GRAMMAR: &str = include_str!("../../grammars/justfile.sublime-syntax");
/// Embedded Earthfile grammar
pub const EARTHFILE_GRAMMAR: &str = include_str!("../../grammars/earthfile.sublime-syntax");
/// Embedded Go Module grammar
pub const GOMOD_GRAMMAR: &str = include_str!("../../grammars/gomod.sublime-syntax");
/// Embedded Vue grammar
pub const VUE_GRAMMAR: &str = include_str!("../../grammars/vue.sublime-syntax");

/// Embedded TypeScript grammar. Serves embedded contexts only (Vue
/// `lang="ts"`, Markdown ```ts fences) — `.ts` buffers stay on
/// tree-sitter via the catalog skip below, mirroring JavaScript.
pub const TYPESCRIPT_GRAMMAR: &str = include_str!("../../grammars/typescript.sublime-syntax");
/// Embedded Svelte grammar
pub const SVELTE_GRAMMAR: &str = include_str!("../../grammars/svelte.sublime-syntax");
/// Embedded Astro grammar
pub const ASTRO_GRAMMAR: &str = include_str!("../../grammars/astro.sublime-syntax");
/// Embedded Hyprlang grammar (Hyprland config)
pub const HYPRLANG_GRAMMAR: &str = include_str!("../../grammars/hyprlang.sublime-syntax");
/// Embedded AutoHotkey grammar
/// From: https://github.com/SALZKARTOFFEEEL/ahk-sublime-syntax (MIT License)
pub const AUTOHOTKEY_GRAMMAR: &str =
    include_str!("../../grammars/autohotkey/AutoHotkey.sublime-syntax");
/// Embedded Racket grammar (syntect doesn't include one)
pub const RACKET_GRAMMAR: &str = include_str!("../../grammars/racket.sublime-syntax");
/// Embedded Verilog grammar (HDL)
pub const VERILOG_GRAMMAR: &str = include_str!("../../grammars/verilog.sublime-syntax");
/// Embedded SystemVerilog grammar (HDL)
pub const SYSTEMVERILOG_GRAMMAR: &str = include_str!("../../grammars/systemverilog.sublime-syntax");
/// Embedded VHDL grammar (HDL)
pub const VHDL_GRAMMAR: &str = include_str!("../../grammars/vhdl.sublime-syntax");

pub const C3_GRAMMAR: &str = include_str!("../../grammars/c3.sublime-syntax");

/// Embedded Assembly grammar (GAS/AT&T and Intel/NASM dialects; syntect
/// doesn't include one)
pub const ASM_GRAMMAR: &str = include_str!("../../grammars/asm.sublime-syntax");

/// Embedded Slang grammar (the HLSL-derived shading language; syntect doesn't
/// include one). See https://shader-slang.com/.
pub const SLANG_GRAMMAR: &str = include_str!("../../grammars/slang.sublime-syntax");
/// Embedded GLSL shader grammar
pub const GLSL_GRAMMAR: &str = include_str!("../../grammars/glsl.sublime-syntax");
/// Embedded HLSL shader grammar
pub const HLSL_GRAMMAR: &str = include_str!("../../grammars/hlsl.sublime-syntax");
/// Embedded WGSL shader grammar
pub const WGSL_GRAMMAR: &str = include_str!("../../grammars/wgsl.sublime-syntax");
/// Embedded Metal shader grammar
pub const METAL_GRAMMAR: &str = include_str!("../../grammars/metal.sublime-syntax");
/// Embedded CUDA grammar
pub const CUDA_GRAMMAR: &str = include_str!("../../grammars/cuda.sublime-syntax");
/// Embedded HIP grammar
pub const HIP_GRAMMAR: &str = include_str!("../../grammars/hip.sublime-syntax");
/// Embedded Fortran grammar
pub const FORTRAN_GRAMMAR: &str = include_str!("../../grammars/fortran.sublime-syntax");
/// Embedded MLIR grammar
pub const MLIR_GRAMMAR: &str = include_str!("../../grammars/mlir.sublime-syntax");
/// Embedded LLVM IR grammar
pub const LLVM_IR_GRAMMAR: &str = include_str!("../../grammars/llvm-ir.sublime-syntax");

/// Registry of all available TextMate grammars.
///
/// This struct holds the compiled syntax set and provides lookup methods.
/// It does not perform I/O directly - use `GrammarLoader` for loading grammars.
impl std::fmt::Debug for GrammarRegistry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GrammarRegistry")
            .field("syntax_count", &self.syntax_set.syntaxes().len())
            .finish()
    }
}

pub struct GrammarRegistry {
    /// Combined syntax set (built-in + embedded + user grammars)
    syntax_set: Arc<SyntaxSet>,
    /// Extension -> scope name mapping for user grammars (takes priority)
    user_extensions: HashMap<String, String>,
    /// Filename -> scope name mapping for dotfiles and special files
    filename_scopes: HashMap<String, String>,
    /// Paths to dynamically loaded grammar files (for reloading when adding more)
    loaded_grammar_paths: Vec<GrammarSpec>,
    /// Provenance info for each grammar (keyed by grammar name)
    grammar_sources: HashMap<String, GrammarInfo>,
    /// Short name aliases: lowercase short_name -> full syntect grammar name.
    /// Provides a deterministic, one-to-one mapping so users can write
    /// `grammar = "bash"` instead of `grammar = "Bourne Again Shell (bash)"`.
    aliases: HashMap<String, String>,
    /// Unified catalog of every known grammar. Rebuilt whenever the syntax set
    /// or alias table changes. Lookups (`find_by_name`, `find_by_path`, ...)
    /// all resolve against this.
    catalog: Vec<GrammarEntry>,
    /// Index from lowercased lookup keys (display name, language_id, short_name)
    /// to catalog index.
    catalog_by_name: HashMap<String, usize>,
    /// Index from file extension (without dot) to catalog index.
    catalog_by_extension: HashMap<String, usize>,
    /// Index from filename to catalog index.
    catalog_by_filename: HashMap<String, usize>,
    /// The most recent language config handed to `apply_language_config`.
    /// Retained so `rebuild_catalog` can replay it — otherwise a rebuild
    /// (triggered by e.g. `populate_built_in_aliases`) silently wipes user
    /// `[languages]` config that was merged on top.
    applied_language_config: HashMap<String, crate::config::LanguageConfig>,
    /// Monotonic generation, bumped on every catalog mutation. Lets
    /// observers (plugin state snapshot) detect changes with one integer
    /// compare instead of recounting entries.
    catalog_gen: u64,
}

impl GrammarRegistry {
    /// Create a new GrammarRegistry from pre-built components.
    ///
    /// This is typically called by `GrammarLoader` implementations after
    /// loading grammars from various sources.
    pub(crate) fn new(
        syntax_set: SyntaxSet,
        user_extensions: HashMap<String, String>,
        filename_scopes: HashMap<String, String>,
    ) -> Self {
        Self::new_with_loaded_paths(
            syntax_set,
            user_extensions,
            filename_scopes,
            Vec::new(),
            HashMap::new(),
        )
    }

    /// Create a GrammarRegistry with pre-loaded grammar path tracking.
    ///
    /// Used by the loader when plugin grammars were included in the initial build,
    /// so that `loaded_grammar_paths()` reflects what was actually loaded.
    pub(crate) fn new_with_loaded_paths(
        syntax_set: SyntaxSet,
        user_extensions: HashMap<String, String>,
        filename_scopes: HashMap<String, String>,
        loaded_grammar_paths: Vec<GrammarSpec>,
        grammar_sources: HashMap<String, GrammarInfo>,
    ) -> Self {
        let mut reg = Self {
            syntax_set: Arc::new(syntax_set),
            user_extensions,
            filename_scopes,
            loaded_grammar_paths,
            grammar_sources,
            aliases: HashMap::new(),
            catalog: Vec::new(),
            catalog_by_name: HashMap::new(),
            catalog_by_extension: HashMap::new(),
            catalog_by_filename: HashMap::new(),
            applied_language_config: HashMap::new(),
            catalog_gen: 0,
        };
        reg.rebuild_catalog();
        reg
    }

    /// Create an empty grammar registry (fast, for tests that don't need syntax highlighting)
    pub fn empty() -> Arc<Self> {
        let mut builder = SyntaxSetBuilder::new();
        builder.add_plain_text_syntax();
        let mut reg = Self {
            syntax_set: Arc::new(builder.build()),
            user_extensions: HashMap::new(),
            filename_scopes: HashMap::new(),
            loaded_grammar_paths: Vec::new(),
            grammar_sources: HashMap::new(),
            aliases: HashMap::new(),
            catalog: Vec::new(),
            catalog_by_name: HashMap::new(),
            catalog_by_extension: HashMap::new(),
            catalog_by_filename: HashMap::new(),
            applied_language_config: HashMap::new(),
            catalog_gen: 0,
        };
        reg.rebuild_catalog();
        Arc::new(reg)
    }

    /// Create a registry with only syntect's pre-compiled defaults (~0ms).
    ///
    /// This provides instant syntax highlighting for ~50 common languages
    /// (Rust, Python, JS/TS, C/C++, Go, Java, HTML, CSS, Markdown, etc.)
    /// without any `SyntaxSetBuilder::build()` call. Use this at startup,
    /// then swap in a full registry built on a background thread.
    pub fn defaults_only() -> Arc<Self> {
        // Load pre-compiled syntax set (defaults + embedded grammars) from
        // build-time packdump. This avoids the expensive into_builder() + build()
        // cycle at runtime (~12s → ~300ms).
        tracing::info!("defaults_only: loading pre-compiled syntax packdump...");
        let syntax_set: SyntaxSet = syntect::dumps::from_uncompressed_data(include_bytes!(
            concat!(env!("OUT_DIR"), "/default_syntaxes.packdump")
        ))
        .expect("Failed to load pre-compiled syntax packdump");
        tracing::info!(
            "defaults_only: loaded ({} syntaxes)",
            syntax_set.syntaxes().len()
        );
        let grammar_sources = Self::build_grammar_sources_from_syntax_set(&syntax_set);
        let filename_scopes = Self::build_filename_scopes();
        let extra_extensions = Self::build_extra_extensions();
        let mut registry = Self {
            syntax_set: Arc::new(syntax_set),
            user_extensions: extra_extensions,
            filename_scopes,
            loaded_grammar_paths: Vec::new(),
            grammar_sources,
            aliases: HashMap::new(),
            catalog: Vec::new(),
            catalog_by_name: HashMap::new(),
            catalog_by_extension: HashMap::new(),
            catalog_by_filename: HashMap::new(),
            applied_language_config: HashMap::new(),
            catalog_gen: 0,
        };
        registry.populate_built_in_aliases();
        registry.rebuild_catalog();
        Arc::new(registry)
    }

    /// Build extra extension -> scope mappings for extensions not covered by syntect defaults.
    ///
    /// These map common file extensions to existing syntect grammar scopes,
    /// filling gaps where syntect's built-in extension lists are incomplete.
    pub(crate) fn build_extra_extensions() -> HashMap<String, String> {
        let mut map = HashMap::new();

        // JavaScript variants not in syntect defaults (["js", "htc"])
        let js_scope = "source.js".to_string();
        map.insert("cjs".to_string(), js_scope.clone());
        map.insert("mjs".to_string(), js_scope);

        // Dockerfile variants (e.g. Dockerfile.dev -> .dev extension)
        // These won't match by extension, handled by filename_scopes and first_line_match

        map
    }

    /// Build the default filename -> scope mappings for dotfiles and special files.
    pub(crate) fn build_filename_scopes() -> HashMap<String, String> {
        let mut map = HashMap::new();

        // Shell configuration files -> Bash/Shell script scope
        let shell_scope = "source.shell.bash".to_string();
        for filename in [
            ".zshrc",
            ".zprofile",
            ".zshenv",
            ".zlogin",
            ".zlogout",
            ".bash_aliases",
            // .bashrc and .bash_profile are already recognized by syntect
            // Common shell script files without extensions
            "PKGBUILD",
            "APKBUILD",
        ] {
            map.insert(filename.to_string(), shell_scope.clone());
        }

        // Git rebase todo files
        let git_rebase_scope = "source.git-rebase-todo".to_string();
        map.insert("git-rebase-todo".to_string(), git_rebase_scope);

        // Git commit message files
        let git_commit_scope = "source.git-commit".to_string();
        for filename in ["COMMIT_EDITMSG", "MERGE_MSG", "SQUASH_MSG", "TAG_EDITMSG"] {
            map.insert(filename.to_string(), git_commit_scope.clone());
        }

        // Gitignore and similar files
        let gitignore_scope = "source.gitignore".to_string();
        for filename in [".gitignore", ".dockerignore", ".npmignore", ".hgignore"] {
            map.insert(filename.to_string(), gitignore_scope.clone());
        }

        // Git config files
        let gitconfig_scope = "source.gitconfig".to_string();
        for filename in [".gitconfig", ".gitmodules"] {
            map.insert(filename.to_string(), gitconfig_scope.clone());
        }

        // Git attributes files
        let gitattributes_scope = "source.gitattributes".to_string();
        map.insert(".gitattributes".to_string(), gitattributes_scope);

        // Jenkinsfile -> Groovy
        let groovy_scope = "source.groovy".to_string();
        map.insert("Jenkinsfile".to_string(), groovy_scope);

        // Vagrantfile -> Ruby (syntect already handles this, but be explicit)
        // Brewfile -> Ruby
        let ruby_scope = "source.ruby".to_string();
        map.insert("Brewfile".to_string(), ruby_scope);

        // Dockerfile and variants (exact names; Dockerfile.* handled via prefix check)
        let dockerfile_scope = "source.dockerfile".to_string();
        map.insert("Dockerfile".to_string(), dockerfile_scope.clone());
        map.insert("Containerfile".to_string(), dockerfile_scope.clone());
        // Common Dockerfile variants
        map.insert("Dockerfile.dev".to_string(), dockerfile_scope.clone());
        map.insert("Dockerfile.prod".to_string(), dockerfile_scope.clone());
        map.insert("Dockerfile.test".to_string(), dockerfile_scope.clone());
        map.insert("Dockerfile.build".to_string(), dockerfile_scope.clone());

        // CMake
        let cmake_scope = "source.cmake".to_string();
        map.insert("CMakeLists.txt".to_string(), cmake_scope);
        let cmake_cache_scope = "source.cmake-cache".to_string();
        map.insert("CMakeCache.txt".to_string(), cmake_cache_scope.clone());
        map.insert("cmakecache.txt".to_string(), cmake_cache_scope);

        // Doxygen configuration and CMake/autoconf template
        let doxygen_config_scope = "source.doxygen-config".to_string();
        map.insert("Doxyfile".to_string(), doxygen_config_scope.clone());
        map.insert("Doxyfile.in".to_string(), doxygen_config_scope);

        // Starlark/Bazel
        let starlark_scope = "source.starlark".to_string();
        for filename in [
            "BUILD",
            "BUILD.bazel",
            "MODULE.bazel",
            "REPO.bazel",
            "WORKSPACE",
            "WORKSPACE.bazel",
        ] {
            map.insert(filename.to_string(), starlark_scope.clone());
        }
        map.insert("Tiltfile".to_string(), starlark_scope);

        // Justfile (various casings)
        let justfile_scope = "source.justfile".to_string();
        map.insert("justfile".to_string(), justfile_scope.clone());
        map.insert("Justfile".to_string(), justfile_scope.clone());
        map.insert(".justfile".to_string(), justfile_scope);

        // EditorConfig -> INI
        let ini_scope = "source.ini".to_string();
        map.insert(".editorconfig".to_string(), ini_scope);

        // Earthfile
        let earthfile_scope = "source.earthfile".to_string();
        map.insert("Earthfile".to_string(), earthfile_scope);

        // Hyprlang (Hyprland config files)
        let hyprlang_scope = "source.hyprlang".to_string();
        map.insert("hyprland.conf".to_string(), hyprlang_scope.clone());
        map.insert("hyprpaper.conf".to_string(), hyprlang_scope.clone());
        map.insert("hyprlock.conf".to_string(), hyprlang_scope);

        // go.mod / go.sum
        let gomod_scope = "source.gomod".to_string();
        map.insert("go.mod".to_string(), gomod_scope.clone());
        map.insert("go.sum".to_string(), gomod_scope);

        // YAML(-ish) files without a .yaml/.yml extension (#2326). These are
        // formats whose content is unambiguously YAML:
        //   - yarn v1 lockfiles use a YAML-compatible format.
        //   - .clang-format / _clang-format and .clang-tidy are YAML (LLVM).
        //   - .yamllint is yamllint's own YAML config.
        //   - Podfile.lock (CocoaPods) and pubspec.lock (Dart pub) are YAML.
        let yaml_scope = "source.yaml".to_string();
        for filename in [
            "yarn.lock",
            ".clang-format",
            "_clang-format",
            ".clang-tidy",
            ".yamllint",
            "Podfile.lock",
            "pubspec.lock",
        ] {
            map.insert(filename.to_string(), yaml_scope.clone());
        }

        // Lock files whose content is TOML. Cargo.lock is also matched by
        // syntect's first-line regex, but mapping it explicitly is robust
        // regardless of the file's first line.
        let toml_scope = "source.toml".to_string();
        for filename in ["Cargo.lock", "poetry.lock", "uv.lock"] {
            map.insert(filename.to_string(), toml_scope.clone());
        }

        // Lock files whose content is JSON. The JSON grammar has no first-line
        // regex for a leading `{`, so these need an explicit filename mapping.
        let json_scope = "source.json".to_string();
        for filename in ["composer.lock", "Pipfile.lock", "flake.lock", "deno.lock"] {
            map.insert(filename.to_string(), json_scope.clone());
        }

        map
    }

    /// Add embedded grammars (TOML, Odin, etc.) to a syntax set builder.
    pub(crate) fn add_embedded_grammars(builder: &mut SyntaxSetBuilder) {
        // TOML grammar
        match SyntaxDefinition::load_from_str(TOML_GRAMMAR, true, Some("TOML")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded TOML grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded TOML grammar: {}", e);
            }
        }

        // Odin grammar
        match SyntaxDefinition::load_from_str(ODIN_GRAMMAR, true, Some("Odin")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded Odin grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded Odin grammar: {}", e);
            }
        }

        // Zig grammar
        match SyntaxDefinition::load_from_str(ZIG_GRAMMAR, true, Some("Zig")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded Zig grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded Zig grammar: {}", e);
            }
        }

        // GDScript grammar
        match SyntaxDefinition::load_from_str(GDSCRIPT_GRAMMAR, true, Some("GDScript")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded GDScript grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded GDScript grammar: {}", e);
            }
        }

        // Git Rebase Todo grammar
        match SyntaxDefinition::load_from_str(GIT_REBASE_GRAMMAR, true, Some("Git Rebase Todo")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded Git Rebase Todo grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded Git Rebase Todo grammar: {}", e);
            }
        }

        // Git Commit Message grammar
        match SyntaxDefinition::load_from_str(GIT_COMMIT_GRAMMAR, true, Some("Git Commit Message"))
        {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded Git Commit Message grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded Git Commit Message grammar: {}", e);
            }
        }

        // Gitignore grammar
        match SyntaxDefinition::load_from_str(GITIGNORE_GRAMMAR, true, Some("Gitignore")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded Gitignore grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded Gitignore grammar: {}", e);
            }
        }

        // Git Config grammar
        match SyntaxDefinition::load_from_str(GITCONFIG_GRAMMAR, true, Some("Git Config")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded Git Config grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded Git Config grammar: {}", e);
            }
        }

        // Git Attributes grammar
        match SyntaxDefinition::load_from_str(GITATTRIBUTES_GRAMMAR, true, Some("Git Attributes")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded Git Attributes grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded Git Attributes grammar: {}", e);
            }
        }

        // Typst grammar
        match SyntaxDefinition::load_from_str(TYPST_GRAMMAR, true, Some("Typst")) {
            Ok(syntax) => {
                builder.add(syntax);
                tracing::debug!("Loaded embedded Typst grammar");
            }
            Err(e) => {
                tracing::warn!("Failed to load embedded Typst grammar: {}", e);
            }
        }

        // Additional embedded grammars for languages not in syntect defaults
        let additional_grammars: &[(&str, &str)] = &[
            (DOCKERFILE_GRAMMAR, "Dockerfile"),
            (INI_GRAMMAR, "INI"),
            (CMAKE_GRAMMAR, "CMake"),
            (CMAKE_CACHE_GRAMMAR, "CMake Cache"),
            (PKG_CONFIG_GRAMMAR, "pkg-config"),
            (WAVEFRONT_OBJ_GRAMMAR, "Wavefront OBJ"),
            (DOXYGEN_GRAMMAR, "Doxygen"),
            (DOXYGEN_CONFIG_GRAMMAR, "Doxygen Config"),
            (BIBTEX_STYLE_GRAMMAR, "BibTeX Style"),
            (WINDOWS_RESOURCE_GRAMMAR, "Windows Resource Script"),
            (SCSS_GRAMMAR, "SCSS"),
            (LESS_GRAMMAR, "LESS"),
            (POWERSHELL_GRAMMAR, "PowerShell"),
            (KOTLIN_GRAMMAR, "Kotlin"),
            (SWIFT_GRAMMAR, "Swift"),
            (DART_GRAMMAR, "Dart"),
            (ELIXIR_GRAMMAR, "Elixir"),
            (FSHARP_GRAMMAR, "FSharp"),
            (NIX_GRAMMAR, "Nix"),
            (HCL_GRAMMAR, "HCL"),
            (PROTOBUF_GRAMMAR, "Protocol Buffers"),
            (THRIFT_GRAMMAR, "Thrift"),
            (GRAPHQL_GRAMMAR, "GraphQL"),
            (JULIA_GRAMMAR, "Julia"),
            (NIM_GRAMMAR, "Nim"),
            (GLEAM_GRAMMAR, "Gleam"),
            (VLANG_GRAMMAR, "V"),
            (SOLIDITY_GRAMMAR, "Solidity"),
            (KDL_GRAMMAR, "KDL"),
            (NUSHELL_GRAMMAR, "Nushell"),
            (SMALI_GRAMMAR, "Smali"),
            (GETTEXT_GRAMMAR, "Gettext PO"),
            (M4_GRAMMAR, "M4"),
            (PBXPROJ_GRAMMAR, "Xcode Project"),
            (FISH_GRAMMAR, "Fish"),
            (STARLARK_GRAMMAR, "Starlark"),
            (JUSTFILE_GRAMMAR, "Justfile"),
            (EARTHFILE_GRAMMAR, "Earthfile"),
            (GOMOD_GRAMMAR, "Go Module"),
            (VUE_GRAMMAR, "Vue"),
            (TYPESCRIPT_GRAMMAR, "TypeScript"),
            (SVELTE_GRAMMAR, "Svelte"),
            (ASTRO_GRAMMAR, "Astro"),
            (HYPRLANG_GRAMMAR, "Hyprlang"),
            (AUTOHOTKEY_GRAMMAR, "AutoHotkey"),
            (RACKET_GRAMMAR, "Racket"),
            (VERILOG_GRAMMAR, "Verilog"),
            (SYSTEMVERILOG_GRAMMAR, "SystemVerilog"),
            (VHDL_GRAMMAR, "VHDL"),
            (C3_GRAMMAR, "C3"),
            (ASM_GRAMMAR, "Assembly"),
            (SLANG_GRAMMAR, "Slang"),
            (GLSL_GRAMMAR, "GLSL"),
            (HLSL_GRAMMAR, "HLSL"),
            (WGSL_GRAMMAR, "WGSL"),
            (METAL_GRAMMAR, "Metal"),
            (CUDA_GRAMMAR, "CUDA"),
            (HIP_GRAMMAR, "HIP"),
            (FORTRAN_GRAMMAR, "Fortran"),
            (MLIR_GRAMMAR, "MLIR"),
            (LLVM_IR_GRAMMAR, "LLVM IR"),
        ];

        for (grammar_str, name) in additional_grammars {
            match SyntaxDefinition::load_from_str(grammar_str, true, Some(name)) {
                Ok(syntax) => {
                    builder.add(syntax);
                    tracing::debug!("Loaded embedded {} grammar", name);
                }
                Err(e) => {
                    tracing::warn!("Failed to load embedded {} grammar: {}", name, e);
                }
            }
        }
    }

    /// Find syntax for a file by path/extension/filename.
    ///
    /// Purely metadata-based — does not read the file. For first-line
    /// (shebang) fallback, use [`find_by_path`] with a `first_line` argument
    /// and resolve the returned entry's syntect index.
    pub fn find_syntax_for_file(&self, path: &Path) -> Option<&SyntaxReference> {
        let entry = self.find_by_path(path, None)?;
        entry
            .engines
            .syntect
            .map(|i| &self.syntax_set.syntaxes()[i])
    }

    /// Find syntax by name, with alias resolution.
    ///
    /// Thin wrapper around `find_by_name` that returns the associated syntect
    /// `SyntaxReference`. Tree-sitter-only entries return `None`.
    ///
    /// Falls back to a direct syntect lookup for "Plain Text", which the
    /// catalog deliberately omits but syntect still exposes.
    pub fn find_syntax_by_name(&self, name: &str) -> Option<&SyntaxReference> {
        if let Some(entry) = self.find_by_name(name) {
            if let Some(idx) = entry.engines.syntect {
                return Some(&self.syntax_set.syntaxes()[idx]);
            }
        }
        // Plain Text is excluded from the catalog (it's not a "grammar" a user
        // would ever pick), but syntect still stores it and a handful of
        // callers still ask for it by name.
        self.syntax_set.find_syntax_by_name(name)
    }

    // === Alias management ===

    /// Hardcoded short-name aliases for built-in and embedded grammars.
    ///
    /// Each entry maps a short name (lowercase) to the exact syntect grammar name.
    /// Only grammars whose full name differs significantly from a natural short
    /// form need an entry here. Grammars already short (e.g., "Rust", "Go") are
    /// reachable via case-insensitive matching and don't need aliases.
    fn built_in_aliases() -> Vec<(&'static str, &'static str)> {
        vec![
            // Syntect built-in grammars with verbose names
            ("bash", "Bourne Again Shell (bash)"),
            ("shell", "Bourne Again Shell (bash)"),
            ("sh", "Bourne Again Shell (bash)"),
            ("c++", "C++"),
            ("cpp", "C++"),
            ("csharp", "C#"),
            ("objc", "Objective-C"),
            ("objcpp", "Objective-C++"),
            ("regex", "Regular Expressions (Python)"),
            ("regexp", "Regular Expressions (Python)"),
            // Embedded grammars with multi-word or non-obvious names
            ("proto", "Protocol Buffers"),
            ("protobuf", "Protocol Buffers"),
            ("gomod", "Go Module"),
            ("git-rebase", "Git Rebase Todo"),
            ("git-commit", "Git Commit Message"),
            ("git-config", "Git Config"),
            ("git-attributes", "Git Attributes"),
            ("gitignore", "Gitignore"),
            ("fsharp", "FSharp"),
            ("f#", "FSharp"),
            ("terraform", "HCL"),
            ("tf", "HCL"),
            ("po", "Gettext PO"),
            ("gettext", "Gettext PO"),
            ("pot", "Gettext PO"),
            ("pbxproj", "Xcode Project"),
            ("xcodeproj", "Xcode Project"),
            ("bazel", "Starlark"),
            ("ts", "TypeScript"),
            ("js", "JavaScript"),
            ("py", "Python"),
            ("rb", "Ruby"),
            ("rs", "Rust"),
            ("md", "Markdown"),
            ("yml", "YAML"),
            ("dockerfile", "Dockerfile"),
            ("msl", "Metal"),
            ("cu", "CUDA"),
            ("cuh", "CUDA"),
            ("f90", "Fortran"),
            ("fortran", "Fortran"),
            ("mlir", "MLIR"),
            ("ll", "LLVM IR"),
            ("llvm", "LLVM IR"),
        ]
    }

    /// Populate aliases from the built-in table.
    ///
    /// Validates that:
    /// - Each alias target (full name) exists in the syntax set
    /// - No alias collides (case-insensitive) with an existing grammar full name
    /// - No duplicate aliases exist
    pub(crate) fn populate_built_in_aliases(&mut self) {
        for (short, full) in Self::built_in_aliases() {
            self.register_alias_inner(short, full, true);
        }
        self.rebuild_catalog();
    }

    /// Register a short-name alias for a grammar.
    ///
    /// Returns `true` if the alias was registered, `false` if rejected due to
    /// collision or missing target. For built-in aliases, collisions panic
    /// (they indicate a bug). For dynamic aliases, collisions log a warning.
    ///
    /// Splices the alias directly into the catalog rather than rebuilding, so
    /// any user config previously merged via `apply_language_config` is
    /// preserved. A full rebuild would wipe those entries.
    pub(crate) fn register_alias(&mut self, short_name: &str, full_name: &str) -> bool {
        if !self.register_alias_inner(short_name, full_name, false) {
            return false;
        }
        let short_lower = short_name.to_lowercase();
        let full_lower = full_name.to_lowercase();
        if let Some(&idx) = self.catalog_by_name.get(&full_lower) {
            self.catalog_by_name
                .entry(short_lower.clone())
                .or_insert(idx);
            let entry = &mut self.catalog[idx];
            let replace = match &entry.short_name {
                None => true,
                Some(existing) => short_name.len() < existing.len(),
            };
            if replace {
                entry.short_name = Some(short_lower);
            }
        }
        true
    }

    fn register_alias_inner(
        &mut self,
        short_name: &str,
        full_name: &str,
        is_built_in: bool,
    ) -> bool {
        let short_lower = short_name.to_lowercase();

        // Validate: target grammar must exist in the syntax set
        let target_exists = self
            .syntax_set
            .syntaxes()
            .iter()
            .any(|s| s.name.eq_ignore_ascii_case(full_name));
        if !target_exists {
            // Tree-sitter-only targets (e.g. TypeScript) are expected to be
            // absent from the syntect set. `rebuild_catalog` attaches their
            // short names via a separate pass over `built_in_aliases()`.
            if tree_sitter_for_syntect_name(full_name).is_some() {
                return false;
            }
            if is_built_in {
                // Built-in alias targets should always exist; warn but don't panic
                // (grammar might have been removed from syntect upstream)
                tracing::warn!(
                    "[grammar-alias] Built-in alias '{}' -> '{}': target grammar not found, skipping",
                    short_name, full_name
                );
            } else {
                tracing::warn!(
                    "[grammar-alias] Alias '{}' -> '{}': target grammar not found, skipping",
                    short_name,
                    full_name
                );
            }
            return false;
        }

        // Validate: short name must not collide (case-insensitive) with any grammar full name
        let collides_with_full_name = self
            .syntax_set
            .syntaxes()
            .iter()
            .any(|s| s.name.eq_ignore_ascii_case(&short_lower));
        if collides_with_full_name {
            // This is actually fine — the short name matches a full name directly,
            // so find_syntax_by_name's case-insensitive search will find it.
            // No alias needed.
            tracing::debug!(
                "[grammar-alias] Alias '{}' matches an existing grammar name, skipping (not needed)",
                short_name
            );
            return false;
        }

        // Validate: no duplicate alias (case-insensitive)
        if let Some(existing_target) = self.aliases.get(&short_lower) {
            if existing_target.eq_ignore_ascii_case(full_name) {
                // Same mapping, no-op
                return true;
            }
            let msg = format!(
                "Alias '{}' already maps to '{}', cannot remap to '{}'",
                short_name, existing_target, full_name
            );
            if is_built_in {
                panic!("[grammar-alias] Built-in alias collision: {}", msg);
            } else {
                tracing::warn!("[grammar-alias] {}", msg);
                return false;
            }
        }

        // Resolve the exact syntect name (preserving original case)
        let exact_name = self
            .syntax_set
            .syntaxes()
            .iter()
            .find(|s| s.name.eq_ignore_ascii_case(full_name))
            .map(|s| s.name.clone())
            .unwrap();

        self.aliases.insert(short_lower, exact_name);
        true
    }

    // === Unified catalog ===

    /// Rebuild the flat catalog of grammar entries.
    ///
    /// Called after the syntax set, aliases, or filename scopes change.
    /// Produces one entry per logical language by merging:
    /// 1. Every `SyntaxReference` in the syntax set (except "Plain Text")
    /// 2. Every `fresh_languages::Language` not already covered by a syntect entry
    /// 3. Alias short-names attached to their target entry
    /// 4. Filename mappings from `filename_scopes` attached to their scope's entry
    /// 5. Extra extensions from `user_extensions` attached to their scope's entry
    ///
    /// Automatically replays the last `apply_language_config` at the end, so
    /// user `[languages]` config survives any rebuild.
    pub(crate) fn rebuild_catalog(&mut self) {
        // Reverse-map: full_name (lowercase) -> shortest alias.
        //
        // Seed from the built-in alias table as well as the live `aliases`
        // HashMap: the live map only contains aliases whose target exists in
        // the syntect set, so tree-sitter-only entries (TypeScript) would
        // otherwise never get their short name ("ts").
        let mut short_by_full: HashMap<String, String> = HashMap::new();
        let record = |map: &mut HashMap<String, String>, short: &str, full: &str| {
            let key = full.to_lowercase();
            let keep = match map.get(&key) {
                None => true,
                Some(existing) => short.len() < existing.len(),
            };
            if keep {
                map.insert(key, short.to_string());
            }
        };
        for (short, full) in Self::built_in_aliases() {
            record(&mut short_by_full, short, full);
        }
        for (short, full) in &self.aliases {
            record(&mut short_by_full, short, full);
        }

        let derive_language_id =
            |display_name: &str| -> (String, Option<fresh_languages::Language>) {
                let ts = tree_sitter_for_syntect_name(display_name);
                let id = ts
                    .map(|l| l.id().to_string())
                    .unwrap_or_else(|| display_name.to_lowercase());
                (id, ts)
            };

        let mut catalog: Vec<GrammarEntry> = Vec::new();
        let mut scope_to_index: HashMap<String, usize> = HashMap::new();

        // Syntect-backed entries (skip Plain Text and JavaScript).
        //
        // Syntect's `file_extensions` is a hybrid list: real extensions like
        // "rb" sit alongside bare filenames like "Gemfile", "Rakefile",
        // "Makefile". Syntect's own `find_syntax_for_file` tries each entry
        // against the whole filename AND against the path's extension, and
        // the catalog has to preserve that semantics. We keep everything in
        // `extensions` here and index each entry as *both* an extension and
        // a filename at the bottom of this method.
        //
        // JavaScript is skipped here so the catalog falls through to the
        // tree-sitter-only fallback below — the bundled syntect JS grammar
        // mishandles class fields whose initialiser is an arrow function
        // returning a template literal (issue #899: state leaks past the
        // closing backtick and paints the rest of the file as a string).
        // tree-sitter-javascript parses template literals from the AST and
        // does not have this failure mode. `find_syntax_by_name("JavaScript")`
        // still returns syntect's grammar via the catalog's fallback path,
        // so markdown popup rendering and other code-string highlighters
        // are unaffected.
        //
        // TypeScript is skipped for the same reason in reverse: its bundled
        // grammar exists only for embedded contexts (Vue `lang="ts"`,
        // Markdown ```ts fences, resolved via `find_syntax_by_token`), and
        // `.ts` buffers must keep the richer tree-sitter highlighting.
        for (idx, syntax) in self.syntax_set.syntaxes().iter().enumerate() {
            if syntax.name == "Plain Text"
                || syntax.name == "JavaScript"
                || syntax.name == "TypeScript"
            {
                continue;
            }
            let (language_id, tree_sitter) = derive_language_id(&syntax.name);
            let short_name = short_by_full.get(&syntax.name.to_lowercase()).cloned();
            let source = self
                .grammar_sources
                .get(&syntax.name)
                .map(|info| info.source.clone())
                .unwrap_or(GrammarSource::BuiltIn);
            let entry_index = catalog.len();
            scope_to_index.insert(syntax.scope.to_string(), entry_index);

            // Union syntect's file_extensions with tree-sitter's own
            // extension list when the entry carries both engines.
            // tree-sitter-javascript handles `.jsx`/`.mjs`/`.cjs` that
            // syntect's JS grammar doesn't list, and the old code used to
            // route those paths to tree-sitter via a separate lookup.
            let mut extensions = syntax.file_extensions.clone();
            if let Some(lang) = tree_sitter {
                for ext in lang.extensions() {
                    let ext = ext.to_string();
                    if !extensions.iter().any(|e| e == &ext) {
                        extensions.push(ext);
                    }
                }
            }

            // Only Fresh's dedicated Fish grammar may own `.fish`. Syntect's
            // stock Bash grammar also advertises the extension (a quirk of its
            // upstream definition), which would otherwise shadow Fish in the
            // first-wins extension index since Bash precedes it in the packdump.
            if syntax.name != "Fish" {
                extensions.retain(|e| e != "fish");
            }

            catalog.push(GrammarEntry {
                display_name: syntax.name.clone(),
                language_id,
                short_name,
                extensions,
                filenames: Vec::new(),
                filename_globs: Vec::new(),
                source,
                engines: GrammarEngines {
                    syntect: Some(idx),
                    tree_sitter,
                },
            });
        }

        // Attach filename_scopes to their entries.
        for (filename, scope) in &self.filename_scopes {
            if let Some(&idx) = scope_to_index.get(scope) {
                if !catalog[idx].filenames.iter().any(|f| f == filename) {
                    catalog[idx].filenames.push(filename.clone());
                }
            }
        }

        // Attach user_extensions (extra → scope) to their entries.
        for (ext, scope) in &self.user_extensions {
            if let Some(&idx) = scope_to_index.get(scope) {
                if !catalog[idx].extensions.iter().any(|e| e == ext) {
                    catalog[idx].extensions.push(ext.clone());
                }
            }
        }

        // Ensure every tree-sitter language has an entry. If a syntect entry
        // already maps to the same tree-sitter language, skip it; otherwise
        // add a tree-sitter-only entry so the catalog is complete (TypeScript
        // being the motivating example — syntect ships no grammar for it).
        let mut ts_covered: std::collections::HashSet<fresh_languages::Language> =
            std::collections::HashSet::new();
        for entry in &catalog {
            if let Some(lang) = entry.engines.tree_sitter {
                ts_covered.insert(lang);
            }
        }
        for lang in fresh_languages::Language::all() {
            if ts_covered.contains(lang) {
                continue;
            }
            let display_name = lang.display_name().to_string();
            let language_id = lang.id().to_string();
            let short_name = short_by_full.get(&display_name.to_lowercase()).cloned();
            let extensions: Vec<String> = lang.extensions().iter().map(|s| s.to_string()).collect();
            catalog.push(GrammarEntry {
                display_name,
                language_id,
                short_name,
                extensions,
                filenames: Vec::new(),
                filename_globs: Vec::new(),
                source: GrammarSource::BuiltIn,
                engines: GrammarEngines {
                    syntect: None,
                    tree_sitter: Some(*lang),
                },
            });
        }

        // Build name / extension / filename indices.
        //
        // Every entry in `extensions` gets indexed in BOTH `by_extension`
        // (lowercased) AND `by_filename` (exact case) — syntect's
        // `file_extensions` list holds both real extensions ("rb") and bare
        // filenames ("Gemfile", "Rakefile", "Makefile"). Indexing both ways
        // matches syntect's own `find_syntax_for_file` semantics.
        let mut by_name: HashMap<String, usize> = HashMap::new();
        let mut by_extension: HashMap<String, usize> = HashMap::new();
        let mut by_filename: HashMap<String, usize> = HashMap::new();
        for (idx, entry) in catalog.iter().enumerate() {
            by_name.insert(entry.display_name.to_lowercase(), idx);
            by_name.insert(entry.language_id.to_lowercase(), idx);
            if let Some(short) = &entry.short_name {
                by_name.insert(short.to_lowercase(), idx);
            }
            for ext in &entry.extensions {
                by_extension.entry(ext.to_lowercase()).or_insert(idx);
                by_filename.entry(ext.clone()).or_insert(idx);
            }
            for filename in &entry.filenames {
                by_filename.entry(filename.clone()).or_insert(idx);
            }
        }

        self.catalog = catalog;
        self.catalog_by_name = by_name;
        self.catalog_by_extension = by_extension;
        self.catalog_by_filename = by_filename;

        // Replay the most recent user config so a rebuild doesn't silently
        // wipe out user `[languages]` rules. `take` + restore avoids both a
        // clone and a borrow checker fight with `apply_language_config_inner`.
        if !self.applied_language_config.is_empty() {
            let cfg = std::mem::take(&mut self.applied_language_config);
            self.apply_language_config_inner(&cfg);
            self.applied_language_config = cfg;
        }
        self.catalog_gen = self.catalog_gen.wrapping_add(1);
    }

    /// Return the full catalog of grammar entries.
    pub fn catalog(&self) -> &[GrammarEntry] {
        &self.catalog
    }

    /// Monotonic generation, bumped on every catalog mutation. Compare against
    /// a previously-observed value to decide whether to recompute derived
    /// state.
    pub fn catalog_gen(&self) -> u64 {
        self.catalog_gen
    }

    /// Look up a grammar entry by display name, language ID, or short alias
    /// (case-insensitive). All aliases — built-in and user-config-declared —
    /// are indexed directly in `catalog_by_name` during `rebuild_catalog` /
    /// `register_alias` / `apply_language_config`, so a single lookup covers
    /// every case.
    pub fn find_by_name(&self, name: &str) -> Option<&GrammarEntry> {
        self.catalog_by_name
            .get(&name.to_lowercase())
            .map(|&idx| &self.catalog[idx])
    }

    /// Look up a grammar entry by file path, with optional first-line content
    /// for shebang / `first_line_match` detection.
    ///
    /// Resolution order:
    /// 1. Exact filename (config-declared filenames and filename_scopes live here)
    /// 2. Glob patterns from user config (e.g. "*.conf", "/etc/**/rc.*")
    /// 3. File extension
    /// 4. Shebang / first-line regex match on `first_line` if supplied
    ///
    /// Globs take priority over extension so a user rule like `*.conf → bash`
    /// wins over any built-in extension match on `.conf`. The first-line
    /// fallback (#4) is last so catalog matches stay authoritative — syntect
    /// might otherwise misclassify `.fish` as bash via its first-line
    /// regexes.
    ///
    /// The first-line fallback is pure: it runs syntect's
    /// `find_syntax_by_first_line` regex cache against the caller-supplied
    /// string. The registry never touches the filesystem — the caller (who
    /// already loaded the buffer via the `FileSystem` trait) must extract
    /// the first line and pass it in.
    pub fn find_by_path(&self, path: &Path, first_line: Option<&str>) -> Option<&GrammarEntry> {
        let filename = path.file_name().and_then(|n| n.to_str());
        let path_str = path.to_str().unwrap_or("");

        if let Some(name) = filename {
            if let Some(&idx) = self.catalog_by_filename.get(name) {
                return Some(&self.catalog[idx]);
            }
        }

        // Glob walk — filenames with globs are rare so linear scan is fine.
        if let Some(name) = filename {
            for entry in &self.catalog {
                for pattern in &entry.filename_globs {
                    let matched = if is_path_pattern(pattern) {
                        path_glob_matches(pattern, path_str)
                    } else {
                        filename_glob_matches(pattern, name)
                    };
                    if matched {
                        return Some(entry);
                    }
                }
            }
        }

        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
            if let Some(entry) = self.find_by_extension(ext) {
                return Some(entry);
            }
        }

        // Last resort: shebang / first-line regex match against the
        // caller-supplied content. Map the matched syntect grammar back to a
        // catalog entry by name — every syntect syntax has a catalog entry,
        // so this round-trip preserves tree-sitter attachment.
        let line = first_line?;
        if let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line) {
            if let Some(entry) = self.find_by_name(&syntax.name) {
                return Some(entry);
            }
        }

        // Final fallback: map the shebang interpreter directly to a known
        // grammar, covering interpreters whose grammars ship no syntect
        // `first_line_match` regex (fish, Lua, PowerShell, …) — see `shebang`.
        let lang = super::shebang::language_for_shebang(line)?;
        self.find_by_name(lang)
    }

    /// Look up a grammar entry by file extension (case-insensitive, without dot).
    pub fn find_by_extension(&self, ext: &str) -> Option<&GrammarEntry> {
        self.catalog_by_extension
            .get(&ext.to_lowercase())
            .map(|&idx| &self.catalog[idx])
    }

    /// Merge user `[languages]` config into the catalog.
    ///
    /// For each config entry, resolves its grammar to an existing catalog entry
    /// (by grammar name or by language id). Extensions are added and override
    /// the ext→entry index so config wins over built-in mappings. Filenames are
    /// split into exact matches (indexed) and globs (walked at lookup time).
    ///
    /// If no existing entry matches, a new engine-less entry is created so the
    /// language still appears in the palette.
    ///
    /// Idempotent. The config is cached on the registry so `rebuild_catalog`
    /// can replay it — callers don't need to re-apply after a rebuild.
    pub fn apply_language_config(
        &mut self,
        languages: &HashMap<String, crate::config::LanguageConfig>,
    ) {
        self.applied_language_config = languages.clone();
        self.apply_language_config_inner(languages);
        self.catalog_gen = self.catalog_gen.wrapping_add(1);
    }

    /// Do the actual catalog splicing without touching
    /// `applied_language_config`. Called from `apply_language_config` (which
    /// records the input) and from `rebuild_catalog` (which replays the
    /// cached input after wiping the catalog).
    fn apply_language_config_inner(
        &mut self,
        languages: &HashMap<String, crate::config::LanguageConfig>,
    ) {
        for (lang_id, lang_cfg) in languages {
            let grammar_name = if lang_cfg.grammar.is_empty() {
                lang_id.as_str()
            } else {
                lang_cfg.grammar.as_str()
            };

            // Resolve to an existing entry; fall back to creating one.
            let idx = self
                .catalog_by_name
                .get(&grammar_name.to_lowercase())
                .copied()
                .or_else(|| self.catalog_by_name.get(&lang_id.to_lowercase()).copied())
                .unwrap_or_else(|| {
                    let idx = self.catalog.len();
                    self.catalog.push(GrammarEntry {
                        display_name: lang_id.clone(),
                        language_id: lang_id.clone(),
                        short_name: None,
                        extensions: Vec::new(),
                        filenames: Vec::new(),
                        filename_globs: Vec::new(),
                        source: GrammarSource::BuiltIn,
                        engines: GrammarEngines::default(),
                    });
                    idx
                });

            // Always index the config key so `find_by_name("mylang")` resolves
            // even when `mylang` aliases an existing grammar (e.g.
            // `[languages.mylang] grammar = "Rust"`). `or_insert` preserves
            // any existing mapping — won't clobber the canonical entry.
            self.catalog_by_name
                .entry(lang_id.to_lowercase())
                .or_insert(idx);

            for ext in &lang_cfg.extensions {
                if !self.catalog[idx].extensions.iter().any(|e| e == ext) {
                    self.catalog[idx].extensions.push(ext.clone());
                }
                // Config-declared extensions override any previous mapping.
                self.catalog_by_extension.insert(ext.to_lowercase(), idx);
            }
            for filename in &lang_cfg.filenames {
                if is_glob_pattern(filename) {
                    if !self.catalog[idx]
                        .filename_globs
                        .iter()
                        .any(|f| f == filename)
                    {
                        self.catalog[idx].filename_globs.push(filename.clone());
                    }
                } else {
                    if !self.catalog[idx].filenames.iter().any(|f| f == filename) {
                        self.catalog[idx].filenames.push(filename.clone());
                    }
                    self.catalog_by_filename.insert(filename.clone(), idx);
                }
            }
        }
    }

    /// Get the underlying syntax set
    pub fn syntax_set(&self) -> &Arc<SyntaxSet> {
        &self.syntax_set
    }

    /// Get a clone of the Arc for sharing
    pub fn syntax_set_arc(&self) -> Arc<SyntaxSet> {
        Arc::clone(&self.syntax_set)
    }

    /// List all available syntax names
    pub fn available_syntaxes(&self) -> Vec<&str> {
        self.syntax_set
            .syntaxes()
            .iter()
            .map(|s| s.name.as_str())
            .collect()
    }

    /// List all available grammars with provenance information.
    ///
    /// Returns a sorted list of `GrammarInfo` entries derived from the unified
    /// catalog — this includes both syntect grammars and tree-sitter-only
    /// languages (like TypeScript). Each entry is listed exactly once even
    /// when both engines can serve it.
    pub fn available_grammar_info(&self) -> Vec<GrammarInfo> {
        let mut result: Vec<GrammarInfo> = self
            .catalog
            .iter()
            .map(|entry| GrammarInfo {
                name: entry.display_name.clone(),
                source: entry.source.clone(),
                file_extensions: entry.extensions.clone(),
                short_name: entry.short_name.clone(),
            })
            .collect();
        result.sort_by_key(|a| a.name.to_lowercase());
        result
    }

    /// Get the grammar sources map.
    pub(crate) fn grammar_sources(&self) -> &HashMap<String, GrammarInfo> {
        &self.grammar_sources
    }

    /// Build grammar source info from a pre-compiled syntax set.
    ///
    /// All grammars in the packdump (syntect defaults + embedded) are tagged as built-in.
    pub(crate) fn build_grammar_sources_from_syntax_set(
        syntax_set: &SyntaxSet,
    ) -> HashMap<String, GrammarInfo> {
        let mut sources = HashMap::new();
        for syntax in syntax_set.syntaxes() {
            sources.insert(
                syntax.name.clone(),
                GrammarInfo {
                    name: syntax.name.clone(),
                    source: GrammarSource::BuiltIn,
                    file_extensions: syntax.file_extensions.clone(),
                    short_name: None,
                },
            );
        }
        sources
    }

    /// Get the user extensions mapping (extension -> scope name).
    #[cfg(test)]
    pub(crate) fn user_extensions(&self) -> &HashMap<String, String> {
        &self.user_extensions
    }

    /// Get the loaded grammar paths (for deduplication in flush_pending_grammars).
    #[cfg(test)]
    pub(crate) fn loaded_grammar_paths(&self) -> &[GrammarSpec] {
        &self.loaded_grammar_paths
    }

    /// Grammar files declared by `[languages.<id>] textmate_grammar = "…"`.
    ///
    /// The language's own `extensions` come along so the loaded grammar is
    /// reachable by file extension, the same way plugin-registered grammars are.
    pub fn config_grammar_specs(
        languages: &HashMap<String, crate::config::LanguageConfig>,
    ) -> Vec<GrammarSpec> {
        let mut specs: Vec<GrammarSpec> = languages
            .iter()
            .filter_map(|(id, lc)| {
                Some(GrammarSpec {
                    language: id.clone(),
                    path: lc.textmate_grammar.clone()?,
                    extensions: lc.extensions.clone(),
                })
            })
            .collect();
        // `languages` is a HashMap, so fix an order — otherwise two grammars
        // claiming the same extension would win at random between runs.
        specs.sort_by(|a, b| a.language.cmp(&b.language));
        specs
    }

    /// Load the `[languages]` block into `registry`: first any
    /// `textmate_grammar` files (a full syntax-set rebuild, so only when at
    /// least one is configured), then the catalog config — which the rebuild
    /// drops and so has to be re-applied after it.
    pub fn apply_languages(
        registry: &mut Arc<Self>,
        languages: &HashMap<String, crate::config::LanguageConfig>,
    ) {
        let specs = Self::config_grammar_specs(languages);
        if !specs.is_empty() {
            if let Some(rebuilt) = Self::with_additional_grammars(registry, &specs) {
                *registry = Arc::new(rebuilt);
            }
        }
        Arc::get_mut(registry)
            .expect("grammar registry Arc must be uniquely owned when applying language config")
            .apply_language_config(languages);
    }

    /// Create a new registry with additional grammar files
    ///
    /// This builds a new GrammarRegistry that includes all grammars from
    /// the base registry plus the additional grammars specified.
    /// Uses the base registry's syntax_set as the builder base, preserving
    /// all existing grammars (user grammars, language packs, etc.).
    ///
    /// # Arguments
    /// * `base` - The base registry to extend
    /// * `additional` - List of (language, path, extensions) tuples for new grammars
    ///
    /// # Returns
    /// A new GrammarRegistry with the additional grammars, or None if rebuilding fails
    pub fn with_additional_grammars(
        base: &GrammarRegistry,
        additional: &[GrammarSpec],
    ) -> Option<Self> {
        tracing::info!(
            "[SYNTAX DEBUG] with_additional_grammars: adding {} grammars to base with {} syntaxes",
            additional.len(),
            base.syntax_set.syntaxes().len()
        );

        // Use the base registry's syntax_set as builder base — this preserves
        // ALL existing grammars (defaults, embedded, user, language packs)
        // without needing to reload them from disk.
        let mut builder = (*base.syntax_set).clone().into_builder();

        // Preserve existing user extensions and add new ones
        let mut user_extensions = base.user_extensions.clone();

        // Track loaded grammar paths (existing + new)
        let mut loaded_grammar_paths = base.loaded_grammar_paths.clone();

        // Preserve existing grammar sources
        let mut grammar_sources = base.grammar_sources.clone();

        // Add each new grammar
        for spec in additional {
            tracing::info!(
                "[SYNTAX DEBUG] loading new grammar file: lang='{}', path={:?}, extensions={:?}",
                spec.language,
                spec.path,
                spec.extensions
            );
            match Self::load_grammar_file(&spec.path) {
                Ok(syntax) => {
                    let scope = syntax.scope.to_string();
                    let syntax_name = syntax.name.clone();
                    tracing::info!(
                        "[SYNTAX DEBUG] grammar loaded successfully: name='{}', scope='{}'",
                        syntax_name,
                        scope
                    );
                    builder.add(syntax);
                    tracing::info!(
                        "Loaded grammar for '{}' from {:?} with extensions {:?}",
                        spec.language,
                        spec.path,
                        spec.extensions
                    );
                    // Register extensions for this grammar
                    for ext in &spec.extensions {
                        user_extensions.insert(ext.clone(), scope.clone());
                    }
                    // Track provenance
                    grammar_sources.insert(
                        syntax_name.clone(),
                        GrammarInfo {
                            name: syntax_name,
                            source: GrammarSource::Plugin {
                                plugin: spec.language.clone(),
                                path: spec.path.clone(),
                            },
                            file_extensions: spec.extensions.clone(),
                            short_name: None,
                        },
                    );
                    // Track this grammar path for future reloads
                    loaded_grammar_paths.push(spec.clone());
                }
                Err(e) => {
                    tracing::warn!(
                        "Failed to load grammar for '{}' from {:?}: {}",
                        spec.language,
                        spec.path,
                        e
                    );
                }
            }
        }

        let mut reg = Self {
            syntax_set: Arc::new(builder.build()),
            user_extensions,
            filename_scopes: base.filename_scopes.clone(),
            loaded_grammar_paths,
            grammar_sources,
            aliases: base.aliases.clone(),
            catalog: Vec::new(),
            catalog_by_name: HashMap::new(),
            catalog_by_extension: HashMap::new(),
            catalog_by_filename: HashMap::new(),
            applied_language_config: HashMap::new(),
            catalog_gen: 0,
        };
        reg.rebuild_catalog();
        Some(reg)
    }

    /// Load a grammar file from disk
    ///
    /// Only Sublime Text (.sublime-syntax) format is supported.
    /// TextMate (.tmLanguage) grammars use a completely different format
    /// and cannot be loaded by syntect's yaml-load feature.
    pub(crate) fn load_grammar_file(path: &Path) -> Result<SyntaxDefinition, String> {
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");

        match ext {
            "sublime-syntax" => {
                let content = std::fs::read_to_string(path)
                    .map_err(|e| format!("Failed to read file: {}", e))?;
                SyntaxDefinition::load_from_str(
                    &content,
                    true,
                    path.file_stem().and_then(|s| s.to_str()),
                )
                .map_err(|e| format!("Failed to parse sublime-syntax: {}", e))
            }
            _ => Err(format!(
                "Unsupported grammar format: .{}. Only .sublime-syntax is supported.",
                ext
            )),
        }
    }
}

impl Default for GrammarRegistry {
    fn default() -> Self {
        // Create with defaults and embedded grammars only (no user grammars)
        let defaults = SyntaxSet::load_defaults_newlines();
        let mut builder = defaults.into_builder();
        Self::add_embedded_grammars(&mut builder);
        let syntax_set = builder.build();
        let filename_scopes = Self::build_filename_scopes();
        let extra_extensions = Self::build_extra_extensions();

        let mut registry = Self::new(syntax_set, extra_extensions, filename_scopes);
        registry.populate_built_in_aliases();
        registry.rebuild_catalog();
        registry
    }
}

// VSCode package.json structures for parsing grammar manifests

#[derive(Debug, Deserialize)]
pub struct PackageManifest {
    #[serde(default)]
    pub contributes: Option<Contributes>,
}

#[derive(Debug, Deserialize, Default)]
pub struct Contributes {
    #[serde(default)]
    pub languages: Vec<LanguageContribution>,
    #[serde(default)]
    pub grammars: Vec<GrammarContribution>,
}

#[derive(Debug, Deserialize)]
pub struct LanguageContribution {
    pub id: String,
    #[serde(default)]
    pub extensions: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub struct GrammarContribution {
    pub language: String,
    #[serde(rename = "scopeName")]
    pub scope_name: String,
    pub path: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_empty_registry() {
        let registry = GrammarRegistry::empty();
        // Should have at least plain text
        assert!(!registry.available_syntaxes().is_empty());
    }

    #[test]
    fn test_default_registry() {
        let registry = GrammarRegistry::default();
        // Should have built-in syntaxes
        assert!(!registry.available_syntaxes().is_empty());
    }

    #[test]
    fn test_find_syntax_for_common_extensions() {
        let registry = GrammarRegistry::default();

        // Test common extensions that resolve to a syntect (TextMate) grammar
        // via the catalog. JavaScript is intentionally NOT here — it is routed
        // exclusively to tree-sitter (issue #899) and so has no catalog-level
        // syntect entry. Code-block highlighting in popups still finds the
        // syntect JS grammar through `SyntaxSet::find_syntax_by_token`, which
        // bypasses the catalog.
        let test_cases = [
            ("test.py", true),
            ("test.rs", true),
            ("test.js", false),
            ("test.json", true),
            ("test.md", true),
            ("test.html", true),
            ("test.css", true),
            ("test.gd", true),
            ("test.unknown_extension_xyz", false),
        ];

        for (filename, should_exist) in test_cases {
            let path = Path::new(filename);
            let result = registry.find_syntax_for_file(path);
            assert_eq!(
                result.is_some(),
                should_exist,
                "Expected {:?} for {}",
                should_exist,
                filename
            );
        }
    }

    #[test]
    fn test_racket_grammar_loaded() {
        let registry = GrammarRegistry::default();
        for filename in ["main.rkt", "data.rktd", "info.rktl", "doc.scrbl"] {
            let result = registry.find_syntax_for_file(Path::new(filename));
            assert!(
                result.is_some(),
                "Racket grammar should be available for {}",
                filename
            );
            let entry = registry.find_by_path(Path::new(filename), None).unwrap();
            assert_eq!(entry.display_name, "Racket", "for {}", filename);
        }
    }

    #[test]
    fn test_syntax_set_arc() {
        let registry = GrammarRegistry::default();
        let arc1 = registry.syntax_set_arc();
        let arc2 = registry.syntax_set_arc();
        // Both should point to the same data
        assert!(Arc::ptr_eq(&arc1, &arc2));
    }

    #[test]
    fn test_shell_dotfiles_detection() {
        let registry = GrammarRegistry::default();

        // All these should be detected as shell scripts
        let shell_files = [".zshrc", ".zprofile", ".zshenv", ".bash_aliases"];

        for filename in shell_files {
            let path = Path::new(filename);
            let result = registry.find_syntax_for_file(path);
            assert!(
                result.is_some(),
                "{} should be detected as a syntax",
                filename
            );
            let syntax = result.unwrap();
            // Should be detected as Bash/Shell
            assert!(
                syntax.name.to_lowercase().contains("bash")
                    || syntax.name.to_lowercase().contains("shell"),
                "{} should be detected as shell/bash, got: {}",
                filename,
                syntax.name
            );
        }
    }

    #[test]
    fn test_shebang_interpreter_targets_resolve() {
        // Every language id the shebang table can return must exist in the
        // built-in catalog, otherwise the fallback would silently no-op.
        let registry = GrammarRegistry::default();
        for name in [
            "/bin/sh",
            "/usr/bin/fish",
            "/usr/bin/env python3",
            "/usr/bin/env ruby",
            "/usr/bin/perl",
            "/usr/bin/env php",
            "/usr/bin/env node",
            "/usr/bin/env deno",
            "/usr/bin/lua",
            "/usr/bin/pwsh",
            "/usr/bin/tclsh",
            "/usr/bin/env groovy",
            "/usr/bin/env elixir",
            "/usr/bin/env Rscript",
            "/usr/bin/env julia",
            "/usr/bin/nu",
            "/usr/bin/dart",
        ] {
            let line = format!("#!{name}\n");
            let lang = super::super::shebang::language_for_shebang(&line)
                .unwrap_or_else(|| panic!("expected an interpreter mapping for {line:?}"));
            assert!(
                registry.find_by_name(lang).is_some(),
                "interpreter mapping {line:?} → {lang:?} must resolve to a catalog grammar",
            );
        }
    }

    #[test]
    fn test_find_by_path_detects_shebang_only_interpreters() {
        // These interpreters have no syntect first-line regex; before the
        // interpreter-table fallback an extensionless script fell through to
        // plain text. Now `find_by_path` resolves them from the shebang alone.
        let registry = GrammarRegistry::default();
        let cases = [
            ("#!/usr/bin/fish\n", "fish"),
            ("#!/usr/bin/lua\n", "lua"),
            ("#!/usr/bin/pwsh\n", "powershell"),
            ("#!/usr/bin/tclsh\n", "tcl"),
            ("#!/usr/bin/env groovy\n", "groovy"),
            ("#!/usr/bin/env elixir\n", "elixir"),
            ("#!/usr/bin/env Rscript\n", "r"),
        ];
        for (first_line, expected_id) in cases {
            let entry = registry
                .find_by_path(Path::new("scriptfile"), Some(first_line))
                .unwrap_or_else(|| panic!("no grammar for {first_line:?}"));
            assert_eq!(
                entry.language_id, expected_id,
                "shebang {first_line:?} should detect {expected_id:?}, got {:?}",
                entry.language_id,
            );
        }
    }

    #[test]
    fn test_find_by_path_extension_still_wins_over_shebang() {
        // The interpreter fallback must not override an explicit extension
        // match — a `.py` file stays Python even with a shell shebang.
        let registry = GrammarRegistry::default();
        let entry = registry
            .find_by_path(Path::new("script.py"), Some("#!/bin/sh\n"))
            .unwrap();
        assert_eq!(entry.language_id, "python");
    }

    #[test]
    fn test_pkgbuild_detection() {
        let registry = GrammarRegistry::default();

        // PKGBUILD and APKBUILD should be detected as shell scripts
        for filename in ["PKGBUILD", "APKBUILD"] {
            let path = Path::new(filename);
            let result = registry.find_syntax_for_file(path);
            assert!(
                result.is_some(),
                "{} should be detected as a syntax",
                filename
            );
            let syntax = result.unwrap();
            // Should be detected as Bash/Shell
            assert!(
                syntax.name.to_lowercase().contains("bash")
                    || syntax.name.to_lowercase().contains("shell"),
                "{} should be detected as shell/bash, got: {}",
                filename,
                syntax.name
            );
        }
    }

    #[test]
    fn test_find_syntax_with_glob_filenames() {
        let mut registry = GrammarRegistry::default();
        let mut languages = std::collections::HashMap::new();
        languages.insert(
            "shell-configs".to_string(),
            crate::config::LanguageConfig {
                extensions: vec!["sh".to_string()],
                filenames: vec!["*.conf".to_string(), "*rc".to_string()],
                grammar: "bash".to_string(),
                comment_prefix: Some("#".to_string()),
                auto_indent: true,
                auto_close: None,
                auto_surround: None,
                textmate_grammar: None,
                show_whitespace_tabs: true,
                line_wrap: None,
                wrap_column: None,
                page_view: None,
                page_width: None,
                use_tabs: None,
                tab_size: None,
                formatter: None,
                format_on_save: false,
                on_save: vec![],
                word_characters: None,
                indentation_guide: None,
                indent: None,
            },
        );
        registry.apply_language_config(&languages);

        assert!(
            registry
                .find_by_path(Path::new("nftables.conf"), None)
                .is_some(),
            "*.conf should match nftables.conf"
        );
        assert!(
            registry.find_by_path(Path::new("lfrc"), None).is_some(),
            "*rc should match lfrc"
        );
        // Unrelated file shouldn't panic.
        let _ = registry.find_by_path(Path::new("randomfile"), None);
    }

    #[test]
    fn test_find_syntax_with_path_glob_filenames() {
        let mut registry = GrammarRegistry::default();
        let mut languages = std::collections::HashMap::new();
        languages.insert(
            "shell-configs".to_string(),
            crate::config::LanguageConfig {
                extensions: vec!["sh".to_string()],
                filenames: vec!["/etc/**/rc.*".to_string()],
                grammar: "bash".to_string(),
                comment_prefix: Some("#".to_string()),
                auto_indent: true,
                auto_close: None,
                auto_surround: None,
                textmate_grammar: None,
                show_whitespace_tabs: true,
                line_wrap: None,
                wrap_column: None,
                page_view: None,
                page_width: None,
                use_tabs: None,
                tab_size: None,
                formatter: None,
                format_on_save: false,
                on_save: vec![],
                word_characters: None,
                indentation_guide: None,
                indent: None,
            },
        );
        registry.apply_language_config(&languages);

        assert!(
            registry
                .find_by_path(Path::new("/etc/rc.conf"), None)
                .is_some(),
            "/etc/**/rc.* should match /etc/rc.conf"
        );
        assert!(
            registry
                .find_by_path(Path::new("/etc/init/rc.local"), None)
                .is_some(),
            "/etc/**/rc.* should match /etc/init/rc.local"
        );
        let _ = registry.find_by_path(Path::new("/var/rc.conf"), None);
    }

    #[test]
    fn test_exact_filename_takes_priority_over_glob() {
        let mut registry = GrammarRegistry::default();
        let mut languages = std::collections::HashMap::new();

        // A language with exact filename "lfrc" -> python grammar
        languages.insert(
            "custom-lfrc".to_string(),
            crate::config::LanguageConfig {
                extensions: vec![],
                filenames: vec!["lfrc".to_string()],
                grammar: "python".to_string(),
                comment_prefix: Some("#".to_string()),
                auto_indent: true,
                auto_close: None,
                auto_surround: None,
                textmate_grammar: None,
                show_whitespace_tabs: true,
                line_wrap: None,
                wrap_column: None,
                page_view: None,
                page_width: None,
                use_tabs: None,
                tab_size: None,
                formatter: None,
                format_on_save: false,
                on_save: vec![],
                word_characters: None,
                indentation_guide: None,
                indent: None,
            },
        );

        // A language with glob "*rc" -> bash grammar
        languages.insert(
            "rc-files".to_string(),
            crate::config::LanguageConfig {
                extensions: vec![],
                filenames: vec!["*rc".to_string()],
                grammar: "bash".to_string(),
                comment_prefix: Some("#".to_string()),
                auto_indent: true,
                auto_close: None,
                auto_surround: None,
                textmate_grammar: None,
                show_whitespace_tabs: true,
                line_wrap: None,
                wrap_column: None,
                page_view: None,
                page_width: None,
                use_tabs: None,
                tab_size: None,
                formatter: None,
                format_on_save: false,
                on_save: vec![],
                word_characters: None,
                indentation_guide: None,
                indent: None,
            },
        );

        registry.apply_language_config(&languages);

        // "lfrc" should match the exact rule (python), not the glob (bash)
        let entry = registry.find_by_path(Path::new("lfrc"), None).unwrap();
        assert!(
            entry.display_name.to_lowercase().contains("python"),
            "exact match should win over glob, got: {}",
            entry.display_name
        );
    }

    #[test]
    fn test_built_in_aliases_resolve() {
        let registry = GrammarRegistry::default();

        // "bash" should resolve to "Bourne Again Shell (bash)" via alias
        let syntax = registry.find_syntax_by_name("bash");
        assert!(syntax.is_some(), "alias 'bash' should resolve");
        assert_eq!(syntax.unwrap().name, "Bourne Again Shell (bash)");

        // "cpp" should resolve to "C++"
        let syntax = registry.find_syntax_by_name("cpp");
        assert!(syntax.is_some(), "alias 'cpp' should resolve");
        assert_eq!(syntax.unwrap().name, "C++");

        // "csharp" should resolve to "C#"
        let syntax = registry.find_syntax_by_name("csharp");
        assert!(syntax.is_some(), "alias 'csharp' should resolve");
        assert_eq!(syntax.unwrap().name, "C#");

        // "sh" should also resolve to bash
        let syntax = registry.find_syntax_by_name("sh");
        assert!(syntax.is_some(), "alias 'sh' should resolve");
        assert_eq!(syntax.unwrap().name, "Bourne Again Shell (bash)");

        // "proto" should resolve to "Protocol Buffers"
        let syntax = registry.find_syntax_by_name("proto");
        assert!(syntax.is_some(), "alias 'proto' should resolve");
        assert_eq!(syntax.unwrap().name, "Protocol Buffers");
    }

    #[test]
    fn test_alias_case_insensitive_input() {
        let registry = GrammarRegistry::default();

        // Aliases should be case-insensitive on input
        let syntax = registry.find_syntax_by_name("BASH");
        assert!(
            syntax.is_some(),
            "alias 'BASH' should resolve case-insensitively"
        );
        assert_eq!(syntax.unwrap().name, "Bourne Again Shell (bash)");

        let syntax = registry.find_syntax_by_name("Cpp");
        assert!(
            syntax.is_some(),
            "alias 'Cpp' should resolve case-insensitively"
        );
        assert_eq!(syntax.unwrap().name, "C++");
    }

    #[test]
    fn test_full_name_still_works() {
        let registry = GrammarRegistry::default();

        // Full names should still work (exact match)
        let syntax = registry.find_syntax_by_name("Bourne Again Shell (bash)");
        assert!(syntax.is_some(), "full name should still resolve");
        assert_eq!(syntax.unwrap().name, "Bourne Again Shell (bash)");

        // Case-insensitive full name should still work
        let syntax = registry.find_syntax_by_name("bourne again shell (bash)");
        assert!(
            syntax.is_some(),
            "case-insensitive full name should resolve"
        );
        assert_eq!(syntax.unwrap().name, "Bourne Again Shell (bash)");
    }

    #[test]
    fn test_alias_does_not_shadow_full_names() {
        let registry = GrammarRegistry::default();

        // "Rust" should resolve directly via case-insensitive match, not via alias
        let syntax = registry.find_syntax_by_name("rust");
        assert!(syntax.is_some());
        assert_eq!(syntax.unwrap().name, "Rust");

        // "Go" should resolve directly
        let syntax = registry.find_syntax_by_name("go");
        assert!(syntax.is_some());
        assert_eq!(syntax.unwrap().name, "Go");
    }

    #[test]
    fn test_register_alias_rejects_collision() {
        let mut registry = GrammarRegistry::default();

        // Trying to register an alias that maps to two different targets should fail
        assert!(registry.register_alias("myalias", "Rust"));
        assert!(!registry.register_alias("myalias", "Go"));

        // Same mapping is fine (idempotent)
        assert!(registry.register_alias("myalias", "Rust"));
    }

    #[test]
    fn test_register_alias_rejects_nonexistent_target() {
        let mut registry = GrammarRegistry::default();
        assert!(!registry.register_alias("nope", "Nonexistent Grammar"));
    }

    #[test]
    fn test_register_alias_skips_existing_grammar_name() {
        let mut registry = GrammarRegistry::default();

        // "rust" case-insensitively matches the grammar "Rust", so no alias needed
        assert!(!registry.register_alias("rust", "Rust"));
        // Should still be resolvable via case-insensitive match
        assert!(registry.find_syntax_by_name("rust").is_some());
    }

    #[test]
    fn test_available_grammar_info_includes_short_names() {
        let registry = GrammarRegistry::default();
        let infos = registry.available_grammar_info();

        let bash_info = infos.iter().find(|g| g.name == "Bourne Again Shell (bash)");
        assert!(bash_info.is_some(), "bash grammar should be in the list");
        let bash_info = bash_info.unwrap();
        assert!(
            bash_info.short_name.is_some(),
            "bash grammar should have a short_name"
        );
        // The shortest alias for bash is "sh"
        assert_eq!(bash_info.short_name.as_deref(), Some("sh"));
    }

    #[test]
    fn test_catalog_contains_each_language_once() {
        let registry = GrammarRegistry::default();
        let catalog = registry.catalog();

        // Every catalog entry must have a unique (case-insensitive) display name.
        let mut seen = std::collections::HashSet::new();
        for entry in catalog {
            let key = entry.display_name.to_lowercase();
            assert!(
                seen.insert(key.clone()),
                "duplicate catalog entry for display_name={:?}",
                entry.display_name
            );
        }

        // TypeScript is tree-sitter-only (syntect ships no grammar for it) yet
        // must still appear in the catalog.
        let ts = registry
            .find_by_name("TypeScript")
            .expect("TypeScript must be in the catalog");
        assert!(ts.engines.syntect.is_none());
        assert_eq!(
            ts.engines.tree_sitter,
            Some(fresh_languages::Language::TypeScript)
        );
        assert_eq!(ts.language_id, "typescript");
        assert!(ts.extensions.iter().any(|e| e == "ts"));

        // Languages that exist in both syntect and tree-sitter (Rust, Python)
        // must appear exactly once and prefer the syntect engine.
        for name in ["Rust", "Python"] {
            let entry = registry
                .find_by_name(name)
                .unwrap_or_else(|| panic!("{} must be in the catalog", name));
            assert!(
                entry.engines.syntect.is_some(),
                "{} should have a syntect index",
                name
            );
            assert!(
                entry.engines.tree_sitter.is_some(),
                "{} should also have a tree-sitter language",
                name
            );
            // Only one entry with this display name (already checked above),
            // but also verify language_id lookup lands on the same entry.
            let by_id = registry
                .find_by_name(&entry.language_id)
                .expect("language_id should resolve");
            assert_eq!(by_id.display_name, entry.display_name);
        }

        // JavaScript is deliberately routed to tree-sitter only — the
        // bundled syntect JavaScript grammar mishandles certain template
        // literals and bleeds string state into the rest of the file
        // (issue #899). The catalog must therefore expose a tree-sitter-only
        // entry, even though syntect ships a JavaScript grammar.
        let js = registry
            .find_by_name("JavaScript")
            .expect("JavaScript must be in the catalog");
        assert!(
            js.engines.syntect.is_none(),
            "JavaScript must not be routed to the syntect engine (issue #899)"
        );
        assert_eq!(
            js.engines.tree_sitter,
            Some(fresh_languages::Language::JavaScript),
            "JavaScript must carry the tree-sitter language"
        );

        let gdscript = registry
            .find_by_path(Path::new("player.gd"), None)
            .expect("player.gd should resolve to GDScript");
        assert_eq!(gdscript.display_name, "GDScript");
        assert_eq!(gdscript.language_id, "gdscript");
        assert!(
            gdscript.engines.syntect.is_some(),
            "GDScript should use the embedded Syntect grammar"
        );
        assert!(
            gdscript.engines.tree_sitter.is_none(),
            "GDScript must not carry a tree-sitter parser"
        );
    }

    #[test]
    fn test_catalog_find_by_path_and_extension() {
        let registry = GrammarRegistry::default();
        let ts = registry
            .find_by_path(Path::new("foo.ts"), None)
            .expect("foo.ts should resolve");
        assert_eq!(ts.display_name, "TypeScript");
        let rs = registry.find_by_extension("rs").expect("rs should resolve");
        assert_eq!(rs.display_name, "Rust");
    }

    #[test]
    fn test_smali_embedded_grammar_loads_and_resolves() {
        let syntax = SyntaxDefinition::load_from_str(SMALI_GRAMMAR, true, Some("Smali"))
            .expect("Smali grammar should parse");
        assert!(syntax.file_extensions.iter().any(|ext| ext == "smali"));

        let registry = GrammarRegistry::default();
        let entry = registry
            .find_by_path(Path::new("MainActivity.smali"), None)
            .expect("Smali files should resolve");
        assert_eq!(entry.display_name, "Smali");
        assert!(entry.engines.syntect.is_some());
        assert!(entry.engines.tree_sitter.is_none());
    }

    #[test]
    fn test_bazel_files_resolve_to_starlark_grammar() {
        let syntax = SyntaxDefinition::load_from_str(STARLARK_GRAMMAR, true, Some("Starlark"))
            .expect("Starlark grammar should parse");
        for extension in ["bzl", "star", "bazel"] {
            assert!(
                syntax.file_extensions.iter().any(|ext| ext == extension),
                "Starlark grammar should declare .{extension}"
            );
        }

        let registry = GrammarRegistry::default();
        for path in [
            "defs.bzl",
            "BUILD",
            "BUILD.bazel",
            "MODULE.bazel",
            "REPO.bazel",
            "WORKSPACE",
            "WORKSPACE.bazel",
        ] {
            let entry = registry
                .find_by_path(Path::new(path), None)
                .unwrap_or_else(|| panic!("{path} should resolve"));
            assert_eq!(entry.display_name, "Starlark", "for {path}");
            assert!(entry.engines.syntect.is_some(), "for {path}");
        }

        assert_eq!(
            registry
                .find_by_name("bazel")
                .expect("Bazel alias should resolve")
                .display_name,
            "Starlark"
        );
    }

    #[test]
    fn test_issue_2556_2557_2560_embedded_grammars_load_and_resolve() {
        for (grammar, name, path) in [
            (GETTEXT_GRAMMAR, "Gettext PO", "messages.po"),
            (M4_GRAMMAR, "M4", "configure.ac"),
            (PBXPROJ_GRAMMAR, "Xcode Project", "project.pbxproj"),
        ] {
            let syntax = SyntaxDefinition::load_from_str(grammar, true, Some(name))
                .unwrap_or_else(|e| panic!("{name} grammar should parse: {e}"));
            assert!(
                !syntax.file_extensions.is_empty(),
                "{name} grammar should declare file extensions"
            );

            let registry = GrammarRegistry::default();
            let entry = registry
                .find_by_path(Path::new(path), None)
                .unwrap_or_else(|| panic!("{path} should resolve"));
            assert_eq!(entry.display_name, name);
            assert!(entry.engines.syntect.is_some());
            assert!(entry.engines.tree_sitter.is_none());
        }
    }

    #[test]
    fn test_gpu_embedded_grammars_load_and_resolve() {
        for (grammar, name, path) in [
            (METAL_GRAMMAR, "Metal", "shader.metal"),
            (CUDA_GRAMMAR, "CUDA", "kernel.cu"),
            (HIP_GRAMMAR, "HIP", "kernel.hip"),
        ] {
            let syntax = SyntaxDefinition::load_from_str(grammar, true, Some(name))
                .unwrap_or_else(|e| panic!("{name} grammar should parse: {e}"));
            assert!(
                !syntax.file_extensions.is_empty(),
                "{name} grammar should declare file extensions"
            );

            let registry = GrammarRegistry::default();
            let entry = registry
                .find_by_path(Path::new(path), None)
                .unwrap_or_else(|| panic!("{path} should resolve"));
            assert_eq!(entry.display_name, name);
            assert!(entry.engines.syntect.is_some());
            assert!(entry.engines.tree_sitter.is_none());
        }
    }

    #[test]
    fn test_asset_and_build_metadata_grammars_load_and_resolve() {
        for (grammar, name, path) in [
            (GLSL_GRAMMAR, "GLSL", "shader.glslf"),
            (GLSL_GRAMMAR, "GLSL", "shader.glslv"),
            (WAVEFRONT_OBJ_GRAMMAR, "Wavefront OBJ", "mesh.obj"),
            (PKG_CONFIG_GRAMMAR, "pkg-config", "library.pc"),
            (CMAKE_CACHE_GRAMMAR, "CMake Cache", "CMakeCache.txt"),
            (CMAKE_CACHE_GRAMMAR, "CMake Cache", "cmakecache.txt"),
            (DOXYGEN_GRAMMAR, "Doxygen", "documentation.dox"),
            (DOXYGEN_GRAMMAR, "Doxygen", "documentation.doxy"),
            (DOXYGEN_CONFIG_GRAMMAR, "Doxygen Config", "Doxyfile.in"),
            (
                WINDOWS_RESOURCE_GRAMMAR,
                "Windows Resource Script",
                "Info.rc",
            ),
            (BIBTEX_STYLE_GRAMMAR, "BibTeX Style", "plain.bst"),
        ] {
            SyntaxDefinition::load_from_str(grammar, true, Some(name))
                .unwrap_or_else(|e| panic!("{name} grammar should parse: {e}"));

            let registry = GrammarRegistry::default();
            let entry = registry
                .find_by_path(Path::new(path), None)
                .unwrap_or_else(|| panic!("{path} should resolve"));
            assert_eq!(entry.display_name, name, "{path} resolved incorrectly");
            assert!(entry.engines.syntect.is_some());
            assert!(entry.engines.tree_sitter.is_none());
        }
    }

    #[test]
    fn test_latex_related_default_language_mappings() {
        let mut registry = GrammarRegistry::default();
        let config = crate::config::Config::default();
        registry.apply_language_config(&config.languages);

        for (path, expected) in [
            ("document.tex", "LaTeX"),
            ("document.tex.in", "LaTeX"),
            ("package.sty", "TeX"),
            ("package.dtx", "TeX"),
            ("citations.bbx", "TeX"),
            ("context.mkxl", "TeX"),
            ("document.aux", "TeX"),
            ("document.toc", "TeX"),
            ("references.bib", "BibTeX"),
            ("references.bib.in", "BibTeX"),
            ("plain.bst", "BibTeX Style"),
            ("latexmkrc", "Perl"),
            (".latexmkrc", "Perl"),
        ] {
            let entry = registry
                .find_by_path(Path::new(path), None)
                .unwrap_or_else(|| panic!("{path} should resolve"));
            assert_eq!(
                entry.display_name, expected,
                "{path} should use {expected} highlighting"
            );
            assert!(entry.engines.syntect.is_some());
        }
    }

    #[test]
    fn test_fortran_embedded_grammar_loads_and_resolves() {
        let syntax = SyntaxDefinition::load_from_str(FORTRAN_GRAMMAR, true, Some("Fortran"))
            .expect("Fortran grammar should parse");
        assert!(syntax.file_extensions.iter().any(|ext| ext == "f90"));

        let registry = GrammarRegistry::default();
        let entry = registry
            .find_by_path(Path::new("solver.f90"), None)
            .expect("Fortran files should resolve");
        assert_eq!(entry.display_name, "Fortran");
        assert!(entry.engines.syntect.is_some());
        assert!(entry.engines.tree_sitter.is_none());
    }

    #[test]
    fn test_mlir_embedded_grammar_loads_and_resolves() {
        let syntax = SyntaxDefinition::load_from_str(MLIR_GRAMMAR, true, Some("MLIR"))
            .expect("MLIR grammar should parse");
        assert!(syntax.file_extensions.iter().any(|ext| ext == "mlir"));

        let registry = GrammarRegistry::default();
        let entry = registry
            .find_by_path(Path::new("model.mlir"), None)
            .expect("MLIR files should resolve");
        assert_eq!(entry.display_name, "MLIR");
        assert!(entry.engines.syntect.is_some());
        assert!(entry.engines.tree_sitter.is_none());
    }

    #[test]
    fn test_llvm_ir_embedded_grammar_loads_and_resolves() {
        let syntax = SyntaxDefinition::load_from_str(LLVM_IR_GRAMMAR, true, Some("LLVM IR"))
            .expect("LLVM IR grammar should parse");
        assert!(syntax.file_extensions.iter().any(|ext| ext == "ll"));

        let registry = GrammarRegistry::default();
        let entry = registry
            .find_by_path(Path::new("module.ll"), None)
            .expect("LLVM IR files should resolve");
        assert_eq!(entry.display_name, "LLVM IR");
        assert!(entry.engines.syntect.is_some());
        assert!(entry.engines.tree_sitter.is_none());
    }

    /// Build a minimal LanguageConfig for tests.
    fn lang_cfg(
        grammar: &str,
        extensions: &[&str],
        filenames: &[&str],
    ) -> crate::config::LanguageConfig {
        crate::config::LanguageConfig {
            extensions: extensions.iter().map(|s| s.to_string()).collect(),
            filenames: filenames.iter().map(|s| s.to_string()).collect(),
            grammar: grammar.to_string(),
            comment_prefix: None,
            auto_indent: true,
            auto_close: None,
            auto_surround: None,
            textmate_grammar: None,
            show_whitespace_tabs: true,
            line_wrap: None,
            wrap_column: None,
            page_view: None,
            page_width: None,
            use_tabs: None,
            tab_size: None,
            formatter: None,
            format_on_save: false,
            on_save: vec![],
            word_characters: None,
            indentation_guide: None,
            indent: None,
        }
    }

    /// Bug #1: a user-declared config key that aliases an existing grammar
    /// (e.g. `[languages.mylang] grammar = "Rust"`) must resolve via
    /// `find_by_name("mylang")` so the language palette can select it.
    #[test]
    fn test_user_alias_resolves_via_find_by_name() {
        let mut registry = GrammarRegistry::default();
        let mut languages = std::collections::HashMap::new();
        languages.insert("mylang".to_string(), lang_cfg("Rust", &[], &[]));
        registry.apply_language_config(&languages);

        let entry = registry
            .find_by_name("mylang")
            .expect("user-declared alias 'mylang' must resolve");
        assert_eq!(entry.display_name, "Rust");
    }

    /// Bug #2: `register_alias` used to rebuild the catalog from scratch,
    /// wiping out everything `apply_language_config` had merged. Registering
    /// an alias afterwards must not lose user config.
    #[test]
    fn test_register_alias_preserves_applied_language_config() {
        let mut registry = GrammarRegistry::default();
        let mut languages = std::collections::HashMap::new();
        languages.insert(
            "shell-configs".to_string(),
            lang_cfg("bash", &["myconf"], &["*.myconf"]),
        );
        registry.apply_language_config(&languages);

        // Sanity: config applied.
        assert!(registry.find_by_extension("myconf").is_some());
        assert!(
            registry
                .find_by_path(Path::new("foo.myconf"), None)
                .is_some(),
            "glob should match before register_alias"
        );

        // Registering an alias must not erase the config we just applied.
        registry.register_alias("mycustom", "Rust");

        assert!(
            registry.find_by_extension("myconf").is_some(),
            "config extension must survive register_alias"
        );
        assert!(
            registry
                .find_by_path(Path::new("foo.myconf"), None)
                .is_some(),
            "glob must survive register_alias"
        );
    }

    /// Bug #4: `from_syntax_name` used to unconditionally overwrite the
    /// catalog's canonical display name with whatever the user typed (e.g.
    /// "BASH") — that string ended up in the status bar.
    #[test]
    fn test_from_syntax_name_preserves_canonical_display_name() {
        use crate::primitives::detected_language::DetectedLanguage;
        let registry = GrammarRegistry::default();
        let languages = std::collections::HashMap::new();

        let detected = DetectedLanguage::from_syntax_name("BASH", &registry, &languages)
            .expect("BASH should resolve via alias");
        assert_eq!(
            detected.display_name, "Bourne Again Shell (bash)",
            "display_name must be canonical, not user-typed"
        );
    }

    /// A config-only language (no matching syntect grammar) must still appear
    /// in the catalog so the language palette can offer it — the old
    /// `DetectedLanguage::from_config_language` branch was load-bearing.
    #[test]
    fn test_config_only_language_appears_in_catalog() {
        let mut registry = GrammarRegistry::default();
        let mut languages = std::collections::HashMap::new();
        languages.insert("elvish".to_string(), lang_cfg("elvish", &["elv"], &[]));
        registry.apply_language_config(&languages);

        let entry = registry
            .find_by_name("elvish")
            .expect("elvish should be in the catalog after apply_language_config");
        assert!(entry.engines.syntect.is_none());
        assert!(entry.engines.tree_sitter.is_none());
        assert_eq!(entry.language_id, "elvish");
        assert!(entry.extensions.iter().any(|e| e == "elv"));
    }

    #[test]
    fn test_fish_extension_resolves_to_fish_grammar_not_bash() {
        // Syntect's stock Bash grammar also advertises `.fish`; the catalog
        // strips it so only Fresh's dedicated Fish grammar owns the extension.
        let registry = GrammarRegistry::default();
        let entry = registry
            .find_by_extension("fish")
            .expect(".fish should resolve to a grammar entry");

        assert_eq!(entry.language_id, "fish");
        assert_eq!(entry.display_name, "Fish");
        assert!(entry.engines.syntect.is_some());
    }

    /// Config-declared extensions must override the built-in mapping. If the
    /// user says `[languages.typescript-overlay] extensions = ["js"] grammar
    /// = "TypeScript"`, then `foo.js` must resolve to TypeScript, not
    /// JavaScript.
    #[test]
    fn test_config_extension_overrides_builtin() {
        let mut registry = GrammarRegistry::default();
        // Sanity: default mapping is JavaScript.
        assert_eq!(
            registry.find_by_extension("js").unwrap().display_name,
            "JavaScript"
        );

        let mut languages = std::collections::HashMap::new();
        languages.insert(
            "ts-overlay".to_string(),
            lang_cfg("TypeScript", &["js"], &[]),
        );
        registry.apply_language_config(&languages);

        assert_eq!(
            registry.find_by_extension("js").unwrap().display_name,
            "TypeScript",
            "user-config extension must win over built-in"
        );
    }

    /// Bare filenames listed by syntect grammars (e.g. "Gemfile", "Makefile",
    /// "Rakefile") must resolve through `find_by_path`. Syntect stores these
    /// in each grammar's `file_extensions` field alongside real extensions
    /// like "rb"; its own `find_syntax_for_file` treats them as either. The
    /// catalog has to do the same or `HighlightEngine::for_file` breaks for
    /// every extensionless config file.
    #[test]
    fn test_bare_filename_resolves_via_find_by_path() {
        let registry = GrammarRegistry::default();
        for (filename, expected_substr) in [
            ("Gemfile", "ruby"),
            ("Rakefile", "ruby"),
            ("Vagrantfile", "ruby"),
            ("Makefile", "makefile"),
            ("GNUmakefile", "makefile"),
        ] {
            let entry = registry
                .find_by_path(Path::new(filename), None)
                .unwrap_or_else(|| panic!("{} must resolve via catalog", filename));
            assert!(
                entry.display_name.to_lowercase().contains(expected_substr),
                "{} should resolve to {} grammar, got {}",
                filename,
                expected_substr,
                entry.display_name
            );
        }
    }

    /// Languages that have both syntect and tree-sitter (e.g. JavaScript) must
    /// expose the union of both engines' extensions. Tree-sitter-javascript
    /// knows `.jsx`; syntect's JavaScript grammar does not. Both should route
    /// through the JavaScript catalog entry.
    #[test]
    fn test_jsx_resolves_to_javascript() {
        let registry = GrammarRegistry::default();
        let entry = registry
            .find_by_path(Path::new("foo.jsx"), None)
            .expect("foo.jsx must resolve");
        assert_eq!(entry.display_name, "JavaScript");
    }

    /// `rebuild_catalog` must replay the last-applied language config so it
    /// can never silently wipe user `[languages]` rules. This is the invariant
    /// that keeps `register_alias`, `populate_built_in_aliases`, and any
    /// future rebuild callsite safe-by-construction.
    #[test]
    fn test_rebuild_catalog_replays_language_config() {
        let mut registry = GrammarRegistry::default();
        let mut languages = std::collections::HashMap::new();
        languages.insert(
            "myshell".to_string(),
            lang_cfg("bash", &["myext"], &["*.myglob"]),
        );
        registry.apply_language_config(&languages);
        assert!(registry.find_by_extension("myext").is_some());
        assert!(registry
            .find_by_path(Path::new("foo.myglob"), None)
            .is_some());

        // Force a rebuild — the catalog gets wiped and re-populated from
        // syntect / tree-sitter, but user config must come back on top.
        registry.rebuild_catalog();
        assert!(
            registry.find_by_extension("myext").is_some(),
            "rebuild_catalog must replay applied user config"
        );
        assert!(
            registry
                .find_by_path(Path::new("foo.myglob"), None)
                .is_some(),
            "rebuild_catalog must replay user globs"
        );
    }

    /// `apply_language_config` must be idempotent: calling it twice with the
    /// same config yields the same catalog state.
    #[test]
    fn test_apply_language_config_idempotent() {
        let mut registry = GrammarRegistry::default();
        let mut languages = std::collections::HashMap::new();
        languages.insert(
            "shell-cfg".to_string(),
            lang_cfg("bash", &["myconf"], &["*.myconf"]),
        );

        registry.apply_language_config(&languages);
        let first_extensions = registry
            .find_by_name("bash")
            .unwrap()
            .extensions
            .iter()
            .filter(|e| e == &"myconf")
            .count();
        let first_globs = registry
            .find_by_name("bash")
            .unwrap()
            .filename_globs
            .iter()
            .filter(|g| g == &"*.myconf")
            .count();
        assert_eq!(first_extensions, 1);
        assert_eq!(first_globs, 1);

        // Second call must not duplicate anything.
        registry.apply_language_config(&languages);
        let second_extensions = registry
            .find_by_name("bash")
            .unwrap()
            .extensions
            .iter()
            .filter(|e| e == &"myconf")
            .count();
        let second_globs = registry
            .find_by_name("bash")
            .unwrap()
            .filename_globs
            .iter()
            .filter(|g| g == &"*.myconf")
            .count();
        assert_eq!(second_extensions, 1, "extensions must not duplicate");
        assert_eq!(second_globs, 1, "globs must not duplicate");
    }

    /// Julia: a single-quote after an identifier is the adjoint
    /// (conjugate-transpose) postfix operator, not the start of a string. The
    /// old grammar pushed a string context on every `'`, so `A'` swallowed
    /// the rest of the file until the next quote — wrecking highlighting for
    /// any subsequent keyword. Issue #1852.
    #[test]
    fn test_julia_adjoint_does_not_start_string() {
        use syntect::parsing::{ParseState, ScopeStack};

        let registry = GrammarRegistry::default();
        let syntax_set = registry.syntax_set();
        let syntax = registry
            .find_syntax_by_name("Julia")
            .expect("Julia grammar must be loaded");
        let mut state = ParseState::new(syntax);
        let mut scopes = ScopeStack::new();

        // Adjoint operator followed by code on later lines.
        let lines = ["x = A'\n", "function foo()\n", "end\n"];
        let mut keyword_line_in_string = false;
        let mut found_function_keyword = false;

        for line in &lines {
            let ops = state.parse_line(line, syntax_set).unwrap();
            // Walk byte-by-byte, applying ops as we pass their offset.
            let mut op_iter = ops.iter().peekable();
            for (byte_idx, _) in line.char_indices() {
                while let Some((offset, op)) = op_iter.peek() {
                    if *offset <= byte_idx {
                        scopes.apply(op).unwrap();
                        op_iter.next();
                    } else {
                        break;
                    }
                }
                let in_string = scopes
                    .as_slice()
                    .iter()
                    .any(|s| s.build_string().starts_with("string."));
                let is_function_kw = line[byte_idx..].starts_with("function");
                if is_function_kw && in_string {
                    keyword_line_in_string = true;
                }
                if is_function_kw && !in_string {
                    found_function_keyword = true;
                }
            }
            // Drain remaining ops at end of line.
            for (_, op) in op_iter {
                scopes.apply(op).unwrap();
            }
        }

        assert!(
            !keyword_line_in_string,
            "the `function` keyword after an adjoint operator must not be inside a string scope"
        );
        assert!(
            found_function_keyword,
            "test harness must have reached the `function` keyword"
        );
    }

    /// Julia: `'a'` is a valid character literal. The grammar must still
    /// scope it as a constant/character so themes can color it. Issue #1852.
    #[test]
    fn test_julia_char_literal_is_recognized() {
        use syntect::parsing::{ParseState, ScopeStack};

        let registry = GrammarRegistry::default();
        let syntax_set = registry.syntax_set();
        let syntax = registry
            .find_syntax_by_name("Julia")
            .expect("Julia grammar must be loaded");
        let mut state = ParseState::new(syntax);
        let mut scopes = ScopeStack::new();

        let line = "x = 'a'\n";
        let ops = state.parse_line(line, syntax_set).unwrap();
        let mut saw_constant_or_string_at_quote = false;
        let mut op_iter = ops.iter().peekable();
        for (byte_idx, _) in line.char_indices() {
            while let Some((offset, op)) = op_iter.peek() {
                if *offset <= byte_idx {
                    scopes.apply(op).unwrap();
                    op_iter.next();
                } else {
                    break;
                }
            }
            if byte_idx == 5 {
                // position of 'a' (the char)
                let scoped = scopes.as_slice().iter().any(|s| {
                    let str = s.build_string();
                    str.starts_with("constant.") || str.starts_with("string.")
                });
                if scoped {
                    saw_constant_or_string_at_quote = true;
                }
            }
        }
        assert!(
            saw_constant_or_string_at_quote,
            "char literal 'a' must receive a constant/string scope"
        );
    }

    /// `tree_sitter_for_syntect_name` handles the alias table + strict
    /// display-name match. The alias table catches syntect's verbose names;
    /// the strict match handles the common case.
    #[test]
    fn test_tree_sitter_bridge() {
        assert_eq!(
            tree_sitter_for_syntect_name("Bourne Again Shell (bash)"),
            Some(fresh_languages::Language::Bash)
        );
        assert_eq!(
            tree_sitter_for_syntect_name("Rust"),
            Some(fresh_languages::Language::Rust)
        );
        assert_eq!(tree_sitter_for_syntect_name("GDScript"), None);
        // Must NOT fuzzy-match Nushell to Bash.
        assert_eq!(tree_sitter_for_syntect_name("Nushell"), None);
        // Must NOT match arbitrary strings.
        assert_eq!(tree_sitter_for_syntect_name("does-not-exist"), None);
    }
}
