Skip to main content

escriba_render/
langs.rs

1//! Escriba-local language plugins — the languages the fleet syntax spine does
2//! not ship yet.
3//!
4//! hikari owns the highlighting machinery: [`LangTable`] is the per-language
5//! data, [`TableLexer`](hikari_core::langs::TableLexer) is the ONE scanner that
6//! reads it, and [`TablePlugin`] adapts the pair to
7//! [`LanguagePlugin`]. Nothing here re-implements any of that — a language is a
8//! table, which is the whole point of the table backend. If a second escriba
9//! language ever needs the same treatment, it is another `static` and another
10//! row in [`escriba_local`], not another module.
11//!
12//! These plugins register **last** in
13//! [`build_ecosystem`](crate::gpu::build_ecosystem), behind both hikari
14//! backends, so the day hikari ships a `blue` grammar or table upstream this
15//! one is skipped and the local copy retires itself without an edit.
16//!
17//! ## Why a table and not tree-sitter
18//!
19//! There is no `tree-sitter-blue` grammar — not in hikari-ts, not upstream. The
20//! table backend is what every non-tree-sitter language in escriba already gets
21//! (nix, yaml, lua, toml, …), so `.b` is served at exactly the tier its
22//! neighbours are, and no `defmode :tree-sitter` claims a grammar that does not
23//! exist.
24//!
25//! ## Why blue's keyword set is not read from `blue-lang-syntax`
26//!
27//! It would be the drift-proof spelling, and it was rejected on the numbers:
28//!
29//! - `blue-lang-syntax` is at **0.0.12** on crates.io. Under cargo's semver
30//!   rules a `0.0.x` version is its own compatibility range — `"0.0.12"` means
31//!   *exactly* 0.0.12 — so the dependency would not follow blue at all. It went
32//!   through eleven releases in the three hours after first publish; escriba
33//!   would be pinned to a dead one immediately and would need a manual bump per
34//!   blue release. The "cannot drift" property is illusory at `0.0.x`.
35//! - It would drag `tatara-lisp` 0.3.21 into a workspace pinned at 0.3.3, for
36//!   all twenty-one crates, to import fifteen strings.
37//! - The rest of blue is not on crates.io, and escriba **is** published
38//!   (`escriba` 0.1.20), so a `git =` dependency is categorically out: it would
39//!   freeze escriba's own publishing.
40//!
41//! So the table is transcribed, and [`BLUE_RESERVED_WORDS`] documents the exact
42//! upstream definition it is transcribed from. The blue *toolchain* (`blue
43//! lsp`, `blue fmt`) is wired the other way — as a binary on `$PATH` — which
44//! needs no registry at all and follows blue automatically.
45
46use hikari_core::{
47    Language, LanguagePlugin, Selector,
48    langs::{LangTable, TablePlugin},
49};
50
51/// The language id escriba resolves `.b` files to.
52pub const BLUE: Language = Language("blue");
53
54/// blue's reserved words, transcribed from `blue_lang_syntax::parse`:
55/// `SURFACE_KEYWORDS` (the eleven form heads) plus the four block-structure
56/// words `is_reserved_word` adds on top of it.
57///
58/// **Diff this against ONE upstream symbol, not two** (blue @ `d276578`,
59/// 2026-08-04): that union is now public as `blue_lang_syntax::is_reserved_word`,
60/// with its non-`SURFACE_KEYWORDS` half exported as `BLOCK_KEYWORDS`. It went
61/// public *because* of this transcription — when the union was reachable only
62/// from inside blue's parser, checking this list meant reconstructing what a
63/// private function added, which is the step a reader skips. The three
64/// deliberate omissions below mean the two lists are not equal, so the check
65/// is `BLUE_RESERVED_WORDS ∪ {true, false, nil} == is_reserved_word`'s set.
66///
67/// The reasons above for not *depending* on the crate are unchanged — a
68/// `0.0.x` pin does not follow blue, and a `git =` would freeze escriba's own
69/// publishing. This only makes the manual check cheap and exact.
70///
71/// `true` / `false` / `nil` are the other three words upstream reserves and are
72/// **deliberately absent**: hikari's table lexer already classifies them as
73/// [`HlClass::Boolean`](hikari_core::HlClass::Boolean), and listing them here
74/// would demote them to plain `Keyword` — a worse paint, not a better one.
75/// `and` / `or` / `not` are also absent, and are not an oversight either: blue
76/// spells those `&&` / `||` / `not(…)`, and `and`/`or` exist only as the
77/// *lowered callee names* in `blue_lang_syntax::INFIX`, never as surface
78/// keywords.
79pub static BLUE_RESERVED_WORDS: &[&str] = &[
80    // ── SURFACE_KEYWORDS ──────────────────────────────────────────────
81    "assert",
82    "case",
83    "def",
84    "defmacro",
85    "fn",
86    "if",
87    "quote",
88    "test",
89    "unless",
90    "unquote",
91    "unquote_splice",
92    // ── the block-structure words `is_reserved_word` adds ─────────────
93    "do",
94    "else",
95    "elsif",
96    "end",
97];
98
99/// blue's lexical shape.
100///
101/// `colon_keywords` is on because a blue symbol is spelled `:name` and lowers
102/// to a tatara-lisp keyword — the same token hikari's lisp table paints as
103/// [`HlClass::KeywordArg`](hikari_core::HlClass::KeywordArg), and the same
104/// meaning. The hash-literal label `name:` (colon *after* the identifier) is a
105/// different token and is NOT covered; it paints as an identifier plus
106/// punctuation.
107///
108/// One string delimiter, because blue's lexer has one: `lex_string` is reached
109/// from `b'"'` alone — no single-quoted strings, no heredocs. Interpolation
110/// (`"n=#{x}"`) paints as one string span; the interpolated expression is not
111/// separately highlighted.
112///
113/// No block comment, because blue has none — comments are `#` to end of line,
114/// full stop.
115pub static BLUE_TABLE: LangTable = LangTable {
116    keywords: BLUE_RESERVED_WORDS,
117    line_comments: &["#"],
118    block_comment: None,
119    string_delims: &['"'],
120    colon_keywords: true,
121};
122
123/// How a document claims to be blue.
124///
125/// `Bluefile` is here because a Bluefile **is a blue program** — blue has no
126/// separate manifest grammar — and it carries no extension, which is exactly
127/// what [`Selector::Filename`] is for.
128pub static BLUE_SELECTORS: &[Selector] =
129    &[Selector::Extension("b"), Selector::Filename("Bluefile")];
130
131/// Every language escriba registers on top of the two hikari backends.
132#[must_use]
133pub fn escriba_local() -> Vec<Box<dyn LanguagePlugin>> {
134    vec![Box::new(TablePlugin {
135        language: BLUE,
136        selectors: BLUE_SELECTORS,
137        table: &BLUE_TABLE,
138    })]
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use hikari_core::{Ecosystem, HlClass};
145
146    /// A registry holding only the escriba-local plugins — the unit under
147    /// test, isolated from whatever hikari happens to ship.
148    fn local_only() -> Ecosystem {
149        let mut eco = Ecosystem::new();
150        for p in escriba_local() {
151            eco.register(p);
152        }
153        eco
154    }
155
156    fn classes(path: &str, src: &str) -> Vec<(String, HlClass)> {
157        let hl = local_only().highlighter_for_path(path);
158        hl.highlight(src)
159            .into_iter()
160            .map(|s| {
161                (
162                    src[s.span.start as usize..s.span.end as usize].to_string(),
163                    s.class,
164                )
165            })
166            .filter(|(t, _)| !t.trim().is_empty())
167            .collect()
168    }
169
170    fn class_of(path: &str, src: &str, token: &str) -> Option<HlClass> {
171        classes(path, src)
172            .into_iter()
173            .find(|(t, _)| t == token)
174            .map(|(_, c)| c)
175    }
176
177    #[test]
178    fn dot_b_resolves_to_blue() {
179        assert_eq!(local_only().resolve("scratch.b"), BLUE);
180        assert_eq!(local_only().resolve("/a/b/c/spec/strings.b"), BLUE);
181    }
182
183    #[test]
184    fn bluefile_resolves_to_blue_by_name() {
185        // No extension — the Filename selector is the only thing that can
186        // claim it, and a Bluefile is a blue program.
187        assert_eq!(local_only().resolve("Bluefile"), BLUE);
188        assert_eq!(local_only().resolve("bidamas/retsu/Bluefile"), BLUE);
189    }
190
191    #[test]
192    fn unrelated_paths_stay_plain() {
193        assert_eq!(local_only().resolve("main.rs"), hikari_core::PLAIN_TEXT);
194        assert_eq!(local_only().resolve("notes.txt"), hikari_core::PLAIN_TEXT);
195        // `.blue` is NOT blue's extension — `.b` is. Claiming it would be a
196        // guess, so the registry declines.
197        assert_eq!(local_only().resolve("x.blue"), hikari_core::PLAIN_TEXT);
198    }
199
200    #[test]
201    fn form_heads_and_block_words_paint_as_keywords() {
202        let src = "def f(x)\n  if x\n    x\n  else\n    0\n  end\nend\n";
203        for word in ["def", "if", "else", "end"] {
204            assert_eq!(
205                class_of("f.b", src, word),
206                Some(HlClass::Keyword),
207                "expected `{word}` to paint as a keyword",
208            );
209        }
210    }
211
212    #[test]
213    fn hash_starts_a_line_comment() {
214        let src = "# blue's own configuration\ndef f\nend\n";
215        let spans = classes("blue.b", src);
216        assert_eq!(
217            spans.first().map(|(_, c)| *c),
218            Some(HlClass::Comment { multiline: false }),
219        );
220        // The comment ends at the newline — `def` on the next line is live.
221        assert_eq!(class_of("blue.b", src, "def"), Some(HlClass::Keyword));
222    }
223
224    #[test]
225    fn symbols_paint_as_keyword_args() {
226        // `:name` is a blue symbol, which lowers to a tatara-lisp keyword.
227        assert_eq!(
228            class_of("f.b", "x = :ok\n", ":ok"),
229            Some(HlClass::KeywordArg),
230        );
231    }
232
233    #[test]
234    fn strings_and_numbers_paint() {
235        let src = "x = \"hello\"\ny = 42\nz = 1.5\n";
236        assert_eq!(class_of("f.b", src, "\"hello\""), Some(HlClass::Str));
237        assert_eq!(
238            class_of("f.b", src, "42"),
239            Some(HlClass::Numeric { float: false }),
240        );
241        assert_eq!(
242            class_of("f.b", src, "1.5"),
243            Some(HlClass::Numeric { float: true }),
244        );
245    }
246
247    #[test]
248    fn literals_stay_boolean_not_keyword() {
249        // Pins the deliberate omission documented on BLUE_RESERVED_WORDS: blue
250        // reserves these three, but the table lexer paints them better than
251        // `Keyword` would, so they must NOT be in the keyword list.
252        for word in ["true", "false", "nil"] {
253            assert_eq!(
254                class_of("f.b", "x = true\ny = false\nz = nil\n", word),
255                Some(HlClass::Boolean),
256                "`{word}` must stay Boolean — see BLUE_RESERVED_WORDS",
257            );
258        }
259    }
260
261    #[test]
262    fn lowered_infix_callees_are_not_surface_keywords() {
263        // `and` / `or` / `not` are callee names in blue's INFIX table, not
264        // surface keywords. Painting them as keywords would be a lie about the
265        // language, so this pins them as ordinary identifiers.
266        let src = "a = not(b)\n";
267        assert_eq!(class_of("f.b", src, "not"), Some(HlClass::Variable));
268        assert!(!BLUE_RESERVED_WORDS.contains(&"and"));
269        assert!(!BLUE_RESERVED_WORDS.contains(&"or"));
270    }
271
272    #[test]
273    fn reserved_word_set_is_the_transcribed_upstream_set() {
274        // The set is closed and small, so pin it exactly. If blue adds a
275        // surface keyword, this is the line that has to be visited — the
276        // module docs explain why it is a transcription and not a dependency.
277        assert_eq!(BLUE_RESERVED_WORDS.len(), 15);
278        let mut sorted = BLUE_RESERVED_WORDS.to_vec();
279        sorted.sort_unstable();
280        sorted.dedup();
281        assert_eq!(sorted.len(), 15, "no duplicate reserved words");
282    }
283}