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            None,
167        );
168        r.tags_over_javascript(tree_sitter_typescript::TAGS_QUERY);
169        r.add(
170            "tsx",
171            &["tsx"],
172            tree_sitter_typescript::LANGUAGE_TSX.into(),
173            format!(
174                "{}\n{}\n{}",
175                tree_sitter_javascript::HIGHLIGHT_QUERY,
176                tree_sitter_javascript::JSX_HIGHLIGHT_QUERY,
177                tree_sitter_typescript::HIGHLIGHTS_QUERY
178            ),
179            None,
180        );
181        r.tags_over_javascript(tree_sitter_typescript::TAGS_QUERY);
182        r.add(
183            "go",
184            &["go"],
185            tree_sitter_go::LANGUAGE.into(),
186            tree_sitter_go::HIGHLIGHTS_QUERY,
187            Some(tree_sitter_go::TAGS_QUERY),
188        );
189        r.add(
190            "c",
191            &["c", "h"],
192            tree_sitter_c::LANGUAGE.into(),
193            tree_sitter_c::HIGHLIGHT_QUERY,
194            Some(tree_sitter_c::TAGS_QUERY),
195        );
196        r.add(
197            "cpp",
198            &["cpp", "cc", "cxx", "hpp", "hh", "hxx"],
199            tree_sitter_cpp::LANGUAGE.into(),
200            // the C++ query extends C's; alone it matches only C++ constructs
201            format!(
202                "{}\n{}",
203                tree_sitter_c::HIGHLIGHT_QUERY,
204                tree_sitter_cpp::HIGHLIGHT_QUERY
205            ),
206            Some(tree_sitter_cpp::TAGS_QUERY),
207        );
208        r.add(
209            "java",
210            &["java"],
211            tree_sitter_java::LANGUAGE.into(),
212            tree_sitter_java::HIGHLIGHTS_QUERY,
213            Some(tree_sitter_java::TAGS_QUERY),
214        );
215        r.add(
216            "c-sharp",
217            &["cs"],
218            tree_sitter_c_sharp::LANGUAGE.into(),
219            tree_sitter_c_sharp::HIGHLIGHTS_QUERY,
220            Some(tree_sitter_c_sharp::TAGS_QUERY),
221        );
222        r.add(
223            "ruby",
224            &["rb"],
225            tree_sitter_ruby::LANGUAGE.into(),
226            tree_sitter_ruby::HIGHLIGHTS_QUERY,
227            Some(tree_sitter_ruby::TAGS_QUERY),
228        );
229        r.add(
230            "php",
231            &["php"],
232            tree_sitter_php::LANGUAGE_PHP.into(),
233            tree_sitter_php::HIGHLIGHTS_QUERY,
234            Some(tree_sitter_php::TAGS_QUERY),
235        );
236        r.add(
237            "bash",
238            &["sh", "bash", "zsh"],
239            tree_sitter_bash::LANGUAGE.into(),
240            tree_sitter_bash::HIGHLIGHT_QUERY,
241            None,
242        );
243        r.add(
244            "json",
245            &["json"],
246            tree_sitter_json::LANGUAGE.into(),
247            tree_sitter_json::HIGHLIGHTS_QUERY,
248            None,
249        );
250        r.register(
251            "html",
252            &["html", "htm"],
253            tree_sitter_html::LANGUAGE.into(),
254            tree_sitter_html::HIGHLIGHTS_QUERY,
255            tree_sitter_html::INJECTIONS_QUERY,
256            None,
257        );
258        r.add(
259            "css",
260            &["css"],
261            tree_sitter_css::LANGUAGE.into(),
262            tree_sitter_css::HIGHLIGHTS_QUERY,
263            None,
264        );
265        r.add(
266            "yaml",
267            &["yml", "yaml"],
268            tree_sitter_yaml::LANGUAGE.into(),
269            tree_sitter_yaml::HIGHLIGHTS_QUERY,
270            None,
271        );
272        r.layout_significant();
273        // the grammar's own numeric patterns are guarded by Lua-style `%d`
274        // predicates that tree-sitter's regex engine never matches, leaving
275        // every number styled as a string; a later pattern wins, so this one
276        // restores number coloring
277        r.add(
278            "sql",
279            &["sql"],
280            tree_sitter_sequel::LANGUAGE.into(),
281            format!(
282                "{}\n((literal) @number (#match? @number \"^[-+]?[0-9][0-9.]*$\"))\n",
283                tree_sitter_sequel::HIGHLIGHTS_QUERY
284            ),
285            None,
286        );
287        // The block grammar highlights headings/markers and injects fenced code
288        // into its own language; inline emphasis, code spans, and links come from
289        // the by-hand inline pass below.
290        r.register(
291            "markdown",
292            &["md", "markdown"],
293            tree_sitter_md::LANGUAGE.into(),
294            tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
295            tree_sitter_md::INJECTION_QUERY_BLOCK,
296            None,
297        );
298        let md_inline: Language = tree_sitter_md::INLINE_LANGUAGE.into();
299        r.markdown_inline_query =
300            Query::new(&md_inline, tree_sitter_md::HIGHLIGHT_QUERY_INLINE).ok();
301        r.register(
302            "markdown_inline",
303            &[],
304            md_inline,
305            tree_sitter_md::HIGHLIGHT_QUERY_INLINE,
306            "",
307            None,
308        );
309
310        r.add(
311            "toml",
312            &["toml"],
313            tree_sitter_toml_ng::LANGUAGE.into(),
314            tree_sitter_toml_ng::HIGHLIGHTS_QUERY,
315            None,
316        );
317        // the grammar crate ships a parser only; the query is vendored
318        r.add(
319            "hcl",
320            &["tf", "tfvars", "hcl"],
321            tree_sitter_hcl::LANGUAGE.into(),
322            include_str!("../../queries/hcl/highlights.scm"),
323            None,
324        );
325        r.add(
326            "dockerfile",
327            &["dockerfile", "containerfile"],
328            arborium_dockerfile::language().into(),
329            arborium_dockerfile::HIGHLIGHTS_QUERY,
330            None,
331        );
332        r.add(
333            "make",
334            &["mk"],
335            tree_sitter_make::LANGUAGE.into(),
336            tree_sitter_make::HIGHLIGHTS_QUERY,
337            None,
338        );
339        r.layout_significant();
340        r.add(
341            "lua",
342            &["lua"],
343            tree_sitter_lua::LANGUAGE.into(),
344            tree_sitter_lua::HIGHLIGHTS_QUERY,
345            Some(tree_sitter_lua::TAGS_QUERY),
346        );
347        r.add(
348            "nix",
349            &["nix"],
350            tree_sitter_nix::LANGUAGE.into(),
351            tree_sitter_nix::HIGHLIGHTS_QUERY,
352            None,
353        );
354        r.add(
355            "xml",
356            &["xml", "xsd", "xslt", "svg"],
357            tree_sitter_xml::LANGUAGE_XML.into(),
358            tree_sitter_xml::XML_HIGHLIGHT_QUERY,
359            None,
360        );
361        r.add(
362            "swift",
363            &["swift"],
364            tree_sitter_swift::LANGUAGE.into(),
365            tree_sitter_swift::HIGHLIGHTS_QUERY,
366            Some(tree_sitter_swift::TAGS_QUERY),
367        );
368        r.add(
369            "scala",
370            &["scala", "sbt"],
371            tree_sitter_scala::LANGUAGE.into(),
372            tree_sitter_scala::HIGHLIGHTS_QUERY,
373            None,
374        );
375        r.layout_significant();
376        r.add(
377            "elixir",
378            &["ex", "exs"],
379            tree_sitter_elixir::LANGUAGE.into(),
380            tree_sitter_elixir::HIGHLIGHTS_QUERY,
381            Some(tree_sitter_elixir::TAGS_QUERY),
382        );
383        r.add(
384            "zig",
385            &["zig", "zon"],
386            tree_sitter_zig::LANGUAGE.into(),
387            tree_sitter_zig::HIGHLIGHTS_QUERY,
388            None,
389        );
390        r.add(
391            "haskell",
392            &["hs"],
393            tree_sitter_haskell::LANGUAGE.into(),
394            tree_sitter_haskell::HIGHLIGHTS_QUERY,
395            None,
396        );
397        r.layout_significant();
398        r.add(
399            "dart",
400            &["dart"],
401            tree_sitter_dart::LANGUAGE.into(),
402            tree_sitter_dart::HIGHLIGHTS_QUERY,
403            Some(tree_sitter_dart::TAGS_QUERY),
404        );
405        r.add(
406            "powershell",
407            &["ps1", "psm1", "psd1"],
408            tree_sitter_powershell::LANGUAGE.into(),
409            tree_sitter_powershell::HIGHLIGHTS_QUERY,
410            None,
411        );
412        r.register(
413            "svelte",
414            &["svelte"],
415            tree_sitter_svelte_ng::LANGUAGE.into(),
416            // the svelte query extends html's, and its injections carry
417            // `<script>` and `<style>` into the js and css grammars
418            format!(
419                "{}\n{}",
420                tree_sitter_html::HIGHLIGHTS_QUERY,
421                tree_sitter_svelte_ng::HIGHLIGHTS_QUERY
422            ),
423            tree_sitter_svelte_ng::INJECTIONS_QUERY,
424            None,
425        );
426        r.name_files("make", &["makefile", "gnumakefile"]);
427        r.name_files("dockerfile", &["dockerfile", "containerfile"]);
428
429        r
430    }
431
432    /// Route files a build tool names outright (`Makefile`, `Dockerfile`).
433    fn name_files(&mut self, name: &'static str, filenames: &'static [&'static str]) {
434        let Some(&idx) = self.by_name.get(name) else {
435            return;
436        };
437        for file in filenames {
438            self.by_filename.insert(file, idx);
439        }
440    }
441
442    fn add(
443        &mut self,
444        name: &'static str,
445        extensions: &'static [&'static str],
446        language: Language,
447        highlights: impl Into<Cow<'static, str>>,
448        tags: Option<&'static str>,
449    ) {
450        self.register(name, extensions, language, highlights, "", tags);
451    }
452
453    fn register(
454        &mut self,
455        name: &'static str,
456        extensions: &'static [&'static str],
457        language: Language,
458        highlights: impl Into<Cow<'static, str>>,
459        injections: impl Into<Cow<'static, str>>,
460        tags: Option<&'static str>,
461    ) {
462        let idx = self.entries.len();
463        self.entries.push(LangEntry {
464            name,
465            language,
466            highlights: highlights.into(),
467            injections: injections.into(),
468            tags_query: tags.map(Cow::Borrowed),
469            layout_significant: false,
470            config: OnceLock::new(),
471            tags: OnceLock::new(),
472        });
473        self.by_name.insert(name, idx);
474        for ext in extensions {
475            self.by_ext.insert(ext, idx);
476        }
477    }
478
479    /// Flags the language just registered as layout-significant (see
480    /// [`LangEntry::layout_significant`]); called right after its `add`/
481    /// `register` so the flag travels with the entry itself.
482    fn layout_significant(&mut self) {
483        if let Some(entry) = self.entries.last_mut() {
484            entry.layout_significant = true;
485        }
486    }
487
488    /// Gives the language just registered JavaScript's tags plus its own
489    /// `tags`. TypeScript's tags query covers only what TypeScript adds to
490    /// JavaScript, so on its own it finds no plain function at all.
491    fn tags_over_javascript(&mut self, tags: &'static str) {
492        if let Some(entry) = self.entries.last_mut() {
493            entry.tags_query = Some(Cow::Owned(format!(
494                "{}\n{tags}",
495                tree_sitter_javascript::TAGS_QUERY
496            )));
497        }
498    }
499
500    /// The entry whose grammar handles `path`, by extension or, for the files
501    /// a build tool names outright (`Makefile`, `Dockerfile`), by basename.
502    pub fn for_path(&self, path: &str) -> Option<&LangEntry> {
503        let name = Path::new(path).file_name()?.to_str()?.to_ascii_lowercase();
504        if let Some(&idx) = self.by_filename.get(name.as_str()) {
505            return self.entries.get(idx);
506        }
507        let ext = Path::new(path).extension()?.to_str()?;
508        let &idx = self.by_ext.get(ext)?;
509        self.entries.get(idx)
510    }
511
512    /// The entry for a markdown fence token (`rust`, `py`, `c++`, ...), matched
513    /// by grammar name then extension.
514    pub fn for_token(&self, token: &str) -> Option<&LangEntry> {
515        let token = token.trim().to_ascii_lowercase();
516        let token = match token.as_str() {
517            "c++" => "cpp",
518            "c#" | "csharp" => "cs",
519            "shell" => "bash",
520            "golang" => "go",
521            "terraform" => "hcl",
522            "docker" => "dockerfile",
523            "makefile" => "make",
524            "pwsh" | "ps" => "powershell",
525            other => other,
526        };
527        let &idx = self.by_name.get(token).or_else(|| self.by_ext.get(token))?;
528        self.entries.get(idx)
529    }
530
531    /// Highlight config for a tree-sitter injection language name (the inline
532    /// markdown grammar, or a fenced code block's language). `None` leaves the
533    /// injected region plain.
534    pub fn config_for_injection(&self, lang: &str) -> Option<&HighlightConfiguration> {
535        self.for_token(lang)?.config()
536    }
537
538    /// Inline markdown captures (emphasis, code spans, links) as byte range plus
539    /// the recognized highlight name, narrowest span first so a first-match
540    /// renderer picks the most specific. The inline grammar parses only the
541    /// block grammar's `(inline)` node ranges, so block markers stay untouched.
542    pub fn markdown_inline_spans(&self, content: &str) -> Vec<(Range<usize>, &'static str)> {
543        if content.len() > MAX_PARSE_BYTES {
544            return Vec::new();
545        }
546        let (Some(query), Some(block), Some(inline)) = (
547            self.markdown_inline_query.as_ref(),
548            self.by_name
549                .get("markdown")
550                .and_then(|&i| self.entries.get(i)),
551            self.by_name
552                .get("markdown_inline")
553                .and_then(|&i| self.entries.get(i)),
554        ) else {
555            return Vec::new();
556        };
557
558        let mut bp = Parser::new();
559        if bp.set_language(&block.language).is_err() {
560            return Vec::new();
561        }
562        let Some(block_tree) = bp.parse(content, None) else {
563            return Vec::new();
564        };
565        let ranges = inline_node_ranges(&block_tree);
566        if ranges.is_empty() {
567            return Vec::new();
568        }
569
570        let mut ip = Parser::new();
571        if ip.set_included_ranges(&ranges).is_err() || ip.set_language(&inline.language).is_err() {
572            return Vec::new();
573        }
574        let Some(inline_tree) = ip.parse(content, None) else {
575            return Vec::new();
576        };
577
578        let names = query.capture_names();
579        let mut cursor = QueryCursor::new();
580        let mut spans = Vec::new();
581        let mut matches = cursor.matches(query, inline_tree.root_node(), content.as_bytes());
582        while let Some(m) = matches.next() {
583            for cap in m.captures {
584                let cname = names.get(cap.index as usize).copied().unwrap_or("");
585                if let Some(name) = recognized_highlight(cname) {
586                    spans.push((cap.node.byte_range(), name));
587                }
588            }
589        }
590        spans.sort_by_key(|(r, _)| r.end - r.start);
591        spans
592    }
593}
594
595/// Byte ranges of every `(inline)` node in a markdown block tree, in document
596/// order. These are the regions the inline grammar reparses.
597fn inline_node_ranges(tree: &Tree) -> Vec<tree_sitter::Range> {
598    let mut ranges = Vec::new();
599    let mut cursor = tree.walk();
600    loop {
601        let node = cursor.node();
602        if node.kind() == "inline" && node.end_byte() > node.start_byte() {
603            ranges.push(node.range());
604        }
605        if cursor.goto_first_child() {
606            continue;
607        }
608        loop {
609            if cursor.goto_next_sibling() {
610                break;
611            }
612            if !cursor.goto_parent() {
613                return ranges;
614            }
615        }
616    }
617}
618
619/// The longest `HIGHLIGHT_NAMES` entry that is a dotted prefix of `capture`,
620/// matching how tree-sitter resolves capture names to recognized highlights.
621fn recognized_highlight(capture: &str) -> Option<&'static str> {
622    HIGHLIGHT_NAMES
623        .iter()
624        .copied()
625        .filter(|name| {
626            capture == *name
627                || capture
628                    .strip_prefix(name)
629                    .is_some_and(|r| r.starts_with('.'))
630        })
631        .max_by_key(|name| name.len())
632}
633
634impl Default for LanguageRegistry {
635    fn default() -> Self {
636        Self::build()
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    #[test]
643    fn exactly_the_layout_languages_are_layout_significant() {
644        let flagged: Vec<_> = super::REGISTRY
645            .entries
646            .iter()
647            .filter(|entry| entry.layout_significant)
648            .map(|entry| entry.name)
649            .collect();
650        assert_eq!(flagged, ["python", "yaml", "make", "scala", "haskell"]);
651    }
652}