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/// `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}