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}