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}