Skip to main content

lattice_syntax/
plugin_lang.rs

1//! Languages registered at runtime rather than compiled in.
2//!
3//! Design:
4//! [`plugin-languages.md`](../../../docs/dev/architecture/plugin-languages.md) §2.3.
5//! Slice plan: LG.2.
6//!
7//! `Lang` is a closed enum, and every language lattice supports is a
8//! workspace dependency. This module is the other half: a registry a
9//! plugin contributes to at load and is withdrawn from at unload, so
10//! "which languages exist" stops being a compile-time property of the
11//! editor (paramount goal #2).
12//!
13//! ## Why the name is the id
14//!
15//! [`LanguageName`] is a `Copy` newtype over a `&'static str`, not an
16//! index into a table. Two things fall out of that, and both were the
17//! reason for choosing it:
18//!
19//! - **`Lang::name()` stays a field read.** It is the key every query
20//!   lookup already uses — `registry.highlights_query(lang.name())`,
21//!   six times per highlight invocation, plus folds and indents. An
22//!   index would have put a process-global table read inside it
23//!   (paramount goal #1).
24//! - **`LangRegistry` is already keyed by `&'static str`.** A plugin
25//!   language joins the map native languages live in, under its own
26//!   name, rather than needing a parallel index space.
27//!
28//! The name is leaked, once per *distinct* name — [`LanguageName::intern`]
29//! dedupes, so a plugin reloaded fifty times in a dev session leaks one
30//! string, not fifty. A leaked name also means a buffer still holding
31//! `Lang::Plugin(name)` after its plugin unloads keeps naming itself
32//! correctly; it simply finds no grammar and renders as plain text.
33//! Nothing dangles, and there is no kind-branch anywhere to express it.
34//!
35//! ## Why reads are process-global
36//!
37//! Writes go through an RCU handle with teardown by provenance, exactly
38//! as [`contributable-registries.md`] prescribes. Reads do not take a
39//! handle, because [`Lang::detect_from_path`] is a free function with
40//! nineteen call sites across `lattice-host`, `lattice-magit` and
41//! `lattice-multibuffer`. Threading a handle through them would make
42//! plugin languages visible on some paths and invisible on others —
43//! a two-tier language concept, and the same failure the "no
44//! kind-specific logic" rule forbids for buffers. `Lang::Plugin` must be
45//! interpretable wherever `Lang::Rust` is, by the same code.
46//! `LangRegistry::standard` is already a process-wide memo in this crate
47//! for a related reason.
48//!
49//! [`Lang::detect_from_path`]: crate::Lang::detect_from_path
50//! [`contributable-registries.md`]: ../../../docs/dev/architecture/contributable-registries.md
51
52use std::collections::{HashMap, HashSet};
53use std::sync::atomic::{AtomicBool, Ordering};
54use std::sync::{Arc, Mutex, OnceLock};
55
56use arc_swap::ArcSwap;
57
58/// The name of a language, interned so it outlives every buffer that
59/// refers to it. Doubles as the language's identity: `Lang::Plugin`
60/// carries one, and it is the key into [`crate::LangRegistry`].
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
62pub struct LanguageName(&'static str);
63
64impl LanguageName {
65    /// Intern `name`, leaking at most once per distinct name.
66    ///
67    /// Deliberately not `pub(crate)`: LG.3's seam interns the name a
68    /// guest supplies, and tests construct one directly.
69    pub fn intern(name: &str) -> Self {
70        static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
71        let names = NAMES.get_or_init(|| Mutex::new(HashSet::new()));
72        let mut names = names.lock().unwrap_or_else(|e| e.into_inner());
73        if let Some(existing) = names.get(name) {
74            return Self(existing);
75        }
76        // The one leak in this module, bounded by the set above. A
77        // language name is a handful of bytes and a session registers a
78        // handful of languages.
79        let leaked: &'static str = Box::leak(name.to_owned().into_boxed_str());
80        names.insert(leaked);
81        Self(leaked)
82    }
83
84    pub fn as_str(self) -> &'static str {
85        self.0
86    }
87}
88
89impl std::fmt::Display for LanguageName {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str(self.0)
92    }
93}
94
95/// Why a registration was refused. Every variant names the offending
96/// language, because a plugin author reading a log line needs to know
97/// which of their contributions failed — and per LG.3's error rule a
98/// failed language must not take the plugin's other contributions with
99/// it.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum LanguageRegistrationError {
102    /// The name collides with a language compiled into the editor.
103    /// Refused rather than shadowed: a plugin silently replacing `rust`
104    /// would be a confusing failure to debug, and deliberate override
105    /// is a separate question nobody has asked for yet.
106    ShadowsBuiltin { name: String },
107    /// Another *plugin* already registered this name.
108    AlreadyRegistered { name: String },
109    /// A language with no extensions can never be selected, so it is
110    /// almost certainly a mistake in the manifest rather than an
111    /// intentional registration.
112    NoExtensions { name: String },
113    /// One of the language's tree-sitter queries did not compile.
114    ///
115    /// Queries compile at REGISTRATION, not first use, so this surfaces
116    /// at load with the offending query named. The alternative — compile
117    /// lazily — turns a typo in `folds.scm` into "folding silently does
118    /// nothing in org files", which is indistinguishable from the
119    /// feature not existing and surfaces days later.
120    QueryCompile { name: String, detail: String },
121}
122
123impl std::fmt::Display for LanguageRegistrationError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::ShadowsBuiltin { name } => write!(
127                f,
128                "language '{name}' is built into the editor and cannot be replaced by a plugin"
129            ),
130            Self::AlreadyRegistered { name } => {
131                write!(f, "language '{name}' is already registered by a plugin")
132            }
133            Self::NoExtensions { name } => {
134                write!(f, "language '{name}' registered no file extensions")
135            }
136            Self::QueryCompile { name, detail } => {
137                write!(f, "language '{name}': {detail}")
138            }
139        }
140    }
141}
142
143impl std::error::Error for LanguageRegistrationError {}
144
145/// One plugin-contributed language.
146///
147/// LG.2 carries identity and selection only. LG.3 adds the compiled
148/// grammar and queries here, beside them — the shape is chosen so that
149/// is an added field rather than a restructure.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct LanguageRegistration {
152    pub name: LanguageName,
153    /// Lower-cased, without the leading dot.
154    pub extensions: Vec<String>,
155    /// The host-issued id of the plugin that contributed this. `u64`
156    /// rather than a `PluginId` newtype so this crate does not depend
157    /// on `lattice-plugin-host`; the same choice
158    /// `lattice-compilation`'s parser factories made.
159    pub provenance: u64,
160}
161
162/// The set of runtime-registered languages.
163///
164/// `Clone` is cheap by construction — registrations sit behind `Arc`, so
165/// a clone is a refcount bump per language plus one small map — which is
166/// what makes the copy-on-write write path affordable. It is paid on
167/// plugin load and unload, not on any read.
168#[derive(Debug, Default, Clone)]
169pub struct PluginLanguages {
170    by_extension: HashMap<String, LanguageName>,
171    registrations: Vec<Arc<LanguageRegistration>>,
172}
173
174impl PluginLanguages {
175    pub fn resolve_extension(&self, ext: &str) -> Option<LanguageName> {
176        self.by_extension.get(&ext.to_ascii_lowercase()).copied()
177    }
178
179    pub fn get(&self, name: LanguageName) -> Option<&Arc<LanguageRegistration>> {
180        self.registrations.iter().find(|r| r.name == name)
181    }
182
183    pub fn iter(&self) -> impl Iterator<Item = &Arc<LanguageRegistration>> {
184        self.registrations.iter()
185    }
186
187    pub fn is_empty(&self) -> bool {
188        self.registrations.is_empty()
189    }
190}
191
192/// Copy-on-write RCU, the idiom `contributable-registries.md` §2
193/// establishes and the tree already carries five times over.
194pub type PluginLanguagesHandle = Arc<ArcSwap<PluginLanguages>>;
195
196/// Set the moment anything is registered and cleared when the last
197/// registration goes away.
198///
199/// This is what makes "native resolution is unchanged when the registry
200/// is empty" true *by construction* rather than by measurement:
201/// [`Lang::detect_from_path`] does one relaxed atomic load and returns,
202/// never touching the `ArcSwap`. `detect_from_path` is called per hunk
203/// in magit's diff highlighting, so "cheap" is not good enough — the
204/// empty case has to be free.
205///
206/// [`Lang::detect_from_path`]: crate::Lang::detect_from_path
207static ANY_REGISTERED: AtomicBool = AtomicBool::new(false);
208
209/// The process-wide handle. See the module docs for why reads do not
210/// take one explicitly.
211pub fn handle() -> &'static PluginLanguagesHandle {
212    static HANDLE: OnceLock<PluginLanguagesHandle> = OnceLock::new();
213    HANDLE.get_or_init(|| Arc::new(ArcSwap::from_pointee(PluginLanguages::default())))
214}
215
216/// Resolve a file extension to a plugin language.
217///
218/// Consulted **after** the native table (see [`crate::Lang::detect_from_path`]),
219/// so a plugin cannot shadow a built-in language by accident. Whether it
220/// should be able to, deliberately, is a separate question — deferred
221/// until someone asks for it.
222pub fn resolve_extension(ext: &str) -> Option<LanguageName> {
223    if !ANY_REGISTERED.load(Ordering::Relaxed) {
224        return None;
225    }
226    handle().load().resolve_extension(ext)
227}
228
229/// Snapshot the live set. Wait-free; callers hold the `Arc` for as long
230/// as they need a coherent view.
231pub fn snapshot() -> Arc<PluginLanguages> {
232    handle().load_full()
233}
234
235/// Register a language.
236///
237/// `extensions` are matched case-insensitively and may be given with or
238/// without a leading dot.
239pub fn register(
240    name: &str,
241    extensions: &[&str],
242    provenance: u64,
243) -> Result<LanguageName, LanguageRegistrationError> {
244    let normalised = validate(name, extensions)?;
245    let interned = LanguageName::intern(name);
246    claim(interned, normalised, provenance).map_err(|_| {
247        LanguageRegistrationError::AlreadyRegistered {
248            name: name.to_owned(),
249        }
250    })?;
251    Ok(interned)
252}
253
254/// Shared prelude: refuse a name that would shadow a builtin, and
255/// normalise the extension list.
256fn validate(name: &str, extensions: &[&str]) -> Result<Vec<String>, LanguageRegistrationError> {
257    if crate::Lang::builtin_by_name(name).is_some() {
258        return Err(LanguageRegistrationError::ShadowsBuiltin {
259            name: name.to_owned(),
260        });
261    }
262    let normalised: Vec<String> = extensions
263        .iter()
264        .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
265        .filter(|e| !e.is_empty())
266        .collect();
267    if normalised.is_empty() {
268        return Err(LanguageRegistrationError::NoExtensions {
269            name: name.to_owned(),
270        });
271    }
272    Ok(normalised)
273}
274
275/// Claim the name and its extensions. `Err(())` means another plugin got
276/// there first.
277fn claim(interned: LanguageName, extensions: Vec<String>, provenance: u64) -> Result<(), ()> {
278    let registration = Arc::new(LanguageRegistration {
279        name: interned,
280        extensions,
281        provenance,
282    });
283    // `rcu` retries its closure under contention, so the closure must
284    // stay pure — it is, and the duplicate check inside it is what makes
285    // two plugins racing to claim one name resolve deterministically
286    // rather than by whoever stored last.
287    let mut refused = false;
288    handle().rcu(|current| {
289        refused = current.registrations.iter().any(|r| r.name == interned);
290        if refused {
291            return PluginLanguages::clone(current);
292        }
293        let mut next = PluginLanguages::clone(current);
294        for ext in &registration.extensions {
295            // Last writer wins *within* the plugin set; the native table
296            // still wins over all of it at resolution time.
297            next.by_extension.insert(ext.clone(), interned);
298        }
299        next.registrations.push(Arc::clone(&registration));
300        next
301    });
302    if refused {
303        return Err(());
304    }
305    ANY_REGISTERED.store(true, Ordering::Relaxed);
306    Ok(())
307}
308
309/// Register a language *with* its grammar and queries (LG.3a).
310///
311/// The complete form: identity, selection, and everything
312/// [`crate::LangRegistry`] needs to parse, highlight, fold and indent the
313/// language. [`register`] is the identity-only subset, kept for callers
314/// that have no grammar to offer.
315///
316/// **Atomic across both registries.** The queries are compiled first,
317/// the name is claimed second, and the compiled config is installed only
318/// once both have succeeded. So a malformed `folds.scm` leaves the
319/// language absent rather than resolvable-but-dead, and a name collision
320/// leaves the winner's grammar untouched.
321pub fn register_with_grammar(
322    name: &str,
323    extensions: &[&str],
324    grammar: &crate::registry::GrammarSpec,
325    provenance: u64,
326) -> Result<LanguageName, LanguageRegistrationError> {
327    register_with_grammar_themed(name, extensions, grammar, provenance, None)
328}
329
330/// [`register_with_grammar`], with the theme registry in hand (TK.1).
331///
332/// A capture name in this language's `highlights.scm` that is not a
333/// builtin category but IS a registered theme element resolves to
334/// [`Style::Element`](crate::Style::Element) — which is how org's TODO
335/// keywords get one colour each without the editor learning what a TODO
336/// keyword is.
337///
338/// **The elements must already be registered when this is called.** The
339/// loader drains the `theme` seam before the `language` seam for that
340/// reason; a capture naming an element that does not exist yet resolves
341/// to `Style::Default` and renders unstyled, which is a silent failure
342/// rather than a loud one. It is the same drain-order gate OM.0
343/// established for `grammar` before `modes`.
344pub fn register_with_grammar_themed(
345    name: &str,
346    extensions: &[&str],
347    grammar: &crate::registry::GrammarSpec,
348    provenance: u64,
349    theme: Option<&dyn lattice_theme::ThemeRegistry>,
350) -> Result<LanguageName, LanguageRegistrationError> {
351    let normalised = validate(name, extensions)?;
352    let interned = LanguageName::intern(name);
353
354    // Compile BEFORE claiming the name: this is the step that fails, and
355    // failing here has installed nothing anywhere.
356    let config =
357        crate::registry::compile_plugin_config(interned.as_str(), grammar, provenance, theme)
358            .map_err(|e| LanguageRegistrationError::QueryCompile {
359                name: name.to_owned(),
360                detail: e.to_string(),
361            })?;
362
363    claim(interned, normalised, provenance).map_err(|_| {
364        LanguageRegistrationError::AlreadyRegistered {
365            name: name.to_owned(),
366        }
367    })?;
368    crate::registry::install_plugin_config(interned.as_str(), config);
369    Ok(interned)
370}
371
372/// Withdraw every language contributed by `provenance`, returning how
373/// many were removed.
374///
375/// Teardown is **by provenance, not by token**: there is no list of
376/// handles for the caller to record and therefore none to forget. A
377/// plugin that registers three languages and crashes still has all three
378/// withdrawn.
379pub fn unregister_plugin(provenance: u64) -> usize {
380    // Both registries, one call. Withdrawing identity without the config
381    // (or the reverse) would leave a language that resolves but cannot
382    // parse, or a grammar nothing can select — two ways to be
383    // half-unloaded, neither of which any caller should have to think
384    // about.
385    crate::registry::unregister_plugin(provenance);
386    let mut removed = 0;
387    handle().rcu(|current| {
388        let mut next = PluginLanguages::clone(current);
389        let before = next.registrations.len();
390        next.registrations.retain(|r| r.provenance != provenance);
391        removed = before - next.registrations.len();
392        // Rebuild the extension map from what survives rather than
393        // deleting the departing language's keys: another plugin may
394        // have claimed the same extension after it, and deleting by key
395        // would withdraw *their* mapping too.
396        next.by_extension.clear();
397        for reg in &next.registrations {
398            for ext in &reg.extensions {
399                next.by_extension.insert(ext.clone(), reg.name);
400            }
401        }
402        next
403    });
404    if removed > 0 && handle().load().registrations.is_empty() {
405        ANY_REGISTERED.store(false, Ordering::Relaxed);
406    }
407    removed
408}
409
410#[cfg(test)]
411mod tests {
412    #![allow(clippy::unwrap_used, clippy::panic)]
413    use super::*;
414    use crate::Lang;
415    use std::path::PathBuf;
416    use std::sync::atomic::AtomicU64;
417
418    /// The registry is process-wide and `cargo test` runs these in
419    /// parallel in one process, so every test must claim names and
420    /// extensions no other test uses. A shared counter is the same fix
421    /// the tempdir helpers needed for the same reason.
422    fn unique(tag: &str) -> (String, String, u64) {
423        static N: AtomicU64 = AtomicU64::new(0);
424        let n = N.fetch_add(1, Ordering::Relaxed);
425        (
426            format!("lg2{tag}{n}"),
427            format!("lg2x{tag}{n}"),
428            9_000_000 + n,
429        )
430    }
431
432    #[test]
433    fn registered_language_resolves_by_extension() {
434        let (name, ext, plugin) = unique("res");
435        let interned = register(&name, &[&ext], plugin).unwrap();
436
437        let path = PathBuf::from(format!("notes.{ext}"));
438        assert_eq!(
439            Lang::detect_from_path(Some(&path)),
440            Lang::Plugin(interned),
441            "a registered extension should resolve to its plugin language"
442        );
443        assert_eq!(Lang::Plugin(interned).name(), name);
444        assert_eq!(Lang::Plugin(interned).label(), name);
445
446        unregister_plugin(plugin);
447    }
448
449    #[test]
450    fn unload_withdraws_the_language() {
451        let (name, ext, plugin) = unique("unload");
452        register(&name, &[&ext], plugin).unwrap();
453        let path = PathBuf::from(format!("notes.{ext}"));
454        assert!(matches!(
455            Lang::detect_from_path(Some(&path)),
456            Lang::Plugin(_)
457        ));
458
459        assert_eq!(unregister_plugin(plugin), 1);
460        assert_eq!(
461            Lang::detect_from_path(Some(&path)),
462            Lang::Plain,
463            "after unload the extension must fall back to plain"
464        );
465    }
466
467    #[test]
468    fn unload_withdraws_every_language_from_that_plugin() {
469        let (a, ext_a, plugin) = unique("multia");
470        let (b, ext_b, _) = unique("multib");
471        register(&a, &[&ext_a], plugin).unwrap();
472        register(&b, &[&ext_b], plugin).unwrap();
473
474        assert_eq!(unregister_plugin(plugin), 2, "both, without a token list");
475        for ext in [&ext_a, &ext_b] {
476            assert_eq!(
477                Lang::detect_from_path(Some(&PathBuf::from(format!("f.{ext}")))),
478                Lang::Plain
479            );
480        }
481    }
482
483    #[test]
484    fn unloading_one_plugin_leaves_anothers_language_alone() {
485        let (a, ext_a, plugin_a) = unique("isoa");
486        let (b, ext_b, plugin_b) = unique("isob");
487        let name_b = register(&b, &[&ext_b], plugin_b).unwrap();
488        register(&a, &[&ext_a], plugin_a).unwrap();
489
490        unregister_plugin(plugin_a);
491        assert_eq!(
492            Lang::detect_from_path(Some(&PathBuf::from(format!("f.{ext_b}")))),
493            Lang::Plugin(name_b),
494            "withdrawing one plugin must not rebuild away another's extensions"
495        );
496        unregister_plugin(plugin_b);
497    }
498
499    #[test]
500    fn plugin_and_native_languages_coexist() {
501        let (name, ext, plugin) = unique("coexist");
502        let interned = register(&name, &[&ext], plugin).unwrap();
503
504        assert_eq!(
505            Lang::detect_from_path(Some(&PathBuf::from("main.rs"))),
506            Lang::Rust
507        );
508        assert_eq!(
509            Lang::detect_from_path(Some(&PathBuf::from(format!("a.{ext}")))),
510            Lang::Plugin(interned)
511        );
512        unregister_plugin(plugin);
513    }
514
515    #[test]
516    fn native_resolution_wins_over_a_plugin_claiming_the_same_extension() {
517        let (name, _, plugin) = unique("shadowext");
518        // Claiming `rs` is allowed — the native table is consulted first,
519        // so the claim simply never wins. That is the design's "a plugin
520        // cannot shadow a built-in language by accident".
521        register(&name, &["rs"], plugin).unwrap();
522        assert_eq!(
523            Lang::detect_from_path(Some(&PathBuf::from("main.rs"))),
524            Lang::Rust
525        );
526        unregister_plugin(plugin);
527    }
528
529    #[test]
530    fn native_resolution_is_unchanged_when_the_registry_is_empty() {
531        // Deliberately does not touch the registry: other tests run in
532        // parallel in this process, so the invariant under test is
533        // "native extensions resolve natively", not "the registry is
534        // empty right now".
535        assert_eq!(
536            Lang::detect_from_path(Some(&PathBuf::from("a.py"))),
537            Lang::Python
538        );
539        assert_eq!(
540            Lang::detect_from_path(Some(&PathBuf::from("a.md"))),
541            Lang::Markdown
542        );
543        assert_eq!(Lang::detect_from_path(None), Lang::Plain);
544        assert_eq!(
545            Lang::detect_from_path(Some(&PathBuf::from("a.lg2-never-registered"))),
546            Lang::Plain
547        );
548    }
549
550    #[test]
551    fn a_plugin_may_not_shadow_a_builtin_name() {
552        let err = register("rust", &["myrust"], 9_999_001).unwrap_err();
553        assert_eq!(
554            err,
555            LanguageRegistrationError::ShadowsBuiltin {
556                name: "rust".into()
557            }
558        );
559        // And the refusal is total — no extension leaked into the map.
560        assert_eq!(
561            Lang::detect_from_path(Some(&PathBuf::from("a.myrust"))),
562            Lang::Plain
563        );
564    }
565
566    #[test]
567    fn a_name_may_not_be_registered_twice() {
568        let (name, ext, plugin) = unique("dup");
569        register(&name, &[&ext], plugin).unwrap();
570        let err = register(&name, &["otherext"], plugin + 1).unwrap_err();
571        assert_eq!(
572            err,
573            LanguageRegistrationError::AlreadyRegistered { name: name.clone() }
574        );
575        unregister_plugin(plugin);
576    }
577
578    #[test]
579    fn a_language_with_no_extensions_is_refused() {
580        let (name, _, plugin) = unique("noext");
581        assert_eq!(
582            register(&name, &[], plugin).unwrap_err(),
583            LanguageRegistrationError::NoExtensions { name: name.clone() }
584        );
585        // A dot alone normalises to empty and is refused the same way.
586        assert_eq!(
587            register(&name, &["."], plugin).unwrap_err(),
588            LanguageRegistrationError::NoExtensions { name }
589        );
590    }
591
592    #[test]
593    fn extensions_are_matched_case_insensitively_and_dots_are_optional() {
594        let (name, ext, plugin) = unique("case");
595        let interned = register(&name, &[&format!(".{}", ext.to_uppercase())], plugin).unwrap();
596        assert_eq!(
597            Lang::detect_from_path(Some(&PathBuf::from(format!("F.{}", ext.to_uppercase())))),
598            Lang::Plugin(interned)
599        );
600        assert_eq!(
601            Lang::detect_from_path(Some(&PathBuf::from(format!("f.{ext}")))),
602            Lang::Plugin(interned)
603        );
604        unregister_plugin(plugin);
605    }
606
607    #[test]
608    fn interning_the_same_name_twice_yields_one_pointer() {
609        let a = LanguageName::intern("lg2-intern-probe");
610        let b = LanguageName::intern("lg2-intern-probe");
611        assert_eq!(a, b);
612        assert!(
613            std::ptr::eq(a.as_str(), b.as_str()),
614            "re-interning must not leak a second copy"
615        );
616    }
617
618    // ── LG.3a: languages that carry a grammar ───────────────────────
619    //
620    // The "plugin" grammar here is `tree-sitter-json`, registered under a
621    // name of its own. Using a real bundled grammar rather than a wasm
622    // one is deliberate: LG.3a is about whether the *pipeline* is
623    // provenance-agnostic, and answering that must not depend on the
624    // second wasmtime being linked. LG.3b supplies a wasm-loaded
625    // `Language` to exactly this code.
626
627    fn json_grammar(highlights: &str) -> crate::registry::GrammarSpec {
628        crate::registry::GrammarSpec {
629            grammar: tree_sitter_json::LANGUAGE.into(),
630            highlights: Some(highlights.to_string()),
631            folds: None,
632            injections: None,
633            indents: None,
634            textobjects: None,
635            conceal_rules: vec![],
636        }
637    }
638
639    /// The one that matters. A runtime-registered language is parsed and
640    /// highlighted by the SAME `Syntax` path a bundled language uses —
641    /// no kind-branch, no second lookup, nothing that knows the grammar
642    /// arrived at runtime.
643    #[test]
644    fn a_registered_language_parses_and_highlights_through_the_normal_path() {
645        let (name, ext, plugin) = unique("full");
646        let interned =
647            register_with_grammar(&name, &[&ext], &json_grammar("(string) @string"), plugin)
648                .expect("registers");
649
650        let lang = Lang::detect_from_path(Some(&PathBuf::from(format!("a.{ext}"))));
651        assert_eq!(lang, Lang::Plugin(interned));
652
653        let mut syntax = crate::Syntax::for_language(lang)
654            .expect("registry")
655            .expect("a registered language must yield a Syntax");
656        syntax.parse("{\"k\": \"v\"}");
657        let lines = syntax.highlight_lines_native(0, 1).expect("highlights");
658        assert!(
659            lines[0].iter().any(|s| s.style == crate::Style::String),
660            "the plugin language's highlights query must produce spans: {:?}",
661            lines[0]
662        );
663
664        unregister_plugin(plugin);
665    }
666
667    #[test]
668    fn unload_reverts_an_open_buffers_language_to_plain() {
669        let (name, ext, plugin) = unique("revert");
670        let interned =
671            register_with_grammar(&name, &[&ext], &json_grammar("(string) @string"), plugin)
672                .expect("registers");
673        assert!(
674            crate::Syntax::for_language(Lang::Plugin(interned))
675                .expect("registry")
676                .is_some()
677        );
678
679        unregister_plugin(plugin);
680
681        // The buffer still holds `Lang::Plugin(interned)`. It must now
682        // find no grammar — which is exactly how `Lang::Plain` behaves,
683        // so the renderer needs no new case for it.
684        assert!(
685            crate::Syntax::for_language(Lang::Plugin(interned))
686                .expect("registry")
687                .is_none(),
688            "the grammar must be withdrawn with the language"
689        );
690    }
691
692    /// Queries compile at registration, not first use. A typo in
693    /// `folds.scm` has to surface at load naming the query — the
694    /// alternative is "folding silently does nothing", which is
695    /// indistinguishable from the feature not existing.
696    #[test]
697    fn a_malformed_query_is_rejected_at_registration_and_names_itself() {
698        let (name, ext, plugin) = unique("badquery");
699        let mut spec = json_grammar("(string) @string");
700        spec.folds = Some("(this_node_does_not_exist) @fold".to_string());
701
702        let err = register_with_grammar(&name, &[&ext], &spec, plugin).unwrap_err();
703        let LanguageRegistrationError::QueryCompile { name: n, detail } = &err else {
704            panic!("expected QueryCompile, got {err:?}");
705        };
706        assert_eq!(n, &name);
707        assert!(
708            detail.contains("folds.scm"),
709            "the offending query must be named: {detail}"
710        );
711
712        // And the refusal is total across BOTH registries — a language
713        // that resolves but cannot parse would be the worst outcome.
714        assert_eq!(
715            Lang::detect_from_path(Some(&PathBuf::from(format!("a.{ext}")))),
716            Lang::Plain
717        );
718        assert!(
719            crate::Syntax::for_language(Lang::Plugin(LanguageName::intern(&name)))
720                .expect("registry")
721                .is_none()
722        );
723    }
724
725    /// H.2: rules reach the compiled config, and a broken one costs
726    /// only itself.
727    #[test]
728    fn h2_conceal_rules_survive_registration_and_a_bad_one_drops_alone() {
729        let (name, ext, plugin) = unique("conceal");
730        let mut spec = json_grammar("(string) @string");
731        spec.conceal_rules = vec![
732            (r"(\[\[[^]]+\]\[)[^]]+(\]\])".to_string(), vec![1, 2], None),
733            // Refused: does not compile.
734            ("(unclosed".to_string(), vec![1], None),
735            // Refused: group 4 does not exist.
736            (r"(\[\[)([^]]+)(\]\])".to_string(), vec![4], None),
737            (r"(\[\[)([^]]+)(\]\])".to_string(), vec![1, 3], None),
738        ];
739        let interned = register_with_grammar(&name, &[&ext], &spec, plugin).expect("registers");
740        let reg = crate::registry::live().expect("registry");
741        let rules = reg.conceal_rules(interned.as_str());
742        assert_eq!(rules.len(), 2, "the two good rules survive, the two do not");
743        assert_eq!(rules[0].hide(), &[1, 2]);
744        assert_eq!(rules[1].hide(), &[1, 3]);
745        unregister_plugin(plugin);
746    }
747
748    /// Teardown takes the rules with the language. Not a separate
749    /// cleanup path — they ride `LangConfig`, which `unregister_plugin`
750    /// already retains by provenance, and this test is what pins that
751    /// they were not put somewhere with its own lifetime.
752    #[test]
753    fn h2_unloading_the_plugin_takes_its_conceal_rules() {
754        let (name, ext, plugin) = unique("conceal-teardown");
755        let mut spec = json_grammar("(string) @string");
756        spec.conceal_rules = vec![(r"(\[\[)([^]]+)(\]\])".to_string(), vec![1, 3], None)];
757        let interned = register_with_grammar(&name, &[&ext], &spec, plugin).expect("registers");
758        assert_eq!(
759            crate::registry::live()
760                .expect("registry")
761                .conceal_rules(interned.as_str())
762                .len(),
763            1
764        );
765        unregister_plugin(plugin);
766        assert!(
767            crate::registry::live()
768                .expect("registry")
769                .conceal_rules(interned.as_str())
770                .is_empty(),
771            "rules must go when the language does"
772        );
773    }
774
775    /// The zero-cost path, asserted rather than assumed: a language that
776    /// declares nothing answers with an empty slice, and so does a name
777    /// that is not a language at all.
778    #[test]
779    fn h2_a_language_with_no_rules_answers_empty() {
780        let reg = crate::registry::live().expect("registry");
781        assert!(reg.conceal_rules("rust").is_empty());
782        assert!(reg.conceal_rules("not-a-language").is_empty());
783    }
784
785    /// An absent query means "that feature is unavailable for this
786    /// language", never an error — the design says so explicitly.
787    #[test]
788    fn absent_optional_queries_are_not_an_error() {
789        let (name, ext, plugin) = unique("noopt");
790        let spec = crate::registry::GrammarSpec {
791            grammar: tree_sitter_json::LANGUAGE.into(),
792            highlights: None,
793            folds: None,
794            injections: None,
795            indents: None,
796            textobjects: None,
797            conceal_rules: vec![],
798        };
799        let interned = register_with_grammar(&name, &[&ext], &spec, plugin).expect("registers");
800        let mut syntax = crate::Syntax::for_language(Lang::Plugin(interned))
801            .expect("registry")
802            .expect("still a usable language");
803        syntax.parse("{\"k\": 1}");
804        // Parses fine, simply renders unstyled.
805        assert!(syntax.highlight_lines_native(0, 1).expect("ok")[0].is_empty());
806        unregister_plugin(plugin);
807    }
808
809    /// Bundled languages carry no provenance and must survive any
810    /// plugin unload.
811    #[test]
812    fn unloading_a_plugin_leaves_bundled_languages_intact() {
813        let (name, ext, plugin) = unique("bundled");
814        register_with_grammar(&name, &[&ext], &json_grammar("(string) @string"), plugin)
815            .expect("registers");
816        unregister_plugin(plugin);
817
818        for l in [Lang::Rust, Lang::Json, Lang::Markdown] {
819            assert!(
820                crate::Syntax::for_language(l).expect("registry").is_some(),
821                "{l:?} must survive a plugin unload"
822            );
823        }
824    }
825
826    /// Registration is atomic across the two registries in the collision
827    /// direction too: the loser must not overwrite the winner's grammar.
828    #[test]
829    fn a_losing_registration_does_not_clobber_the_winners_grammar() {
830        let (name, ext, plugin) = unique("clobber");
831        let interned =
832            register_with_grammar(&name, &[&ext], &json_grammar("(string) @string"), plugin)
833                .expect("first wins");
834
835        let mut other = json_grammar("");
836        other.grammar = tree_sitter_md::LANGUAGE.into();
837        let err =
838            register_with_grammar(&name, &["otherclobberext"], &other, plugin + 1).unwrap_err();
839        assert!(matches!(
840            err,
841            LanguageRegistrationError::AlreadyRegistered { .. }
842        ));
843
844        // Still the winner's grammar: json parses `{}` without error,
845        // and the markdown grammar would have produced a different tree.
846        let mut syntax = crate::Syntax::for_language(Lang::Plugin(interned))
847            .expect("registry")
848            .expect("present");
849        syntax.parse("{\"k\": \"v\"}");
850        assert!(
851            syntax.highlight_lines_native(0, 1).expect("ok")[0]
852                .iter()
853                .any(|s| s.style == crate::Style::String),
854            "the winner's highlights query must still be installed"
855        );
856        unregister_plugin(plugin);
857    }
858
859    #[test]
860    fn unregistering_an_unknown_plugin_is_a_no_op() {
861        assert_eq!(unregister_plugin(9_999_999), 0);
862    }
863
864    #[test]
865    fn a_stale_lang_still_names_itself_after_unload() {
866        let (name, ext, plugin) = unique("stale");
867        let interned = register(&name, &[&ext], plugin).unwrap();
868        let held = Lang::Plugin(interned);
869        unregister_plugin(plugin);
870
871        // The buffer that was open when the plugin went away keeps a
872        // usable `Lang`: it names itself, finds no grammar, and renders
873        // as plain text. No dangle, and no kind-branch to express it.
874        assert_eq!(held.name(), name);
875        assert_eq!(held.comment_syntax().line, None);
876        assert_eq!(crate::major_mode_id_for_lang(held), None);
877    }
878}