Skip to main content

lattice_syntax/
lang.rs

1//! Languages this crate knows how to parse.
2
3use std::path::Path;
4
5use crate::plugin_lang::{self, LanguageName};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum Lang {
9    Plain,
10    Rust,
11    Python,
12    JavaScript,
13    Bash,
14    C,
15    Cpp,
16    Css,
17    Go,
18    Html,
19    Java,
20    Json,
21    Lua,
22    Ruby,
23    /// SQL via `tree-sitter-sequel` — general/permissive SQL grammar
24    /// supporting multiple dialects (MySQL, PostgreSQL, SQLite).
25    Sql,
26    Toml,
27    TypeScript,
28    /// WebAssembly Interface Types — the language `wit/` is written in, and
29    /// therefore the language this editor's own plugin API is written in.
30    /// Highlighted with a repo-held query rather than the grammar crate's own:
31    /// `tree_sitter_wit::HIGHLIGHTS_QUERY` uses TextMate capture names
32    /// (`entity.name.type.interface`), and [`crate::style::name_to_style`]
33    /// keys on the first dot-segment — so every `interface` / `record` /
34    /// `world` NAME would have rendered unstyled.
35    Wit,
36    /// TypeScript + JSX (React) via `tree-sitter-typescript`'s TSX
37    /// grammar. Separate from `TypeScript` because the two are
38    /// different dialects — TSX adds JSX element syntax on top of
39    /// the TypeScript parser.
40    Tsx,
41    Yaml,
42    /// CommonMark + GitHub-flavor markdown via `tree-sitter-md`.
43    /// The grammar is intentionally split into block + inline parsers;
44    /// the registry holds both as separate `HighlightConfiguration`s
45    /// (`markdown` and `markdown_inline`) and the block grammar's
46    /// injection queries thread between them. Help buffers also
47    /// render through this language.
48    Markdown,
49    /// A language contributed at runtime by a plugin (LG.2; design
50    /// [`plugin-languages.md`](../../../docs/dev/architecture/plugin-languages.md) §2.3).
51    ///
52    /// The existing variants deliberately stay. Native languages keep
53    /// compiler-checked coverage in [`Self::comment_syntax`],
54    /// [`crate::major_mode_id_for_lang`] and `FormatSpec::for_lang` —
55    /// so adding a bundled language still cannot silently miss its
56    /// formatter — and a plugin language is one fallthrough arm at each.
57    ///
58    /// The payload is the language's *name*, interned, rather than an
59    /// index: [`Self::name`] is the key every query lookup already uses,
60    /// and it stays a field read. See [`crate::plugin_lang`].
61    Plugin(LanguageName),
62}
63
64impl Lang {
65    /// The compiled-in language with this canonical name, if any.
66    ///
67    /// Exists so [`crate::plugin_lang::register`] can refuse a name that
68    /// would shadow a builtin, and it is written as a match over the
69    /// same table [`Self::name`] uses so the two cannot drift.
70    pub fn builtin_by_name(name: &str) -> Option<Self> {
71        const BUILTINS: &[Lang] = &[
72            Lang::Plain,
73            Lang::Rust,
74            Lang::Python,
75            Lang::JavaScript,
76            Lang::Bash,
77            Lang::C,
78            Lang::Cpp,
79            Lang::Css,
80            Lang::Go,
81            Lang::Html,
82            Lang::Java,
83            Lang::Json,
84            Lang::Lua,
85            Lang::Ruby,
86            Lang::Sql,
87            Lang::Toml,
88            Lang::TypeScript,
89            Lang::Wit,
90            Lang::Tsx,
91            Lang::Yaml,
92            Lang::Markdown,
93        ];
94        BUILTINS.iter().copied().find(|l| l.name() == name).or_else(
95            // Not a `Lang` variant, but it IS a registry key: markdown's
96            // inline grammar. A plugin claiming it would collide in the
97            // config map even though no `Lang` names it.
98            || (name == "markdown_inline").then_some(Lang::Markdown),
99        )
100    }
101
102    /// Detect language from a file path's extension.
103    pub fn detect_from_path(path: Option<&Path>) -> Self {
104        // Check known shell rc/profile filenames (dotfiles with no extension).
105        if let Some(p) = path
106            && let Some(name) = p.file_name().and_then(|n| n.to_str())
107        {
108            let lower = name.to_ascii_lowercase();
109            match lower.as_str() {
110                ".bashrc" | ".bash_profile" | ".bash_login" | ".bash_logout" | ".zshrc"
111                | ".zshenv" | ".zprofile" | ".zlogin" | ".zlogout" | ".profile" | ".shrc"
112                | ".kshrc" => return Lang::Bash,
113                _ => {}
114            }
115        }
116        match path
117            .and_then(|p| p.extension())
118            .and_then(|e| e.to_str())
119            .map(str::to_ascii_lowercase)
120            .as_deref()
121        {
122            Some("rs") => Lang::Rust,
123            Some("py") | Some("pyw") => Lang::Python,
124            Some("js") | Some("mjs") | Some("cjs") => Lang::JavaScript,
125            Some("sh") | Some("bash") | Some("zsh") | Some("fish") => Lang::Bash,
126            Some("c") | Some("h") => Lang::C,
127            Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") | Some("hxx") => {
128                Lang::Cpp
129            }
130            Some("css") => Lang::Css,
131            Some("go") => Lang::Go,
132            Some("html") | Some("htm") | Some("xhtml") => Lang::Html,
133            Some("java") => Lang::Java,
134            Some("json") => Lang::Json,
135            Some("lua") => Lang::Lua,
136            Some("rb") | Some("ruby") => Lang::Ruby,
137            Some("sql") => Lang::Sql,
138            Some("toml") => Lang::Toml,
139            Some("wit") => Lang::Wit,
140            Some("ts") | Some("mts") | Some("cts") => Lang::TypeScript,
141            Some("tsx") => Lang::Tsx,
142            Some("yaml") | Some("yml") => Lang::Yaml,
143            Some("md") | Some("markdown") | Some("mdown") | Some("mkd") => Lang::Markdown,
144            // The runtime registry is consulted only AFTER every native
145            // arm, so a plugin cannot shadow a bundled language by
146            // accident. When nothing is registered this costs one
147            // relaxed atomic load — `detect_from_path` runs per hunk in
148            // magit's diff highlighting, so the empty case has to be
149            // free rather than merely cheap.
150            Some(ext) => plugin_lang::resolve_extension(ext).map_or(Lang::Plain, Lang::Plugin),
151            None => Lang::Plain,
152        }
153    }
154
155    pub fn label(self) -> &'static str {
156        match self {
157            Lang::Plain => "plain",
158            Lang::Rust => "rust",
159            Lang::Python => "python",
160            Lang::JavaScript => "javascript",
161            Lang::Bash => "bash",
162            Lang::C => "c",
163            Lang::Cpp => "cpp",
164            Lang::Css => "css",
165            Lang::Go => "go",
166            Lang::Html => "html",
167            Lang::Java => "java",
168            Lang::Json => "json",
169            Lang::Lua => "lua",
170            Lang::Ruby => "ruby",
171            Lang::Sql => "sql",
172            Lang::Toml => "toml",
173            Lang::TypeScript => "typescript",
174            Lang::Tsx => "tsx",
175            Lang::Yaml => "yaml",
176            Lang::Wit => "wit",
177            Lang::Markdown => "markdown",
178            // The interned name IS the identity — a field read, not a
179            // table lookup. See `plugin_lang`.
180            Lang::Plugin(n) => n.as_str(),
181        }
182    }
183
184    /// Canonical name used as a registry key. The block markdown
185    /// parser is registered under `"markdown"`; the inline variant
186    /// (registered under `"markdown_inline"`) is reached only via
187    /// the block grammar's injection queries.
188    pub fn name(self) -> &'static str {
189        match self {
190            Lang::Plain => "plain",
191            Lang::Rust => "rust",
192            Lang::Python => "python",
193            Lang::JavaScript => "javascript",
194            Lang::Bash => "bash",
195            Lang::C => "c",
196            Lang::Cpp => "cpp",
197            Lang::Css => "css",
198            Lang::Go => "go",
199            Lang::Html => "html",
200            Lang::Java => "java",
201            Lang::Json => "json",
202            Lang::Lua => "lua",
203            Lang::Ruby => "ruby",
204            Lang::Sql => "sql",
205            Lang::Toml => "toml",
206            Lang::TypeScript => "typescript",
207            Lang::Tsx => "tsx",
208            Lang::Yaml => "yaml",
209            Lang::Wit => "wit",
210            Lang::Markdown => "markdown",
211            // The interned name IS the identity — a field read, not a
212            // table lookup. See `plugin_lang`.
213            Lang::Plugin(n) => n.as_str(),
214        }
215    }
216
217    /// N.1.6: per-language comment-leader descriptor for the comment
218    /// text objects (`aC` / `iC`). Commentstring-driven — the line
219    /// leader is all v1 uses; block delimiters are carried for a
220    /// follow-up. Languages with no line comment (markdown, plain)
221    /// return `line: None`, so the comment objects no-op there.
222    pub fn comment_syntax(self) -> lattice_grammar::CommentSyntax {
223        let (line, block): (Option<&str>, Option<(&str, &str)>) = match self {
224            Lang::Rust | Lang::JavaScript | Lang::TypeScript | Lang::Tsx => {
225                (Some("//"), Some(("/*", "*/")))
226            }
227            Lang::Python | Lang::Ruby | Lang::Bash | Lang::Yaml | Lang::Toml => (Some("#"), None),
228            // WIT sits here rather than with the `#` languages: it takes its
229            // comment syntax from C, including the `///` doc form.
230            Lang::Go | Lang::C | Lang::Cpp | Lang::Java | Lang::Wit => {
231                (Some("//"), Some(("/*", "*/")))
232            }
233            // C-style blocks, but the line form is `--`, not `//`.
234            Lang::Sql => (Some("--"), Some(("/*", "*/"))),
235            Lang::Css => (None, Some(("/*", "*/"))),
236            Lang::Html => (None, Some(("<!--", "-->"))),
237            Lang::Lua => (Some("--"), Some(("--[[", "]]"))),
238            Lang::Json | Lang::Plain | Lang::Markdown => (None, None),
239            // LG.3 lets a plugin declare its comment syntax; until
240            // then `aC` / `iC` no-op in a plugin language exactly as
241            // they do in markdown, rather than guessing a leader.
242            Lang::Plugin(_) => (None, None),
243        };
244        lattice_grammar::CommentSyntax {
245            line: line.map(str::to_string),
246            block: block.map(|(s, e)| (s.to_string(), e.to_string())),
247        }
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    #![allow(clippy::unwrap_used, clippy::panic)]
254    use super::*;
255    use std::path::PathBuf;
256
257    /// Every built-in language's line leader, pinned. SQL shipped as `//` —
258    /// grouped with the C family for its `/* */` blocks — which made `aC` /
259    /// `iC` find nothing in a `.sql` file, where comments start with `--`.
260    #[test]
261    fn line_comment_leaders_match_each_language() {
262        let expected: &[(Lang, Option<&str>)] = &[
263            (Lang::Rust, Some("//")),
264            (Lang::JavaScript, Some("//")),
265            (Lang::TypeScript, Some("//")),
266            (Lang::Tsx, Some("//")),
267            (Lang::Go, Some("//")),
268            (Lang::C, Some("//")),
269            (Lang::Cpp, Some("//")),
270            (Lang::Java, Some("//")),
271            (Lang::Wit, Some("//")),
272            (Lang::Python, Some("#")),
273            (Lang::Ruby, Some("#")),
274            (Lang::Bash, Some("#")),
275            (Lang::Yaml, Some("#")),
276            (Lang::Toml, Some("#")),
277            (Lang::Sql, Some("--")),
278            (Lang::Lua, Some("--")),
279            (Lang::Css, None),
280            (Lang::Html, None),
281            (Lang::Json, None),
282            (Lang::Markdown, None),
283            (Lang::Plain, None),
284        ];
285        for (lang, leader) in expected {
286            assert_eq!(
287                lang.comment_syntax().line.as_deref(),
288                *leader,
289                "line comment leader for {lang:?}"
290            );
291        }
292    }
293
294    #[test]
295    fn sql_keeps_its_block_comment_form() {
296        assert_eq!(
297            Lang::Sql.comment_syntax().block,
298            Some(("/*".to_string(), "*/".to_string()))
299        );
300    }
301
302    #[test]
303    fn detects_rust() {
304        assert_eq!(
305            Lang::detect_from_path(Some(&PathBuf::from("foo.rs"))),
306            Lang::Rust
307        );
308    }
309
310    #[test]
311    fn detects_python() {
312        assert_eq!(
313            Lang::detect_from_path(Some(&PathBuf::from("foo.py"))),
314            Lang::Python
315        );
316        assert_eq!(
317            Lang::detect_from_path(Some(&PathBuf::from("foo.pyw"))),
318            Lang::Python
319        );
320    }
321
322    #[test]
323    fn detects_javascript() {
324        assert_eq!(
325            Lang::detect_from_path(Some(&PathBuf::from("foo.js"))),
326            Lang::JavaScript
327        );
328        assert_eq!(
329            Lang::detect_from_path(Some(&PathBuf::from("foo.mjs"))),
330            Lang::JavaScript
331        );
332        assert_eq!(
333            Lang::detect_from_path(Some(&PathBuf::from("foo.cjs"))),
334            Lang::JavaScript
335        );
336    }
337
338    #[test]
339    fn falls_back_to_plain() {
340        assert_eq!(Lang::detect_from_path(None), Lang::Plain);
341        assert_eq!(
342            Lang::detect_from_path(Some(&PathBuf::from("README"))),
343            Lang::Plain
344        );
345        assert_eq!(
346            Lang::detect_from_path(Some(&PathBuf::from("foo.unknown"))),
347            Lang::Plain
348        );
349    }
350
351    #[test]
352    fn detection_is_case_insensitive() {
353        assert_eq!(
354            Lang::detect_from_path(Some(&PathBuf::from("FOO.RS"))),
355            Lang::Rust
356        );
357        assert_eq!(
358            Lang::detect_from_path(Some(&PathBuf::from("Foo.PY"))),
359            Lang::Python
360        );
361    }
362
363    #[test]
364    fn label_is_distinct_per_lang() {
365        assert_eq!(Lang::Plain.label(), "plain");
366        assert_eq!(Lang::Rust.label(), "rust");
367        assert_eq!(Lang::Python.label(), "python");
368        assert_eq!(Lang::JavaScript.label(), "javascript");
369        assert_eq!(Lang::Markdown.label(), "markdown");
370    }
371
372    /// `.wit` is the language this editor's own plugin API is written in, and
373    /// until now every file under `wit/` opened as plain text.
374    #[test]
375    fn detects_wit_files() {
376        assert_eq!(
377            Lang::detect_from_path(Some(&PathBuf::from("wit/grammar.wit"))),
378            Lang::Wit
379        );
380    }
381
382    /// Compiling the query is not the same as highlighting anything, and the
383    /// two came apart here: the crate ships a `HIGHLIGHTS_QUERY` that compiles
384    /// perfectly and leaves every declaration NAME unstyled, because its
385    /// captures are TextMate names and `style::name_to_style` keys on the first
386    /// dot-segment. So this asserts the tokens a reader actually scans for —
387    /// the `interface` keyword and the interface's own name — carry a style.
388    #[test]
389    fn a_wit_declaration_and_its_name_are_both_styled() {
390        let src = "interface ui {\n  register-segment: func(id: string);\n}\n";
391        let mut syntax = crate::Syntax::for_language(Lang::Wit)
392            .expect("registry builds")
393            .expect("wit has a grammar");
394        syntax.parse(src);
395        let lines = syntax.highlight_lines_native(0, 3).expect("spans");
396        let first = &lines[0];
397        assert!(
398            !first.is_empty(),
399            "the `interface ui {{` line produced no styled spans at all"
400        );
401        // Two distinct styles on line 0: the keyword and the name. One style
402        // would mean the name fell through to the keyword's span or to default.
403        let styles: std::collections::BTreeSet<String> =
404            first.iter().map(|s| format!("{:?}", s.style)).collect();
405        assert!(
406            styles.len() >= 2,
407            "keyword and declaration name must style differently, got {styles:?}"
408        );
409    }
410
411    /// `///` must look the same in a `.wit` file as in a `.rs` one.
412    ///
413    /// It did not. Two separate query bugs stacked: declaration captures were
414    /// written `(interface_item name: (identifier)) @type`, which attaches the
415    /// capture to the WHOLE item rather than the identifier, so an item's entire
416    /// span — comments included — rendered as a type; and `(attribute)`, which
417    /// in this grammar spans the doc comment attached to its item, painted the
418    /// `///` line as an attribute. Neither is visible from a query that
419    /// compiles, which is why this asserts resolved STYLES against Rust rather
420    /// than asserting that anything parsed.
421    #[test]
422    fn a_doc_comment_styles_the_same_in_wit_as_in_rust() {
423        fn styles(lang: Lang, src: &str) -> Vec<crate::Style> {
424            let mut s = crate::Syntax::for_language(lang)
425                .expect("registry builds")
426                .expect("language has a grammar");
427            s.parse(src);
428            s.highlight_lines_native(0, 2)
429                .expect("spans")
430                .iter()
431                .flat_map(|line| line.iter().map(|sp| sp.style))
432                .collect()
433        }
434        let wit = styles(Lang::Wit, "/// Doc.\n// Plain.\ninterface ui {\n}\n");
435        let rust = styles(Lang::Rust, "/// Doc.\n// Plain.\nfn f() {}\n");
436        assert_eq!(
437            wit, rust,
438            "a doc comment and a line comment must resolve to the same styles in \
439             both languages; WIT rendered them as Attribute and Type"
440        );
441    }
442
443    #[test]
444    fn detects_markdown_extensions() {
445        for ext in ["md", "markdown", "mdown", "mkd"] {
446            assert_eq!(
447                Lang::detect_from_path(Some(&PathBuf::from(format!("README.{ext}")))),
448                Lang::Markdown,
449                "{ext}"
450            );
451        }
452        // Case-insensitive.
453        assert_eq!(
454            Lang::detect_from_path(Some(&PathBuf::from("README.MD"))),
455            Lang::Markdown
456        );
457    }
458}