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