Skip to main content

lattice_syntax/
registry.rs

1//! Process-wide registry of `HighlightConfiguration`s, keyed by language
2//! name. Owned via `Arc` so every `Syntax` instance holds a cheap clone
3//! and the injection callback in `Highlighter::highlight` can look up
4//! sibling configs (markdown block → markdown_inline; markdown's fenced
5//! code blocks → rust / python / javascript / etc.) without each
6//! `Syntax` carrying its own copy.
7//!
8//! ## Why an `Arc` registry instead of per-document configs
9//!
10//! `HighlightConfiguration::new` does the expensive setup (compiling
11//! the highlights and injections queries against the language). Per-
12//! document copies would multiply that cost and inflate memory. The
13//! registry constructs each config once at startup; `Syntax` instances
14//! borrow refs through the shared `Arc<LangRegistry>`.
15//!
16//! The injection callback that `Highlighter::highlight` takes returns
17//! `Option<&'a HighlightConfiguration>` where `'a` is the highlighter's
18//! borrow lifetime. Closing the callback over `&self.registry` lets the
19//! callback return refs that live for the highlight call.
20
21use std::collections::HashMap;
22use std::sync::{Arc, OnceLock};
23
24use arc_swap::ArcSwap;
25
26use tree_sitter::{Language, Query};
27
28use crate::syntax::SyntaxError;
29
30// Embedded folds.scm queries. Files live at
31// `crates/lattice-syntax/queries/<lang>/folds.scm`; we ship them in
32// the binary via `include_str!` (no runtime path lookup, parity with
33// the embedded HIGHLIGHTS_QUERY constants exposed by the
34// tree-sitter-* crates).
35const RUST_FOLDS_QUERY: &str = include_str!("../queries/rust/folds.scm");
36const PYTHON_FOLDS_QUERY: &str = include_str!("../queries/python/folds.scm");
37const JAVASCRIPT_FOLDS_QUERY: &str = include_str!("../queries/javascript/folds.scm");
38const MARKDOWN_FOLDS_QUERY: &str = include_str!("../queries/markdown/folds.scm");
39// Custom markdown (block) highlights — the bundled tree-sitter-md query
40// captures headings level-less (`@text.title`); this one distinguishes
41// `@text.title.1` … `.6` by atx marker so headings get per-level size +
42// colour (see the query file's header + style.rs `text.title.N`).
43const MARKDOWN_HIGHLIGHTS_QUERY: &str = include_str!("../queries/markdown/highlights.scm");
44
45// Symbol queries -- one per language that supports the
46// `gen:tree-sitter-symbol` insert-completion source (Phase
47// 4.2.g.6 (1/2)). Markdown / markdown_inline don't ship one;
48// prose has no symbols-to-define semantic in this sense.
49const RUST_SYMBOLS_QUERY: &str = include_str!("../queries/rust/symbols.scm");
50const PYTHON_SYMBOLS_QUERY: &str = include_str!("../queries/python/symbols.scm");
51const JAVASCRIPT_SYMBOLS_QUERY: &str = include_str!("../queries/javascript/symbols.scm");
52
53// Text-object queries -- one per language that supports narrow-mode's
54// tree-sitter targets (`:narrow-function` / `:narrow-class` /
55// `:narrow-block`, N.1.3) and the future text-object operator set.
56// Capture names follow the nvim-treesitter / Helix convention
57// (`@function.outer`, `@class.outer`, `@block.outer`).
58// `SyntaxSnapshot::scope_at_cursor` reads them. Markdown ships none
59// (prose has no function/class/block semantics).
60const RUST_TEXTOBJECTS_QUERY: &str = include_str!("../queries/rust/textobjects.scm");
61const PYTHON_TEXTOBJECTS_QUERY: &str = include_str!("../queries/python/textobjects.scm");
62const JAVASCRIPT_TEXTOBJECTS_QUERY: &str = include_str!("../queries/javascript/textobjects.scm");
63
64// ── 15 additional Tier-1 language queries ───────────────────────
65const BASH_FOLDS_QUERY: &str = include_str!("../queries/bash/folds.scm");
66const BASH_SYMBOLS_QUERY: &str = include_str!("../queries/bash/symbols.scm");
67const BASH_TEXTOBJECTS_QUERY: &str = include_str!("../queries/bash/textobjects.scm");
68
69const C_FOLDS_QUERY: &str = include_str!("../queries/c/folds.scm");
70const C_SYMBOLS_QUERY: &str = include_str!("../queries/c/symbols.scm");
71const C_TEXTOBJECTS_QUERY: &str = include_str!("../queries/c/textobjects.scm");
72
73const CPP_FOLDS_QUERY: &str = include_str!("../queries/cpp/folds.scm");
74const CPP_SYMBOLS_QUERY: &str = include_str!("../queries/cpp/symbols.scm");
75const CPP_TEXTOBJECTS_QUERY: &str = include_str!("../queries/cpp/textobjects.scm");
76
77const CSS_FOLDS_QUERY: &str = include_str!("../queries/css/folds.scm");
78const CSS_SYMBOLS_QUERY: &str = include_str!("../queries/css/symbols.scm");
79const CSS_TEXTOBJECTS_QUERY: &str = include_str!("../queries/css/textobjects.scm");
80
81const GO_FOLDS_QUERY: &str = include_str!("../queries/go/folds.scm");
82const GO_SYMBOLS_QUERY: &str = include_str!("../queries/go/symbols.scm");
83const GO_TEXTOBJECTS_QUERY: &str = include_str!("../queries/go/textobjects.scm");
84
85const HTML_FOLDS_QUERY: &str = include_str!("../queries/html/folds.scm");
86const HTML_SYMBOLS_QUERY: &str = include_str!("../queries/html/symbols.scm");
87const HTML_TEXTOBJECTS_QUERY: &str = include_str!("../queries/html/textobjects.scm");
88
89const JAVA_FOLDS_QUERY: &str = include_str!("../queries/java/folds.scm");
90const JAVA_SYMBOLS_QUERY: &str = include_str!("../queries/java/symbols.scm");
91const JAVA_TEXTOBJECTS_QUERY: &str = include_str!("../queries/java/textobjects.scm");
92
93const JSON_FOLDS_QUERY: &str = include_str!("../queries/json/folds.scm");
94const JSON_SYMBOLS_QUERY: &str = include_str!("../queries/json/symbols.scm");
95const JSON_TEXTOBJECTS_QUERY: &str = include_str!("../queries/json/textobjects.scm");
96
97const LUA_FOLDS_QUERY: &str = include_str!("../queries/lua/folds.scm");
98const LUA_SYMBOLS_QUERY: &str = include_str!("../queries/lua/symbols.scm");
99const LUA_TEXTOBJECTS_QUERY: &str = include_str!("../queries/lua/textobjects.scm");
100
101const RUBY_FOLDS_QUERY: &str = include_str!("../queries/ruby/folds.scm");
102const RUBY_SYMBOLS_QUERY: &str = include_str!("../queries/ruby/symbols.scm");
103const RUBY_TEXTOBJECTS_QUERY: &str = include_str!("../queries/ruby/textobjects.scm");
104
105const SQL_FOLDS_QUERY: &str = include_str!("../queries/sql/folds.scm");
106const SQL_SYMBOLS_QUERY: &str = include_str!("../queries/sql/symbols.scm");
107const SQL_TEXTOBJECTS_QUERY: &str = include_str!("../queries/sql/textobjects.scm");
108
109const WIT_HIGHLIGHTS_QUERY: &str = include_str!("../queries/wit/highlights.scm");
110const WIT_FOLDS_QUERY: &str = include_str!("../queries/wit/folds.scm");
111const TOML_FOLDS_QUERY: &str = include_str!("../queries/toml/folds.scm");
112const TOML_SYMBOLS_QUERY: &str = include_str!("../queries/toml/symbols.scm");
113const TOML_TEXTOBJECTS_QUERY: &str = include_str!("../queries/toml/textobjects.scm");
114
115const TYPESCRIPT_FOLDS_QUERY: &str = include_str!("../queries/typescript/folds.scm");
116const TYPESCRIPT_SYMBOLS_QUERY: &str = include_str!("../queries/typescript/symbols.scm");
117const TYPESCRIPT_TEXTOBJECTS_QUERY: &str = include_str!("../queries/typescript/textobjects.scm");
118
119const YAML_FOLDS_QUERY: &str = include_str!("../queries/yaml/folds.scm");
120const YAML_SYMBOLS_QUERY: &str = include_str!("../queries/yaml/symbols.scm");
121const YAML_TEXTOBJECTS_QUERY: &str = include_str!("../queries/yaml/textobjects.scm");
122
123/// Per-language compiled state held by the shared registry.
124///
125/// Phase note: `highlight` is the legacy `tree_sitter_highlight`
126/// configuration that the streaming highlighter consumes; it stays
127/// in place during Step 1. `language` is the raw `tree_sitter::Language`
128/// that downstream owners (`Syntax::parse`, future query consumers
129/// like `folds.scm`, `textobjects.scm`, `indents.scm`) feed into
130/// their own `Parser` instances. Step 2 adds optional compiled
131/// queries (folds, etc.) to this struct without touching the
132/// highlight field.
133pub(crate) struct LangConfig {
134    pub(crate) language: Language,
135    /// Compiled `folds.scm` query, when the language ships one.
136    /// `None` means the syntax fold provider falls through to the
137    /// indent / markdown cascades for buffers in this language --
138    /// not every language we register has folds.scm yet (e.g.
139    /// `markdown_inline`, which is purely inline content).
140    pub(crate) folds: Option<Query>,
141    /// Compiled `highlights.scm` query for the hand-rolled native
142    /// highlighter (Step 3 of Option B). Same query string the
143    /// legacy `HighlightConfiguration` consumes, but compiled
144    /// here so we can run it directly via `QueryCursor` without
145    /// going through the streaming-event API.
146    pub(crate) highlights: Query,
147    /// Pre-resolved style for each capture index in `highlights`.
148    /// Indexed by `cap.index as usize`. Out-of-range values fall
149    /// back to `Style::Default` at lookup time.
150    pub(crate) highlight_styles: Vec<crate::style::Style>,
151    /// Pre-resolved CAPTURE_NAMES priority for each capture index in
152    /// `highlights` (lower value = higher precedence on overlap).
153    /// Used by the native pipeline's tie-break logic so e.g.
154    /// `@function` wins over `@variable` on the same byte range.
155    pub(crate) highlight_priorities: Vec<u32>,
156    /// Compiled `injections.scm` query, when the language ships one.
157    /// Markdown's block grammar is the canonical user (block →
158    /// inline + fenced code blocks → embedded language); some
159    /// languages (rust, JS) include light-weight injections too.
160    pub(crate) injections: Option<Query>,
161    /// Compiled `symbols.scm` query (Phase 4.2.g.6 (1/2)) --
162    /// captures definition-position identifiers the
163    /// `gen:tree-sitter-symbol` insert-completion source
164    /// surfaces. `None` for languages that don't ship a
165    /// symbols query (markdown family, currently); the source
166    /// emits no candidates for those buffers.
167    pub(crate) symbols: Option<Query>,
168    /// Compiled `textobjects.scm` query (N.1.0) -- captures
169    /// whole-construct (`@*.outer`) tree-sitter text objects that
170    /// narrow-mode targets via [`crate::SyntaxSnapshot::scope_at_cursor`].
171    /// `None` for languages that don't ship one (markdown family);
172    /// `scope_at_cursor` returns `None` for those buffers.
173    pub(crate) textobjects: Option<Query>,
174    /// IN.2: compiled `indents.scm` query, when the language ships
175    /// one. `None` means predictive indent falls back to the lexical
176    /// bridge for buffers in this language -- a graceful degradation
177    /// to vim's `smartindent`, not a failure.
178    ///
179    /// Unlike its siblings above, the SOURCE is not threaded through
180    /// `build_config`'s parameter list; it is looked up by language
181    /// name from `crate::indent::indents_source`. `build_config`
182    /// already carries eight positional arguments and a
183    /// `too_many_arguments` lint to match, so a ninth `Option<&str>`
184    /// repeated across twenty call sites is the wrong direction. The
185    /// name-keyed table also keeps the `include_str!` beside the
186    /// engine that consumes it.
187    pub(crate) indents: Option<Query>,
188    /// LG.3a: the host-issued id of the plugin that contributed this
189    /// language, or `None` for one compiled into the editor. Teardown
190    /// is `retain(|c| c.provenance != Some(id))` — by provenance, not by
191    /// a token list the caller has to remember to keep.
192    pub(crate) provenance: Option<u64>,
193    /// H.2: compiled display-time elision rules
194    /// (`docs/dev/architecture/conceal.md`). Empty for every language
195    /// that declares none, which is every language today except one —
196    /// and the emptiness is what makes the zero-cost path zero-cost.
197    ///
198    /// Lives here rather than on `LanguageRegistration` because this is
199    /// per-language *render* config, the same shelf `highlights` sits
200    /// on. That also means a NATIVE language gains rules by populating
201    /// one field in `build_native_config`, with no new plumbing —
202    /// markdown's `**`/`[]()` set is a rule list, not a mechanism.
203    pub(crate) conceal_rules: Arc<[crate::conceal::ConcealRule]>,
204}
205
206/// Catalog of every supported language's parser + highlight + injection
207/// configuration. Construct once via [`Self::standard`] and share by
208/// `Arc` clone.
209///
210/// `Default` produces an empty registry -- used by
211/// `lattice_host::editor::Editor::default()` for headless / placeholder
212/// construction. Production uses [`Self::standard`].
213/// `Clone` is cheap by construction: each config sits behind an `Arc`, so
214/// a clone is one refcount bump per language plus a small map. That is
215/// what makes the copy-on-write registration path affordable — compiling
216/// the queries for ~19 languages costs ~1.2 s and must never be repeated
217/// just because a plugin registered a twentieth.
218#[derive(Default, Clone)]
219pub struct LangRegistry {
220    configs: HashMap<&'static str, Arc<LangConfig>>,
221}
222
223impl std::fmt::Debug for LangRegistry {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct("LangRegistry")
226            .field("languages", &self.configs.keys().collect::<Vec<_>>())
227            .finish()
228    }
229}
230
231impl LangRegistry {
232    /// The standard registry: rust, python, javascript, markdown
233    /// (block + inline) and the rest. Returns an `Arc` so the App /
234    /// multiple `Syntax` instances can share one allocation.
235    ///
236    /// **Memoised process-wide.** Building it compiles five tree-sitter
237    /// queries (highlights, injections, folds, symbols, textobjects)
238    /// for each of ~19 languages, which measured at **~1.2 s** — and
239    /// `Editor::boot` calls this every time. In the test suite that was
240    /// the dominant cost of the entire crate: one App-building test
241    /// took 1.28 s against 0.01 s for a test that builds no App, and
242    /// `lattice-ui-tui`'s 1683 tests took 779 s almost entirely on
243    /// this. In production it is paid at startup, to open one file.
244    ///
245    /// Sharing is safe by construction rather than by convention: a
246    /// `LangRegistry` has no interior mutability and no `&mut self`
247    /// method, so a snapshot cannot be observed to change under its
248    /// holder. **Which snapshot you get can change** — LG.3a made this
249    /// the live RCU value so a plugin language appears in the same map
250    /// bundled ones live in, with no call-site change and no kind-branch
251    /// anywhere. Registration replaces the value; holders of an older
252    /// `Arc` keep a coherent older view until they next call this.
253    ///
254    /// See [`live`], [`register_plugin_language`] and
255    /// [`unregister_plugin`].
256    ///
257    /// Only success is cached. A failure here means a static query
258    /// string does not compile, which is deterministic and fatal (every
259    /// caller `expect`s it), so re-deriving it costs nothing real and
260    /// avoids requiring `SyntaxError: Clone`.
261    pub fn standard() -> Result<Arc<Self>, SyntaxError> {
262        live()
263    }
264
265    /// The uncached construction of the bundled set. Separated so the
266    /// live handle can build it exactly once; not public, because every
267    /// caller wants the shared, live one.
268    fn build_standard() -> Result<Self, SyntaxError> {
269        let mut configs: HashMap<&'static str, LangConfig> = HashMap::new();
270
271        configs.insert(
272            "rust",
273            build_config(
274                tree_sitter_rust::LANGUAGE.into(),
275                "rust",
276                tree_sitter_rust::HIGHLIGHTS_QUERY,
277                tree_sitter_rust::INJECTIONS_QUERY,
278                "",
279                Some(RUST_FOLDS_QUERY),
280                Some(RUST_SYMBOLS_QUERY),
281                Some(RUST_TEXTOBJECTS_QUERY),
282            )?,
283        );
284        configs.insert(
285            "python",
286            build_config(
287                tree_sitter_python::LANGUAGE.into(),
288                "python",
289                tree_sitter_python::HIGHLIGHTS_QUERY,
290                "",
291                "",
292                Some(PYTHON_FOLDS_QUERY),
293                Some(PYTHON_SYMBOLS_QUERY),
294                Some(PYTHON_TEXTOBJECTS_QUERY),
295            )?,
296        );
297        configs.insert(
298            "javascript",
299            build_config(
300                tree_sitter_javascript::LANGUAGE.into(),
301                "javascript",
302                tree_sitter_javascript::HIGHLIGHT_QUERY,
303                tree_sitter_javascript::INJECTIONS_QUERY,
304                tree_sitter_javascript::LOCALS_QUERY,
305                Some(JAVASCRIPT_FOLDS_QUERY),
306                Some(JAVASCRIPT_SYMBOLS_QUERY),
307                Some(JAVASCRIPT_TEXTOBJECTS_QUERY),
308            )?,
309        );
310
311        // Markdown: dual-grammar split. The block parser handles
312        // headings / lists / fenced blocks / blockquotes; its
313        // injections.scm wires the inline parser into paragraph
314        // content (`(inline) @injection.content (#set! injection.language "markdown_inline")`).
315        // The block grammar's injections.scm also drives fenced code
316        // blocks via `(fenced_code_block (info_string (language) @injection.language) (code_fence_content) @injection.content)`,
317        // so a `\`\`\`rust ... \`\`\`` block recurses into the rust
318        // config in this same registry.
319        configs.insert(
320            "markdown",
321            build_config(
322                tree_sitter_md::LANGUAGE.into(),
323                "markdown",
324                MARKDOWN_HIGHLIGHTS_QUERY,
325                tree_sitter_md::INJECTION_QUERY_BLOCK,
326                "",
327                Some(MARKDOWN_FOLDS_QUERY),
328                None,
329                None,
330            )?,
331        );
332        configs.insert(
333            "markdown_inline",
334            build_config(
335                tree_sitter_md::INLINE_LANGUAGE.into(),
336                "markdown_inline",
337                tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
338                tree_sitter_md::INJECTION_QUERY_INLINE,
339                "",
340                None,
341                None,
342                None,
343            )?,
344        );
345
346        // ── 15 additional Tier-1 languages ───────────────────────
347        configs.insert(
348            "bash",
349            build_config(
350                tree_sitter_bash::LANGUAGE.into(),
351                "bash",
352                tree_sitter_bash::HIGHLIGHT_QUERY,
353                "",
354                "",
355                Some(BASH_FOLDS_QUERY),
356                Some(BASH_SYMBOLS_QUERY),
357                Some(BASH_TEXTOBJECTS_QUERY),
358            )?,
359        );
360        configs.insert(
361            "c",
362            build_config(
363                tree_sitter_c::LANGUAGE.into(),
364                "c",
365                tree_sitter_c::HIGHLIGHT_QUERY,
366                "",
367                "",
368                Some(C_FOLDS_QUERY),
369                Some(C_SYMBOLS_QUERY),
370                Some(C_TEXTOBJECTS_QUERY),
371            )?,
372        );
373        configs.insert(
374            "cpp",
375            build_config(
376                tree_sitter_cpp::LANGUAGE.into(),
377                "cpp",
378                tree_sitter_cpp::HIGHLIGHT_QUERY,
379                "",
380                "",
381                Some(CPP_FOLDS_QUERY),
382                Some(CPP_SYMBOLS_QUERY),
383                Some(CPP_TEXTOBJECTS_QUERY),
384            )?,
385        );
386        configs.insert(
387            "css",
388            build_config(
389                tree_sitter_css::LANGUAGE.into(),
390                "css",
391                tree_sitter_css::HIGHLIGHTS_QUERY,
392                "",
393                "",
394                Some(CSS_FOLDS_QUERY),
395                Some(CSS_SYMBOLS_QUERY),
396                Some(CSS_TEXTOBJECTS_QUERY),
397            )?,
398        );
399        // NOTE: Dockerfile's tree-sitter grammar (0.2.0) depends on
400        // tree-sitter 0.20 — a different type than our 0.26 core.
401        // Skipped; Dockerfile buffers use Plain fallback.
402        configs.insert(
403            "go",
404            build_config(
405                tree_sitter_go::LANGUAGE.into(),
406                "go",
407                tree_sitter_go::HIGHLIGHTS_QUERY,
408                "",
409                "",
410                Some(GO_FOLDS_QUERY),
411                Some(GO_SYMBOLS_QUERY),
412                Some(GO_TEXTOBJECTS_QUERY),
413            )?,
414        );
415        configs.insert(
416            "html",
417            build_config(
418                tree_sitter_html::LANGUAGE.into(),
419                "html",
420                tree_sitter_html::HIGHLIGHTS_QUERY,
421                tree_sitter_html::INJECTIONS_QUERY,
422                "",
423                Some(HTML_FOLDS_QUERY),
424                Some(HTML_SYMBOLS_QUERY),
425                Some(HTML_TEXTOBJECTS_QUERY),
426            )?,
427        );
428        configs.insert(
429            "java",
430            build_config(
431                tree_sitter_java::LANGUAGE.into(),
432                "java",
433                tree_sitter_java::HIGHLIGHTS_QUERY,
434                "",
435                "",
436                Some(JAVA_FOLDS_QUERY),
437                Some(JAVA_SYMBOLS_QUERY),
438                Some(JAVA_TEXTOBJECTS_QUERY),
439            )?,
440        );
441        configs.insert(
442            "json",
443            build_config(
444                tree_sitter_json::LANGUAGE.into(),
445                "json",
446                tree_sitter_json::HIGHLIGHTS_QUERY,
447                "",
448                "",
449                Some(JSON_FOLDS_QUERY),
450                Some(JSON_SYMBOLS_QUERY),
451                Some(JSON_TEXTOBJECTS_QUERY),
452            )?,
453        );
454        configs.insert(
455            "lua",
456            build_config(
457                tree_sitter_lua::LANGUAGE.into(),
458                "lua",
459                tree_sitter_lua::HIGHLIGHTS_QUERY,
460                "",
461                "",
462                Some(LUA_FOLDS_QUERY),
463                Some(LUA_SYMBOLS_QUERY),
464                Some(LUA_TEXTOBJECTS_QUERY),
465            )?,
466        );
467        configs.insert(
468            "ruby",
469            build_config(
470                tree_sitter_ruby::LANGUAGE.into(),
471                "ruby",
472                tree_sitter_ruby::HIGHLIGHTS_QUERY,
473                "",
474                "",
475                Some(RUBY_FOLDS_QUERY),
476                Some(RUBY_SYMBOLS_QUERY),
477                Some(RUBY_TEXTOBJECTS_QUERY),
478            )?,
479        );
480        configs.insert(
481            "sql",
482            build_config(
483                tree_sitter_sequel::LANGUAGE.into(),
484                "sql",
485                tree_sitter_sequel::HIGHLIGHTS_QUERY,
486                "",
487                "",
488                Some(SQL_FOLDS_QUERY),
489                Some(SQL_SYMBOLS_QUERY),
490                Some(SQL_TEXTOBJECTS_QUERY),
491            )?,
492        );
493        // WIT. Two things differ from every sibling here, both forced:
494        //   * `tree_sitter_wit` exposes `language()` — a plain fn returning a
495        //     `Language` — rather than a `LanguageFn` constant, so there is no
496        //     `.into()`.
497        //   * the highlights query is OURS (`queries/wit/highlights.scm`). The
498        //     crate ships one, but in the TextMate capture vocabulary, and
499        //     `style::name_to_style` keys on the first dot-segment — so
500        //     `entity.name.type.interface` and its peers resolve to
501        //     `Style::Default` and every declaration NAME would render plain.
502        configs.insert(
503            "wit",
504            build_config(
505                tree_sitter_wit::language(),
506                "wit",
507                WIT_HIGHLIGHTS_QUERY,
508                "",
509                "",
510                Some(WIT_FOLDS_QUERY),
511                None,
512                None,
513            )?,
514        );
515        configs.insert(
516            "toml",
517            build_config(
518                tree_sitter_toml_ng::LANGUAGE.into(),
519                "toml",
520                tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
521                "",
522                "",
523                Some(TOML_FOLDS_QUERY),
524                Some(TOML_SYMBOLS_QUERY),
525                Some(TOML_TEXTOBJECTS_QUERY),
526            )?,
527        );
528        // NOTE: tree-sitter-typescript exposes two grammars:
529        // LANGUAGE_TYPESCRIPT (strict TS) and LANGUAGE_TSX (TS+JSX).
530        // We register "typescript" with the TS grammar; see "tsx"
531        // for the TSX variant.
532        configs.insert(
533            "typescript",
534            build_config(
535                tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
536                "typescript",
537                tree_sitter_typescript::HIGHLIGHTS_QUERY,
538                "",
539                tree_sitter_typescript::LOCALS_QUERY,
540                Some(TYPESCRIPT_FOLDS_QUERY),
541                Some(TYPESCRIPT_SYMBOLS_QUERY),
542                Some(TYPESCRIPT_TEXTOBJECTS_QUERY),
543            )?,
544        );
545        // TSX grammar (TypeScript + JSX) — separate language object
546        // but shares the same query files (the TSX grammar is a
547        // superset of the TypeScript grammar so all node type names
548        // valid in TS are valid in TSX, and TSX-only constructs will
549        // simply produce no captures if the query doesn't reference
550        // them).
551        configs.insert(
552            "tsx",
553            build_config(
554                tree_sitter_typescript::LANGUAGE_TSX.into(),
555                "tsx",
556                tree_sitter_typescript::HIGHLIGHTS_QUERY,
557                "",
558                tree_sitter_typescript::LOCALS_QUERY,
559                Some(TYPESCRIPT_FOLDS_QUERY),
560                Some(TYPESCRIPT_SYMBOLS_QUERY),
561                Some(TYPESCRIPT_TEXTOBJECTS_QUERY),
562            )?,
563        );
564        configs.insert(
565            "yaml",
566            build_config(
567                tree_sitter_yaml::LANGUAGE.into(),
568                "yaml",
569                tree_sitter_yaml::HIGHLIGHTS_QUERY,
570                "",
571                "",
572                Some(YAML_FOLDS_QUERY),
573                Some(YAML_SYMBOLS_QUERY),
574                Some(YAML_TEXTOBJECTS_QUERY),
575            )?,
576        );
577
578        // Wrapped here rather than at each of the twenty `insert` sites:
579        // the `Arc` exists to make `Clone` cheap, and nothing about
580        // *building* the standard set cares.
581        Ok(Self {
582            configs: configs.into_iter().map(|(k, v)| (k, Arc::new(v))).collect(),
583        })
584    }
585
586    /// The compiled `folds.scm` `Query` for `name`, when the language
587    /// ships one. Used by `lattice-ui-tui::folds::compute_syntax_folds`.
588    pub fn folds_query(&self, name: &str) -> Option<&Query> {
589        self.lookup(name).and_then(|c| c.folds.as_ref())
590    }
591
592    /// Compiled `highlights.scm` query for the native pipeline.
593    pub fn highlights_query(&self, name: &str) -> Option<&Query> {
594        self.lookup(name).map(|c| &c.highlights)
595    }
596
597    /// Per-capture `Style` table aligned with `highlights_query`'s
598    /// `capture_names()`.
599    pub fn highlight_styles(&self, name: &str) -> Option<&[crate::style::Style]> {
600        self.lookup(name).map(|c| c.highlight_styles.as_slice())
601    }
602
603    /// Per-capture priority table (CAPTURE_NAMES position) aligned
604    /// with `highlights_query`'s `capture_names()`. Lower = higher
605    /// precedence on overlap.
606    pub fn highlight_priorities(&self, name: &str) -> Option<&[u32]> {
607        self.lookup(name).map(|c| c.highlight_priorities.as_slice())
608    }
609
610    /// IN.2: the compiled `indents.scm` query for `name`, when the
611    /// language ships one. Consumed by `crate::indent`.
612    pub fn indents_query(&self, name: &str) -> Option<&Query> {
613        self.lookup(name).and_then(|c| c.indents.as_ref())
614    }
615
616    /// Compiled `injections.scm` query, when one is registered.
617    pub fn injections_query(&self, name: &str) -> Option<&Query> {
618        self.lookup(name).and_then(|c| c.injections.as_ref())
619    }
620
621    /// Compiled `symbols.scm` query for the
622    /// `gen:tree-sitter-symbol` insert-completion source
623    /// (Phase 4.2.g.6 (1/2)). `None` for languages that don't
624    /// ship a symbols query (markdown family today).
625    pub fn symbols_query(&self, name: &str) -> Option<&Query> {
626        self.lookup(name).and_then(|c| c.symbols.as_ref())
627    }
628
629    /// Compiled `textobjects.scm` query for `name` (N.1.0), when the
630    /// language ships one. Used by
631    /// [`crate::SyntaxSnapshot::scope_at_cursor`] to resolve narrow-mode's
632    /// tree-sitter targets (`:narrow-function` / `:narrow-class` /
633    /// `:narrow-block`). `None` for languages without a text-object
634    /// query (markdown family today). Resolves the same aliases as the
635    /// other per-query lookups.
636    pub fn textobjects_query(&self, name: &str) -> Option<&Query> {
637        self.lookup(name).and_then(|c| c.textobjects.as_ref())
638    }
639
640    /// Resolve the `tree_sitter::Language` for `name` (with the same
641    /// alias mapping as the other per-query lookups). Returned by value because
642    /// `Language` is a cheap `Arc`-equivalent handle in tree-sitter
643    /// 0.24+.
644    pub fn tree_sitter_language(&self, name: &str) -> Option<Language> {
645        self.lookup(name).map(|c| c.language.clone())
646    }
647
648    /// H.2: this language's compiled conceal rules, or an empty slice.
649    ///
650    /// Returns the `Arc` rather than a borrow so the display-matrix
651    /// build can hold the rules across a rebuild without pinning the
652    /// whole registry snapshot, and so a plugin unloading mid-rebuild
653    /// cannot pull them out from under it.
654    ///
655    /// **Empty is the answer for every language but one**, and callers
656    /// are expected to branch on that before doing any per-line work —
657    /// the conceal path must cost a Rust buffer nothing at all.
658    pub fn conceal_rules(&self, name: &str) -> Arc<[crate::conceal::ConcealRule]> {
659        self.lookup(name)
660            .map(|c| Arc::clone(&c.conceal_rules))
661            .unwrap_or_else(|| Arc::from([] as [crate::conceal::ConcealRule; 0]))
662    }
663
664    /// Internal lookup that returns the full per-language config
665    /// (includes the raw `Language` plus -- after Step 2 -- the
666    /// compiled folds / textobjects / indents queries).
667    pub(crate) fn lookup(&self, name: &str) -> Option<&LangConfig> {
668        let canonical = match name {
669            "rs" => "rust",
670            "py" => "python",
671            "js" | "mjs" | "cjs" => "javascript",
672            "sh" => "bash",
673            "h" => "c",
674            "cc" | "cxx" | "hpp" | "hh" | "hxx" => "cpp",
675            "htm" | "xhtml" => "html",
676            "rb" => "ruby",
677            "ts" | "mts" | "cts" => "typescript",
678            "yml" => "yaml",
679            "md" => "markdown",
680            other => other,
681        };
682        self.configs.get(canonical).map(|c| &**c)
683    }
684
685    /// True if a config exists for the given canonical language name.
686    pub fn has_lang(&self, name: &str) -> bool {
687        self.lookup(name).is_some()
688    }
689
690    /// Iterate the registered language names. Useful for tests + the
691    /// future `:describe-mode` content.
692    pub fn lang_names(&self) -> impl Iterator<Item = &&'static str> {
693        self.configs.keys()
694    }
695}
696
697// Crate-private per-language config builder. The positional arg list
698// grows by one optional query source per query type (folds, symbols,
699// textobjects, ...); past the clippy default of 7 it's still clearer
700// here than threading a builder struct through five call sites.
701#[allow(clippy::too_many_arguments)]
702fn build_config(
703    language: tree_sitter::Language,
704    name: &str,
705    highlights: &str,
706    injections: &str,
707    locals: &str,
708    folds: Option<&str>,
709    symbols: Option<&str>,
710    textobjects: Option<&str>,
711) -> Result<LangConfig, SyntaxError> {
712    let folds = match folds {
713        Some(src) => Some(
714            Query::new(&language, src)
715                .map_err(|e| SyntaxError::Language(format!("compile {name} folds.scm: {e}")))?,
716        ),
717        None => None,
718    };
719    let highlights_query = Query::new(&language, highlights)
720        .map_err(|e| SyntaxError::Language(format!("compile {name} highlights.scm: {e}")))?;
721    let highlight_styles: Vec<crate::style::Style> = highlights_query
722        .capture_names()
723        .iter()
724        .map(|n| crate::style::name_to_style_pub(n))
725        .collect();
726    let highlight_priorities: Vec<u32> = highlights_query
727        .capture_names()
728        .iter()
729        .map(|n| crate::style::capture_priority(n))
730        .collect();
731    let injections =
732        if injections.is_empty() {
733            None
734        } else {
735            Some(Query::new(&language, injections).map_err(|e| {
736                SyntaxError::Language(format!("compile {name} injections.scm: {e}"))
737            })?)
738        };
739    let symbols = match symbols {
740        Some(src) => Some(
741            Query::new(&language, src)
742                .map_err(|e| SyntaxError::Language(format!("compile {name} symbols.scm: {e}")))?,
743        ),
744        None => None,
745    };
746    let textobjects =
747        match textobjects {
748            Some(src) => Some(Query::new(&language, src).map_err(|e| {
749                SyntaxError::Language(format!("compile {name} textobjects.scm: {e}"))
750            })?),
751            None => None,
752        };
753    // IN.2: resolved by name rather than passed in -- see the
754    // `indents` field's doc on `LangConfig`.
755    let indents = match crate::indent::indents_source(name) {
756        Some(src) => Some(
757            Query::new(&language, src)
758                .map_err(|e| SyntaxError::Language(format!("compile {name} indents.scm: {e}")))?,
759        ),
760        None => None,
761    };
762    let _ = locals; // locals.scm support deferred to a follow-up commit.
763    Ok(LangConfig {
764        language,
765        folds,
766        highlights: highlights_query,
767        highlight_styles,
768        highlight_priorities,
769        injections,
770        symbols,
771        textobjects,
772        indents,
773        provenance: None,
774        conceal_rules: Arc::from([] as [crate::conceal::ConcealRule; 0]),
775    })
776}
777
778#[cfg(test)]
779mod tests {
780    #![allow(clippy::unwrap_used, clippy::panic)]
781    use super::*;
782
783    #[test]
784    fn standard_registry_includes_every_supported_language() {
785        let r = LangRegistry::standard().unwrap();
786        for name in [
787            "rust",
788            "python",
789            "javascript",
790            "markdown",
791            "markdown_inline",
792            "bash",
793            "c",
794            "cpp",
795            "css",
796            "go",
797            "html",
798            "java",
799            "json",
800            "lua",
801            "ruby",
802            "sql",
803            "toml",
804            "typescript",
805            "tsx",
806            "wit",
807            "yaml",
808        ] {
809            assert!(r.has_lang(name), "missing language: {name}");
810        }
811    }
812
813    #[test]
814    fn registry_resolves_common_aliases() {
815        let r = LangRegistry::standard().unwrap();
816        for alias in [
817            "rs", "py", "js", "md", "sh", "h", "cc", "cxx", "hpp", "hh", "hxx", "rb", "ts", "mts",
818            "cts", "yml",
819        ] {
820            assert!(r.has_lang(alias), "alias not resolved: {alias}");
821        }
822    }
823
824    #[test]
825    fn folds_query_compiled_for_each_language_with_folds_scm() {
826        let r = LangRegistry::standard().unwrap();
827        // Every language except markdown_inline ships folds.scm.
828        let with_folds = [
829            "rust",
830            "python",
831            "javascript",
832            "markdown",
833            "bash",
834            "c",
835            "cpp",
836            "css",
837            "go",
838            "html",
839            "java",
840            "json",
841            "lua",
842            "ruby",
843            "sql",
844            "toml",
845            "typescript",
846            "tsx",
847            "wit",
848            "yaml",
849        ];
850        for lang in with_folds {
851            assert!(r.folds_query(lang).is_some(), "{lang} folds.scm");
852        }
853        assert!(
854            r.folds_query("markdown_inline").is_none(),
855            "markdown_inline should not ship a folds.scm"
856        );
857    }
858
859    #[test]
860    fn folds_query_resolves_aliases() {
861        let r = LangRegistry::standard().unwrap();
862        for alias in ["rs", "py", "js", "md", "sh", "cc", "rb", "ts", "yml"] {
863            assert!(r.folds_query(alias).is_some(), "folds alias: {alias}");
864        }
865    }
866
867    #[test]
868    fn textobjects_query_compiled_for_each_code_language() {
869        // N.1.0: every code language ships a textobjects.scm; if any
870        // capture references an invalid node kind, `Query::new` errors
871        // at `standard()` construction and `.unwrap()` panics here.
872        let r = LangRegistry::standard().unwrap();
873        let with_textobjects = [
874            "rust",
875            "python",
876            "javascript",
877            "bash",
878            "c",
879            "cpp",
880            "css",
881            "go",
882            "html",
883            "java",
884            "json",
885            "lua",
886            "ruby",
887            "sql",
888            "toml",
889            "typescript",
890            "tsx",
891            "yaml",
892        ];
893        for lang in with_textobjects {
894            assert!(r.textobjects_query(lang).is_some(), "{lang} textobjects");
895        }
896        // Prose has no function/class/block semantics -- no query.
897        for lang in ["markdown", "markdown_inline"] {
898            assert!(
899                r.textobjects_query(lang).is_none(),
900                "{lang} ships textobjects (unexpected)"
901            );
902        }
903    }
904
905    #[test]
906    fn textobjects_query_resolves_aliases() {
907        let r = LangRegistry::standard().unwrap();
908        for alias in ["rs", "py", "js", "sh", "cc", "rb", "ts", "yml"] {
909            assert!(
910                r.textobjects_query(alias).is_some(),
911                "textobjects alias: {alias}"
912            );
913        }
914    }
915
916    #[test]
917    fn unregistered_language_returns_none() {
918        let r = LangRegistry::standard().unwrap();
919        assert!(!r.has_lang("zig"));
920        assert!(!r.has_lang(""));
921    }
922
923    #[test]
924    fn each_tier1_language_has_highlights_query() {
925        let r = LangRegistry::standard().unwrap();
926        for name in r.lang_names() {
927            assert!(r.highlights_query(name).is_some(), "{name} highlights.scm");
928        }
929    }
930
931    #[test]
932    fn each_tier1_language_has_tree_sitter_language() {
933        let r = LangRegistry::standard().unwrap();
934        for name in r.lang_names() {
935            assert!(
936                r.tree_sitter_language(name).is_some(),
937                "{name} tree_sitter_language"
938            );
939        }
940    }
941}
942
943// ── LG.3a: the live registry ────────────────────────────────────────
944//
945// Design: `plugin-languages.md` §2.3. The bundled set is built once and
946// then becomes the initial value of an RCU cell; a plugin language is
947// registered by cloning that value, inserting, and storing.
948//
949// `standard()` returns the LIVE snapshot, which is the whole point: a
950// plugin language is found by `registry.highlights_query(lang.name())`
951// exactly as `rust` is, so no lookup path anywhere in the tree learns
952// that plugin languages exist. The alternative — a second map consulted
953// when `Lang::Plugin` matches — would be a kind-branch in every accessor,
954// which is what the architecture rules forbid.
955
956/// The process-wide live registry.
957///
958/// The bundled set is built on first touch (~1.2 s of query compilation)
959/// and never rebuilt; registration clones the map, not the queries.
960fn handle() -> &'static Arc<ArcSwap<LangRegistry>> {
961    static HANDLE: OnceLock<Arc<ArcSwap<LangRegistry>>> = OnceLock::new();
962    HANDLE.get_or_init(|| {
963        // A failure here means a bundled `.scm` does not compile, which
964        // is deterministic and fatal — every caller of `standard()`
965        // already `expect`s it. Storing an empty registry rather than
966        // panicking in an initialiser keeps the error at the call site,
967        // where it can name itself.
968        let built = LangRegistry::build_standard().unwrap_or_default();
969        Arc::new(ArcSwap::from_pointee(built))
970    })
971}
972
973/// Snapshot the live registry.
974///
975/// Wait-free. Returns `Err` only if the bundled set failed to compile,
976/// which is a bug in a checked-in query rather than anything a plugin
977/// can cause.
978pub fn live() -> Result<Arc<LangRegistry>, SyntaxError> {
979    let snapshot = handle().load_full();
980    if snapshot.configs.is_empty() {
981        // Re-derive so the caller gets the real compiler diagnostic
982        // rather than "empty registry".
983        return LangRegistry::build_standard().map(Arc::new);
984    }
985    Ok(snapshot)
986}
987
988/// Everything a runtime-registered language supplies beyond its identity.
989///
990/// Query sources are compiled **at registration, not first use**. A
991/// malformed `folds.scm` is the plugin author's error and has to surface
992/// at load with the offending query named — not silently disable folding
993/// three days later, which is indistinguishable from the feature not
994/// existing.
995#[derive(Debug, Clone)]
996pub struct GrammarSpec {
997    pub grammar: Language,
998    /// H.2: `(pattern, hide-groups)` as the guest declared them,
999    /// uncompiled. Compiled in [`compile_plugin_config`], where a
1000    /// refused rule is dropped and logged rather than failing the
1001    /// language — see `conceal.rs` for why that is asymmetric with
1002    /// query compilation.
1003    pub conceal_rules: Vec<(String, Vec<u32>, Option<String>)>,
1004    pub highlights: Option<String>,
1005    pub folds: Option<String>,
1006    pub injections: Option<String>,
1007    pub indents: Option<String>,
1008    pub textobjects: Option<String>,
1009}
1010
1011/// Compile `spec` against `name`, WITHOUT installing anything.
1012///
1013/// Split from [`install_plugin_config`] so registration can be made
1014/// atomic across two registries: the caller compiles first, claims the
1015/// language's identity second, and installs only if both succeeded. A
1016/// failed query therefore leaves no trace in either — a language works
1017/// or is legibly absent, never half-registered.
1018pub(crate) fn compile_plugin_config(
1019    name: &'static str,
1020    spec: &GrammarSpec,
1021    provenance: u64,
1022    // TK.1: resolves capture names that name a registered theme element
1023    // rather than a builtin category. `None` restricts the query to the
1024    // builtin vocabulary — the pre-TK.1 behaviour, and what any caller
1025    // with no registry in hand should pass.
1026    theme: Option<&dyn lattice_theme::ThemeRegistry>,
1027) -> Result<Arc<LangConfig>, SyntaxError> {
1028    let compile = |what: &str, src: &str| -> Result<Query, SyntaxError> {
1029        Query::new(&spec.grammar, src)
1030            .map_err(|e| SyntaxError::Language(format!("compile {name} {what}: {e}")))
1031    };
1032    let opt = |what: &str, src: &Option<String>| -> Result<Option<Query>, SyntaxError> {
1033        match src {
1034            Some(src) if !src.trim().is_empty() => compile(what, src).map(Some),
1035            // Absent means "that feature is unavailable for this
1036            // language", never an error — the design says so explicitly.
1037            _ => Ok(None),
1038        }
1039    };
1040
1041    // An absent highlights query is legal: the language parses, folds and
1042    // indents, and simply renders unstyled. An empty `Query` compiles
1043    // against any grammar, so this needs no special case downstream.
1044    let highlights = match &spec.highlights {
1045        Some(src) if !src.trim().is_empty() => compile("highlights.scm", src)?,
1046        _ => compile("highlights.scm", "")?,
1047    };
1048    // TK.1: resolved ONCE per language, not per span. An element lookup
1049    // is one hash probe per distinct capture NAME in the query — a
1050    // handful — at registration, on the loader's off-boot thread.
1051    let highlight_styles = highlights
1052        .capture_names()
1053        .iter()
1054        .map(|n| crate::style::name_to_style_with_theme(n, theme))
1055        .collect();
1056    let highlight_priorities = highlights
1057        .capture_names()
1058        .iter()
1059        .map(|n| crate::style::capture_priority(n))
1060        .collect();
1061
1062    // Refusals are logged HERE, once, at registration — naming the
1063    // language and which rule. Deferring the check to match time would
1064    // log at rebuild rate, which is the `debug!`-not-`info!` mistake in
1065    // a different costume.
1066    let (conceal_rules, conceal_errs) = crate::conceal::compile_rules(&spec.conceal_rules, theme);
1067    for (i, e) in &conceal_errs {
1068        tracing::warn!(
1069            language = name,
1070            rule = i,
1071            "conceal rule refused, the language's other rules still apply: {e}"
1072        );
1073    }
1074
1075    let config = LangConfig {
1076        language: spec.grammar.clone(),
1077        folds: opt("folds.scm", &spec.folds)?,
1078        highlights,
1079        highlight_styles,
1080        highlight_priorities,
1081        injections: opt("injections.scm", &spec.injections)?,
1082        // A plugin language ships no symbols query yet; the WIT spec has
1083        // no field for one, so this is absent rather than empty-by-choice.
1084        symbols: None,
1085        textobjects: opt("textobjects.scm", &spec.textobjects)?,
1086        indents: opt("indents.scm", &spec.indents)?,
1087        provenance: Some(provenance),
1088        conceal_rules: Arc::from(conceal_rules.into_boxed_slice()),
1089    };
1090
1091    Ok(Arc::new(config))
1092}
1093
1094/// Install an already-compiled config. Infallible by construction — all
1095/// the ways this can fail happened in [`compile_plugin_config`].
1096pub(crate) fn install_plugin_config(name: &'static str, config: Arc<LangConfig>) {
1097    handle().rcu(|current| {
1098        let mut next = LangRegistry::clone(current);
1099        next.configs.insert(name, Arc::clone(&config));
1100        next
1101    });
1102}
1103
1104/// Withdraw every language contributed by `provenance`, returning how
1105/// many were removed. Bundled languages carry `None` and are untouchable.
1106pub(crate) fn unregister_plugin(provenance: u64) -> usize {
1107    let mut removed = 0;
1108    handle().rcu(|current| {
1109        let mut next = LangRegistry::clone(current);
1110        let before = next.configs.len();
1111        next.configs.retain(|_, c| c.provenance != Some(provenance));
1112        removed = before - next.configs.len();
1113        next
1114    });
1115    removed
1116}