Skip to main content

diffler_core/syntax/
registry.rs

1//! Language registry: maps a file path to its tree-sitter grammar, a configured
2//! highlight configuration, and (where the grammar ships one) a tags query used
3//! for scope/definition lookup. Built once and reused.
4
5use std::borrow::Cow;
6use std::collections::HashMap;
7use std::ops::Range;
8use std::path::Path;
9use std::sync::{LazyLock, OnceLock};
10
11use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator, Tree};
12use tree_sitter_highlight::HighlightConfiguration;
13
14use crate::syntax::MAX_PARSE_BYTES;
15
16/// Capture names recognized during highlighting. A grammar capture like
17/// `function.method` resolves to the longest matching prefix here (`function`),
18/// so listing the general categories is enough to color every grammar.
19pub const HIGHLIGHT_NAMES: &[&str] = &[
20    "attribute",
21    "boolean",
22    "comment",
23    "conditional",
24    "constant",
25    "constant.builtin",
26    "constructor",
27    "field",
28    "function",
29    "function.builtin",
30    "keyword",
31    "label",
32    "number",
33    "operator",
34    "parameter",
35    "property",
36    "punctuation",
37    "punctuation.bracket",
38    "punctuation.delimiter",
39    "spell",
40    "storageclass",
41    "string",
42    "string.escape",
43    "string.special",
44    "tag",
45    "text.emphasis",
46    "text.literal",
47    "text.reference",
48    "text.strong",
49    "text.title",
50    "text.uri",
51    "type",
52    "type.builtin",
53    "variable",
54    "variable.builtin",
55    "variable.parameter",
56];
57
58pub struct LangEntry {
59    pub name: &'static str,
60    pub language: Language,
61    highlights: Cow<'static, str>,
62    injections: Cow<'static, str>,
63    tags_query: Option<Cow<'static, str>>,
64    /// This language's indentation or layout is syntax: re-indenting a line
65    /// there moves it between blocks, so the structural diff algorithm never
66    /// calls it a reformat, whatever the AST diff reports.
67    pub layout_significant: bool,
68    /// Compiling a query costs ~15ms, so a grammar pays only once someone opens
69    /// a file in it. Both stay `None` when the grammar's query fails to
70    /// compile: the file renders plain instead of erroring.
71    config: OnceLock<Option<HighlightConfiguration>>,
72    tags: OnceLock<Option<Query>>,
73}
74
75impl LangEntry {
76    pub fn config(&self) -> Option<&HighlightConfiguration> {
77        self.config
78            .get_or_init(|| {
79                HighlightConfiguration::new(
80                    self.language.clone(),
81                    self.name,
82                    &self.highlights,
83                    &self.injections,
84                    "",
85                )
86                .ok()
87                .map(|mut config| {
88                    config.configure(HIGHLIGHT_NAMES);
89                    config
90                })
91            })
92            .as_ref()
93    }
94
95    pub fn tags(&self) -> Option<&Query> {
96        self.tags
97            .get_or_init(|| Query::new(&self.language, self.tags_query.as_deref()?).ok())
98            .as_ref()
99    }
100}
101
102/// The grammars, built once for the process. Registration only records the
103/// grammar and its query text, so this costs microseconds; a theme switch
104/// rebuilds the palette and reuses these.
105pub static REGISTRY: LazyLock<LanguageRegistry> = LazyLock::new(LanguageRegistry::build);
106
107pub struct LanguageRegistry {
108    entries: Vec<LangEntry>,
109    by_ext: HashMap<&'static str, usize>,
110    by_name: HashMap<&'static str, usize>,
111    by_filename: HashMap<&'static str, usize>,
112    /// The inline markdown highlight query, applied by hand over the block
113    /// grammar's `(inline)` nodes: tree-sitter's generic injection does not
114    /// drive the split markdown grammar's inline pass.
115    markdown_inline_query: Option<Query>,
116}
117
118impl LanguageRegistry {
119    /// Build the registry with every bundled grammar, reused for the session.
120    // flat per-language registration table
121    #[allow(clippy::too_many_lines)]
122    pub fn build() -> Self {
123        let mut r = Self {
124            entries: Vec::new(),
125            by_ext: HashMap::new(),
126            by_name: HashMap::new(),
127            by_filename: HashMap::new(),
128            markdown_inline_query: None,
129        };
130
131        r.add(
132            "rust",
133            &["rs"],
134            tree_sitter_rust::LANGUAGE.into(),
135            tree_sitter_rust::HIGHLIGHTS_QUERY,
136            Some(tree_sitter_rust::TAGS_QUERY),
137        );
138        r.add(
139            "python",
140            &["py", "pyi"],
141            tree_sitter_python::LANGUAGE.into(),
142            tree_sitter_python::HIGHLIGHTS_QUERY,
143            Some(tree_sitter_python::TAGS_QUERY),
144        );
145        r.layout_significant();
146        r.add(
147            "javascript",
148            &["js", "jsx", "mjs", "cjs"],
149            tree_sitter_javascript::LANGUAGE.into(),
150            format!(
151                "{}\n{}",
152                tree_sitter_javascript::HIGHLIGHT_QUERY,
153                tree_sitter_javascript::JSX_HIGHLIGHT_QUERY
154            ),
155            Some(tree_sitter_javascript::TAGS_QUERY),
156        );
157        r.add(
158            "typescript",
159            &["ts", "mts", "cts"],
160            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
161            format!(
162                "{}\n{}",
163                tree_sitter_javascript::HIGHLIGHT_QUERY,
164                tree_sitter_typescript::HIGHLIGHTS_QUERY
165            ),
166            Some(tree_sitter_typescript::TAGS_QUERY),
167        );
168        r.add(
169            "tsx",
170            &["tsx"],
171            tree_sitter_typescript::LANGUAGE_TSX.into(),
172            format!(
173                "{}\n{}\n{}",
174                tree_sitter_javascript::HIGHLIGHT_QUERY,
175                tree_sitter_javascript::JSX_HIGHLIGHT_QUERY,
176                tree_sitter_typescript::HIGHLIGHTS_QUERY
177            ),
178            Some(tree_sitter_typescript::TAGS_QUERY),
179        );
180        r.add(
181            "go",
182            &["go"],
183            tree_sitter_go::LANGUAGE.into(),
184            tree_sitter_go::HIGHLIGHTS_QUERY,
185            Some(tree_sitter_go::TAGS_QUERY),
186        );
187        r.add(
188            "c",
189            &["c", "h"],
190            tree_sitter_c::LANGUAGE.into(),
191            tree_sitter_c::HIGHLIGHT_QUERY,
192            Some(tree_sitter_c::TAGS_QUERY),
193        );
194        r.add(
195            "cpp",
196            &["cpp", "cc", "cxx", "hpp", "hh", "hxx"],
197            tree_sitter_cpp::LANGUAGE.into(),
198            // the C++ query extends C's; alone it matches only C++ constructs
199            format!(
200                "{}\n{}",
201                tree_sitter_c::HIGHLIGHT_QUERY,
202                tree_sitter_cpp::HIGHLIGHT_QUERY
203            ),
204            Some(tree_sitter_cpp::TAGS_QUERY),
205        );
206        r.add(
207            "java",
208            &["java"],
209            tree_sitter_java::LANGUAGE.into(),
210            tree_sitter_java::HIGHLIGHTS_QUERY,
211            Some(tree_sitter_java::TAGS_QUERY),
212        );
213        r.add(
214            "c-sharp",
215            &["cs"],
216            tree_sitter_c_sharp::LANGUAGE.into(),
217            tree_sitter_c_sharp::HIGHLIGHTS_QUERY,
218            Some(tree_sitter_c_sharp::TAGS_QUERY),
219        );
220        r.add(
221            "ruby",
222            &["rb"],
223            tree_sitter_ruby::LANGUAGE.into(),
224            tree_sitter_ruby::HIGHLIGHTS_QUERY,
225            Some(tree_sitter_ruby::TAGS_QUERY),
226        );
227        r.add(
228            "php",
229            &["php"],
230            tree_sitter_php::LANGUAGE_PHP.into(),
231            tree_sitter_php::HIGHLIGHTS_QUERY,
232            Some(tree_sitter_php::TAGS_QUERY),
233        );
234        r.add(
235            "bash",
236            &["sh", "bash", "zsh"],
237            tree_sitter_bash::LANGUAGE.into(),
238            tree_sitter_bash::HIGHLIGHT_QUERY,
239            None,
240        );
241        r.add(
242            "json",
243            &["json"],
244            tree_sitter_json::LANGUAGE.into(),
245            tree_sitter_json::HIGHLIGHTS_QUERY,
246            None,
247        );
248        r.register(
249            "html",
250            &["html", "htm"],
251            tree_sitter_html::LANGUAGE.into(),
252            tree_sitter_html::HIGHLIGHTS_QUERY,
253            tree_sitter_html::INJECTIONS_QUERY,
254            None,
255        );
256        r.add(
257            "css",
258            &["css"],
259            tree_sitter_css::LANGUAGE.into(),
260            tree_sitter_css::HIGHLIGHTS_QUERY,
261            None,
262        );
263        r.add(
264            "yaml",
265            &["yml", "yaml"],
266            tree_sitter_yaml::LANGUAGE.into(),
267            tree_sitter_yaml::HIGHLIGHTS_QUERY,
268            None,
269        );
270        r.layout_significant();
271        // the grammar's own numeric patterns are guarded by Lua-style `%d`
272        // predicates that tree-sitter's regex engine never matches, leaving
273        // every number styled as a string; a later pattern wins, so this one
274        // restores number coloring
275        r.add(
276            "sql",
277            &["sql"],
278            tree_sitter_sequel::LANGUAGE.into(),
279            format!(
280                "{}\n((literal) @number (#match? @number \"^[-+]?[0-9][0-9.]*$\"))\n",
281                tree_sitter_sequel::HIGHLIGHTS_QUERY
282            ),
283            None,
284        );
285        // The block grammar highlights headings/markers and injects fenced code
286        // into its own language; inline emphasis, code spans, and links come from
287        // the by-hand inline pass below.
288        r.register(
289            "markdown",
290            &["md", "markdown"],
291            tree_sitter_md::LANGUAGE.into(),
292            tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
293            tree_sitter_md::INJECTION_QUERY_BLOCK,
294            None,
295        );
296        let md_inline: Language = tree_sitter_md::INLINE_LANGUAGE.into();
297        r.markdown_inline_query =
298            Query::new(&md_inline, tree_sitter_md::HIGHLIGHT_QUERY_INLINE).ok();
299        r.register(
300            "markdown_inline",
301            &[],
302            md_inline,
303            tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
304            "",
305            None,
306        );
307
308        r.add(
309            "toml",
310            &["toml"],
311            tree_sitter_toml_ng::LANGUAGE.into(),
312            tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
313            None,
314        );
315        // the grammar crate ships a parser only; the query is vendored
316        r.add(
317            "hcl",
318            &["tf", "tfvars", "hcl"],
319            tree_sitter_hcl::LANGUAGE.into(),
320            include_str!("../../queries/hcl/highlights.scm"),
321            None,
322        );
323        r.add(
324            "dockerfile",
325            &["dockerfile", "containerfile"],
326            arborium_dockerfile::language().into(),
327            arborium_dockerfile::HIGHLIGHTS_QUERY,
328            None,
329        );
330        r.add(
331            "make",
332            &["mk"],
333            tree_sitter_make::LANGUAGE.into(),
334            tree_sitter_make::HIGHLIGHTS_QUERY,
335            None,
336        );
337        r.layout_significant();
338        r.add(
339            "lua",
340            &["lua"],
341            tree_sitter_lua::LANGUAGE.into(),
342            tree_sitter_lua::HIGHLIGHTS_QUERY,
343            Some(tree_sitter_lua::TAGS_QUERY),
344        );
345        r.add(
346            "nix",
347            &["nix"],
348            tree_sitter_nix::LANGUAGE.into(),
349            tree_sitter_nix::HIGHLIGHTS_QUERY,
350            None,
351        );
352        r.add(
353            "xml",
354            &["xml", "xsd", "xslt", "svg"],
355            tree_sitter_xml::LANGUAGE_XML.into(),
356            tree_sitter_xml::XML_HIGHLIGHT_QUERY,
357            None,
358        );
359        r.add(
360            "swift",
361            &["swift"],
362            tree_sitter_swift::LANGUAGE.into(),
363            tree_sitter_swift::HIGHLIGHTS_QUERY,
364            Some(tree_sitter_swift::TAGS_QUERY),
365        );
366        r.add(
367            "scala",
368            &["scala", "sbt"],
369            tree_sitter_scala::LANGUAGE.into(),
370            tree_sitter_scala::HIGHLIGHTS_QUERY,
371            None,
372        );
373        r.layout_significant();
374        r.add(
375            "elixir",
376            &["ex", "exs"],
377            tree_sitter_elixir::LANGUAGE.into(),
378            tree_sitter_elixir::HIGHLIGHTS_QUERY,
379            Some(tree_sitter_elixir::TAGS_QUERY),
380        );
381        r.add(
382            "zig",
383            &["zig", "zon"],
384            tree_sitter_zig::LANGUAGE.into(),
385            tree_sitter_zig::HIGHLIGHTS_QUERY,
386            None,
387        );
388        r.add(
389            "haskell",
390            &["hs"],
391            tree_sitter_haskell::LANGUAGE.into(),
392            tree_sitter_haskell::HIGHLIGHTS_QUERY,
393            None,
394        );
395        r.layout_significant();
396        r.add(
397            "dart",
398            &["dart"],
399            tree_sitter_dart::LANGUAGE.into(),
400            tree_sitter_dart::HIGHLIGHTS_QUERY,
401            Some(tree_sitter_dart::TAGS_QUERY),
402        );
403        r.add(
404            "powershell",
405            &["ps1", "psm1", "psd1"],
406            tree_sitter_powershell::LANGUAGE.into(),
407            tree_sitter_powershell::HIGHLIGHTS_QUERY,
408            None,
409        );
410        r.register(
411            "svelte",
412            &["svelte"],
413            tree_sitter_svelte_ng::LANGUAGE.into(),
414            // the svelte query extends html's, and its injections carry
415            // `<script>` and `<style>` into the js and css grammars
416            format!(
417                "{}\n{}",
418                tree_sitter_html::HIGHLIGHTS_QUERY,
419                tree_sitter_svelte_ng::HIGHLIGHTS_QUERY
420            ),
421            tree_sitter_svelte_ng::INJECTIONS_QUERY,
422            None,
423        );
424        r.name_files("make", &["makefile", "gnumakefile"]);
425        r.name_files("dockerfile", &["dockerfile", "containerfile"]);
426
427        r
428    }
429
430    /// Route files a build tool names outright (`Makefile`, `Dockerfile`).
431    fn name_files(&mut self, name: &'static str, filenames: &'static [&'static str]) {
432        let Some(&idx) = self.by_name.get(name) else {
433            return;
434        };
435        for file in filenames {
436            self.by_filename.insert(file, idx);
437        }
438    }
439
440    fn add(
441        &mut self,
442        name: &'static str,
443        extensions: &'static [&'static str],
444        language: Language,
445        highlights: impl Into<Cow<'static, str>>,
446        tags: Option<&'static str>,
447    ) {
448        self.register(name, extensions, language, highlights, "", tags);
449    }
450
451    fn register(
452        &mut self,
453        name: &'static str,
454        extensions: &'static [&'static str],
455        language: Language,
456        highlights: impl Into<Cow<'static, str>>,
457        injections: impl Into<Cow<'static, str>>,
458        tags: Option<&'static str>,
459    ) {
460        let idx = self.entries.len();
461        self.entries.push(LangEntry {
462            name,
463            language,
464            highlights: highlights.into(),
465            injections: injections.into(),
466            tags_query: tags.map(Cow::Borrowed),
467            layout_significant: false,
468            config: OnceLock::new(),
469            tags: OnceLock::new(),
470        });
471        self.by_name.insert(name, idx);
472        for ext in extensions {
473            self.by_ext.insert(ext, idx);
474        }
475    }
476
477    /// Flags the language just registered as layout-significant (see
478    /// [`LangEntry::layout_significant`]); called right after its `add`/
479    /// `register` so the flag travels with the entry itself.
480    fn layout_significant(&mut self) {
481        if let Some(entry) = self.entries.last_mut() {
482            entry.layout_significant = true;
483        }
484    }
485
486    /// The entry whose grammar handles `path`, by extension or, for the files
487    /// a build tool names outright (`Makefile`, `Dockerfile`), by basename.
488    pub fn for_path(&self, path: &str) -> Option<&LangEntry> {
489        let name = Path::new(path).file_name()?.to_str()?.to_ascii_lowercase();
490        if let Some(&idx) = self.by_filename.get(name.as_str()) {
491            return self.entries.get(idx);
492        }
493        let ext = Path::new(path).extension()?.to_str()?;
494        let &idx = self.by_ext.get(ext)?;
495        self.entries.get(idx)
496    }
497
498    /// The entry for a markdown fence token (`rust`, `py`, `c++`, ...), matched
499    /// by grammar name then extension.
500    pub fn for_token(&self, token: &str) -> Option<&LangEntry> {
501        let token = token.trim().to_ascii_lowercase();
502        let token = match token.as_str() {
503            "c++" => "cpp",
504            "c#" | "csharp" => "cs",
505            "shell" => "bash",
506            "golang" => "go",
507            "terraform" => "hcl",
508            "docker" => "dockerfile",
509            "makefile" => "make",
510            "pwsh" | "ps" => "powershell",
511            other => other,
512        };
513        let &idx = self.by_name.get(token).or_else(|| self.by_ext.get(token))?;
514        self.entries.get(idx)
515    }
516
517    /// Highlight config for a tree-sitter injection language name (the inline
518    /// markdown grammar, or a fenced code block's language). `None` leaves the
519    /// injected region plain.
520    pub fn config_for_injection(&self, lang: &str) -> Option<&HighlightConfiguration> {
521        self.for_token(lang)?.config()
522    }
523
524    /// Inline markdown captures (emphasis, code spans, links) as byte range plus
525    /// the recognized highlight name, narrowest span first so a first-match
526    /// renderer picks the most specific. The inline grammar parses only the
527    /// block grammar's `(inline)` node ranges, so block markers stay untouched.
528    pub fn markdown_inline_spans(&self, content: &str) -> Vec<(Range<usize>, &'static str)> {
529        if content.len() > MAX_PARSE_BYTES {
530            return Vec::new();
531        }
532        let (Some(query), Some(block), Some(inline)) = (
533            self.markdown_inline_query.as_ref(),
534            self.by_name
535                .get("markdown")
536                .and_then(|&i| self.entries.get(i)),
537            self.by_name
538                .get("markdown_inline")
539                .and_then(|&i| self.entries.get(i)),
540        ) else {
541            return Vec::new();
542        };
543
544        let mut bp = Parser::new();
545        if bp.set_language(&block.language).is_err() {
546            return Vec::new();
547        }
548        let Some(block_tree) = bp.parse(content, None) else {
549            return Vec::new();
550        };
551        let ranges = inline_node_ranges(&block_tree);
552        if ranges.is_empty() {
553            return Vec::new();
554        }
555
556        let mut ip = Parser::new();
557        if ip.set_included_ranges(&ranges).is_err() || ip.set_language(&inline.language).is_err() {
558            return Vec::new();
559        }
560        let Some(inline_tree) = ip.parse(content, None) else {
561            return Vec::new();
562        };
563
564        let names = query.capture_names();
565        let mut cursor = QueryCursor::new();
566        let mut spans = Vec::new();
567        let mut matches = cursor.matches(query, inline_tree.root_node(), content.as_bytes());
568        while let Some(m) = matches.next() {
569            for cap in m.captures {
570                let cname = names.get(cap.index as usize).copied().unwrap_or("");
571                if let Some(name) = recognized_highlight(cname) {
572                    spans.push((cap.node.byte_range(), name));
573                }
574            }
575        }
576        spans.sort_by_key(|(r, _)| r.end - r.start);
577        spans
578    }
579}
580
581/// Byte ranges of every `(inline)` node in a markdown block tree, in document
582/// order. These are the regions the inline grammar reparses.
583fn inline_node_ranges(tree: &Tree) -> Vec<tree_sitter::Range> {
584    let mut ranges = Vec::new();
585    let mut cursor = tree.walk();
586    loop {
587        let node = cursor.node();
588        if node.kind() == "inline" && node.end_byte() > node.start_byte() {
589            ranges.push(node.range());
590        }
591        if cursor.goto_first_child() {
592            continue;
593        }
594        loop {
595            if cursor.goto_next_sibling() {
596                break;
597            }
598            if !cursor.goto_parent() {
599                return ranges;
600            }
601        }
602    }
603}
604
605/// The longest `HIGHLIGHT_NAMES` entry that is a dotted prefix of `capture`,
606/// matching how tree-sitter resolves capture names to recognized highlights.
607fn recognized_highlight(capture: &str) -> Option<&'static str> {
608    HIGHLIGHT_NAMES
609        .iter()
610        .copied()
611        .filter(|name| {
612            capture == *name
613                || capture
614                    .strip_prefix(name)
615                    .is_some_and(|r| r.starts_with('.'))
616        })
617        .max_by_key(|name| name.len())
618}
619
620impl Default for LanguageRegistry {
621    fn default() -> Self {
622        Self::build()
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    #[test]
629    fn exactly_the_layout_languages_are_layout_significant() {
630        let flagged: Vec<_> = super::REGISTRY
631            .entries
632            .iter()
633            .filter(|entry| entry.layout_significant)
634            .map(|entry| entry.name)
635            .collect();
636        assert_eq!(flagged, ["python", "yaml", "make", "scala", "haskell"]);
637    }
638}