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/// `true` / `false` / `nil` are the other three words upstream reserves and are
59/// **deliberately absent**: hikari's table lexer already classifies them as
60/// [`HlClass::Boolean`](hikari_core::HlClass::Boolean), and listing them here
61/// would demote them to plain `Keyword` — a worse paint, not a better one.
62/// `and` / `or` / `not` are also absent, and are not an oversight either: blue
63/// spells those `&&` / `||` / `not(…)`, and `and`/`or` exist only as the
64/// *lowered callee names* in `blue_lang_syntax::INFIX`, never as surface
65/// keywords.
66pub static BLUE_RESERVED_WORDS: &[&str] = &[
67 // ── SURFACE_KEYWORDS ──────────────────────────────────────────────
68 "assert",
69 "case",
70 "def",
71 "defmacro",
72 "fn",
73 "if",
74 "quote",
75 "test",
76 "unless",
77 "unquote",
78 "unquote_splice",
79 // ── the block-structure words `is_reserved_word` adds ─────────────
80 "do",
81 "else",
82 "elsif",
83 "end",
84];
85
86/// blue's lexical shape.
87///
88/// `colon_keywords` is on because a blue symbol is spelled `:name` and lowers
89/// to a tatara-lisp keyword — the same token hikari's lisp table paints as
90/// [`HlClass::KeywordArg`](hikari_core::HlClass::KeywordArg), and the same
91/// meaning. The hash-literal label `name:` (colon *after* the identifier) is a
92/// different token and is NOT covered; it paints as an identifier plus
93/// punctuation.
94///
95/// One string delimiter, because blue's lexer has one: `lex_string` is reached
96/// from `b'"'` alone — no single-quoted strings, no heredocs. Interpolation
97/// (`"n=#{x}"`) paints as one string span; the interpolated expression is not
98/// separately highlighted.
99///
100/// No block comment, because blue has none — comments are `#` to end of line,
101/// full stop.
102pub static BLUE_TABLE: LangTable = LangTable {
103 keywords: BLUE_RESERVED_WORDS,
104 line_comments: &["#"],
105 block_comment: None,
106 string_delims: &['"'],
107 colon_keywords: true,
108};
109
110/// How a document claims to be blue.
111///
112/// `Bluefile` is here because a Bluefile **is a blue program** — blue has no
113/// separate manifest grammar — and it carries no extension, which is exactly
114/// what [`Selector::Filename`] is for.
115pub static BLUE_SELECTORS: &[Selector] =
116 &[Selector::Extension("b"), Selector::Filename("Bluefile")];
117
118/// Every language escriba registers on top of the two hikari backends.
119#[must_use]
120pub fn escriba_local() -> Vec<Box<dyn LanguagePlugin>> {
121 vec![Box::new(TablePlugin {
122 language: BLUE,
123 selectors: BLUE_SELECTORS,
124 table: &BLUE_TABLE,
125 })]
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use hikari_core::{Ecosystem, HlClass};
132
133 /// A registry holding only the escriba-local plugins — the unit under
134 /// test, isolated from whatever hikari happens to ship.
135 fn local_only() -> Ecosystem {
136 let mut eco = Ecosystem::new();
137 for p in escriba_local() {
138 eco.register(p);
139 }
140 eco
141 }
142
143 fn classes(path: &str, src: &str) -> Vec<(String, HlClass)> {
144 let hl = local_only().highlighter_for_path(path);
145 hl.highlight(src)
146 .into_iter()
147 .map(|s| {
148 (
149 src[s.span.start as usize..s.span.end as usize].to_string(),
150 s.class,
151 )
152 })
153 .filter(|(t, _)| !t.trim().is_empty())
154 .collect()
155 }
156
157 fn class_of(path: &str, src: &str, token: &str) -> Option<HlClass> {
158 classes(path, src)
159 .into_iter()
160 .find(|(t, _)| t == token)
161 .map(|(_, c)| c)
162 }
163
164 #[test]
165 fn dot_b_resolves_to_blue() {
166 assert_eq!(local_only().resolve("scratch.b"), BLUE);
167 assert_eq!(local_only().resolve("/a/b/c/spec/strings.b"), BLUE);
168 }
169
170 #[test]
171 fn bluefile_resolves_to_blue_by_name() {
172 // No extension — the Filename selector is the only thing that can
173 // claim it, and a Bluefile is a blue program.
174 assert_eq!(local_only().resolve("Bluefile"), BLUE);
175 assert_eq!(local_only().resolve("bidamas/retsu/Bluefile"), BLUE);
176 }
177
178 #[test]
179 fn unrelated_paths_stay_plain() {
180 assert_eq!(local_only().resolve("main.rs"), hikari_core::PLAIN_TEXT);
181 assert_eq!(local_only().resolve("notes.txt"), hikari_core::PLAIN_TEXT);
182 // `.blue` is NOT blue's extension — `.b` is. Claiming it would be a
183 // guess, so the registry declines.
184 assert_eq!(local_only().resolve("x.blue"), hikari_core::PLAIN_TEXT);
185 }
186
187 #[test]
188 fn form_heads_and_block_words_paint_as_keywords() {
189 let src = "def f(x)\n if x\n x\n else\n 0\n end\nend\n";
190 for word in ["def", "if", "else", "end"] {
191 assert_eq!(
192 class_of("f.b", src, word),
193 Some(HlClass::Keyword),
194 "expected `{word}` to paint as a keyword",
195 );
196 }
197 }
198
199 #[test]
200 fn hash_starts_a_line_comment() {
201 let src = "# blue's own configuration\ndef f\nend\n";
202 let spans = classes("blue.b", src);
203 assert_eq!(
204 spans.first().map(|(_, c)| *c),
205 Some(HlClass::Comment { multiline: false }),
206 );
207 // The comment ends at the newline — `def` on the next line is live.
208 assert_eq!(class_of("blue.b", src, "def"), Some(HlClass::Keyword));
209 }
210
211 #[test]
212 fn symbols_paint_as_keyword_args() {
213 // `:name` is a blue symbol, which lowers to a tatara-lisp keyword.
214 assert_eq!(
215 class_of("f.b", "x = :ok\n", ":ok"),
216 Some(HlClass::KeywordArg),
217 );
218 }
219
220 #[test]
221 fn strings_and_numbers_paint() {
222 let src = "x = \"hello\"\ny = 42\nz = 1.5\n";
223 assert_eq!(class_of("f.b", src, "\"hello\""), Some(HlClass::Str));
224 assert_eq!(
225 class_of("f.b", src, "42"),
226 Some(HlClass::Numeric { float: false }),
227 );
228 assert_eq!(
229 class_of("f.b", src, "1.5"),
230 Some(HlClass::Numeric { float: true }),
231 );
232 }
233
234 #[test]
235 fn literals_stay_boolean_not_keyword() {
236 // Pins the deliberate omission documented on BLUE_RESERVED_WORDS: blue
237 // reserves these three, but the table lexer paints them better than
238 // `Keyword` would, so they must NOT be in the keyword list.
239 for word in ["true", "false", "nil"] {
240 assert_eq!(
241 class_of("f.b", "x = true\ny = false\nz = nil\n", word),
242 Some(HlClass::Boolean),
243 "`{word}` must stay Boolean — see BLUE_RESERVED_WORDS",
244 );
245 }
246 }
247
248 #[test]
249 fn lowered_infix_callees_are_not_surface_keywords() {
250 // `and` / `or` / `not` are callee names in blue's INFIX table, not
251 // surface keywords. Painting them as keywords would be a lie about the
252 // language, so this pins them as ordinary identifiers.
253 let src = "a = not(b)\n";
254 assert_eq!(class_of("f.b", src, "not"), Some(HlClass::Variable));
255 assert!(!BLUE_RESERVED_WORDS.contains(&"and"));
256 assert!(!BLUE_RESERVED_WORDS.contains(&"or"));
257 }
258
259 #[test]
260 fn reserved_word_set_is_the_transcribed_upstream_set() {
261 // The set is closed and small, so pin it exactly. If blue adds a
262 // surface keyword, this is the line that has to be visited — the
263 // module docs explain why it is a transcription and not a dependency.
264 assert_eq!(BLUE_RESERVED_WORDS.len(), 15);
265 let mut sorted = BLUE_RESERVED_WORDS.to_vec();
266 sorted.sort_unstable();
267 sorted.dedup();
268 assert_eq!(sorted.len(), 15, "no duplicate reserved words");
269 }
270}