Skip to main content

diffler_core/syntax/
registry.rs

1//! Maps a file path to its tree-sitter grammar, highlight configuration and
2//! tags query.
3
4use std::borrow::Cow;
5use std::collections::HashMap;
6use std::ops::Range;
7use std::path::Path;
8use std::sync::{LazyLock, OnceLock};
9
10use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator, Tree};
11use tree_sitter_highlight::HighlightConfiguration;
12
13use crate::syntax::MAX_PARSE_BYTES;
14
15/// A grammar capture like `function.method` resolves to its longest prefix
16/// here, so general categories color every grammar.
17pub const HIGHLIGHT_NAMES: &[&str] = &[
18    "attribute",
19    "boolean",
20    "comment",
21    "conditional",
22    "constant",
23    "constant.builtin",
24    "constructor",
25    "field",
26    "function",
27    "function.builtin",
28    "keyword",
29    "label",
30    "number",
31    "operator",
32    "parameter",
33    "property",
34    "punctuation",
35    "punctuation.bracket",
36    "punctuation.delimiter",
37    "spell",
38    "storageclass",
39    "string",
40    "string.escape",
41    "string.special",
42    "tag",
43    "text.emphasis",
44    "text.literal",
45    "text.reference",
46    "text.strong",
47    "text.title",
48    "text.uri",
49    "type",
50    "type.builtin",
51    "variable",
52    "variable.builtin",
53    "variable.parameter",
54];
55
56pub struct LangEntry {
57    pub name: &'static str,
58    pub language: Language,
59    highlights: Cow<'static, str>,
60    injections: Cow<'static, str>,
61    tags_query: Option<Cow<'static, str>>,
62    /// Indentation is syntax here, so a re-indent is never a reformat.
63    pub layout_significant: bool,
64    /// Compiling a query costs ~15ms, so we compile on first use. `None` when
65    /// the query fails to compile, and the file renders plain.
66    config: OnceLock<Option<HighlightConfiguration>>,
67    tags: OnceLock<Option<Query>>,
68}
69
70impl LangEntry {
71    pub fn config(&self) -> Option<&HighlightConfiguration> {
72        self.config
73            .get_or_init(|| {
74                HighlightConfiguration::new(
75                    self.language.clone(),
76                    self.name,
77                    &self.highlights,
78                    &self.injections,
79                    "",
80                )
81                .ok()
82                .map(|mut config| {
83                    config.configure(HIGHLIGHT_NAMES);
84                    config
85                })
86            })
87            .as_ref()
88    }
89
90    pub fn tags(&self) -> Option<&Query> {
91        self.tags
92            .get_or_init(|| Query::new(&self.language, self.tags_query.as_deref()?).ok())
93            .as_ref()
94    }
95}
96
97pub static REGISTRY: LazyLock<LanguageRegistry> = LazyLock::new(LanguageRegistry::build);
98
99/// Suffixes after a file's real extension, as in `config.yml.example`.
100const TEMPLATE_SUFFIXES: &[&str] = &[
101    "example", "sample", "template", "tmpl", "tpl", "dist", "default", "local", "orig", "bak",
102];
103
104pub struct LanguageRegistry {
105    entries: Vec<LangEntry>,
106    by_ext: HashMap<&'static str, usize>,
107    by_name: HashMap<&'static str, usize>,
108    by_filename: HashMap<&'static str, usize>,
109    /// We apply this by hand over the block grammar's `(inline)` nodes, since
110    /// tree-sitter's injection does not drive the split markdown grammar.
111    markdown_inline_query: Option<Query>,
112}
113
114impl LanguageRegistry {
115    // flat per-language registration table
116    #[allow(clippy::too_many_lines)]
117    pub fn build() -> Self {
118        let mut r = Self {
119            entries: Vec::new(),
120            by_ext: HashMap::new(),
121            by_name: HashMap::new(),
122            by_filename: HashMap::new(),
123            markdown_inline_query: None,
124        };
125
126        r.add(
127            "rust",
128            &["rs"],
129            tree_sitter_rust::LANGUAGE.into(),
130            tree_sitter_rust::HIGHLIGHTS_QUERY,
131            Some(tree_sitter_rust::TAGS_QUERY),
132        );
133        r.add(
134            "python",
135            &["py", "pyi"],
136            tree_sitter_python::LANGUAGE.into(),
137            tree_sitter_python::HIGHLIGHTS_QUERY,
138            Some(tree_sitter_python::TAGS_QUERY),
139        );
140        r.layout_significant();
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            None,
162        );
163        r.tags_over_javascript(tree_sitter_typescript::TAGS_QUERY);
164        r.add(
165            "tsx",
166            &["tsx"],
167            tree_sitter_typescript::LANGUAGE_TSX.into(),
168            format!(
169                "{}\n{}\n{}",
170                tree_sitter_javascript::HIGHLIGHT_QUERY,
171                tree_sitter_javascript::JSX_HIGHLIGHT_QUERY,
172                tree_sitter_typescript::HIGHLIGHTS_QUERY
173            ),
174            None,
175        );
176        r.tags_over_javascript(tree_sitter_typescript::TAGS_QUERY);
177        r.add(
178            "go",
179            &["go"],
180            tree_sitter_go::LANGUAGE.into(),
181            tree_sitter_go::HIGHLIGHTS_QUERY,
182            Some(tree_sitter_go::TAGS_QUERY),
183        );
184        r.add(
185            "c",
186            &["c", "h"],
187            tree_sitter_c::LANGUAGE.into(),
188            tree_sitter_c::HIGHLIGHT_QUERY,
189            Some(tree_sitter_c::TAGS_QUERY),
190        );
191        r.add(
192            "cpp",
193            &["cpp", "cc", "cxx", "hpp", "hh", "hxx"],
194            tree_sitter_cpp::LANGUAGE.into(),
195            // the C++ query extends C's; alone it matches only C++ constructs
196            format!(
197                "{}\n{}",
198                tree_sitter_c::HIGHLIGHT_QUERY,
199                tree_sitter_cpp::HIGHLIGHT_QUERY
200            ),
201            Some(tree_sitter_cpp::TAGS_QUERY),
202        );
203        r.add(
204            "java",
205            &["java"],
206            tree_sitter_java::LANGUAGE.into(),
207            tree_sitter_java::HIGHLIGHTS_QUERY,
208            Some(tree_sitter_java::TAGS_QUERY),
209        );
210        r.add(
211            "c-sharp",
212            &["cs"],
213            tree_sitter_c_sharp::LANGUAGE.into(),
214            tree_sitter_c_sharp::HIGHLIGHTS_QUERY,
215            Some(tree_sitter_c_sharp::TAGS_QUERY),
216        );
217        r.add(
218            "ruby",
219            &["rb"],
220            tree_sitter_ruby::LANGUAGE.into(),
221            tree_sitter_ruby::HIGHLIGHTS_QUERY,
222            Some(tree_sitter_ruby::TAGS_QUERY),
223        );
224        r.add(
225            "php",
226            &["php"],
227            tree_sitter_php::LANGUAGE_PHP.into(),
228            tree_sitter_php::HIGHLIGHTS_QUERY,
229            Some(tree_sitter_php::TAGS_QUERY),
230        );
231        r.add(
232            "bash",
233            &["sh", "bash", "zsh", "env"],
234            tree_sitter_bash::LANGUAGE.into(),
235            tree_sitter_bash::HIGHLIGHT_QUERY,
236            None,
237        );
238        r.add(
239            "json",
240            &["json"],
241            tree_sitter_json::LANGUAGE.into(),
242            tree_sitter_json::HIGHLIGHTS_QUERY,
243            None,
244        );
245        r.register(
246            "html",
247            &["html", "htm"],
248            tree_sitter_html::LANGUAGE.into(),
249            tree_sitter_html::HIGHLIGHTS_QUERY,
250            tree_sitter_html::INJECTIONS_QUERY,
251            None,
252        );
253        r.add(
254            "css",
255            &["css"],
256            tree_sitter_css::LANGUAGE.into(),
257            tree_sitter_css::HIGHLIGHTS_QUERY,
258            None,
259        );
260        r.add(
261            "yaml",
262            &["yml", "yaml"],
263            tree_sitter_yaml::LANGUAGE.into(),
264            tree_sitter_yaml::HIGHLIGHTS_QUERY,
265            None,
266        );
267        r.layout_significant();
268        // the grammar guards its number patterns with Lua-style `%d`
269        // predicates tree-sitter never matches, so we append a pattern that
270        // wins over them
271        r.add(
272            "sql",
273            &["sql"],
274            tree_sitter_sequel::LANGUAGE.into(),
275            format!(
276                "{}\n((literal) @number (#match? @number \"^[-+]?[0-9][0-9.]*$\"))\n",
277                tree_sitter_sequel::HIGHLIGHTS_QUERY
278            ),
279            None,
280        );
281        // inline emphasis, code spans and links come from markdown_inline_spans
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.layout_significant();
332        r.add(
333            "lua",
334            &["lua"],
335            tree_sitter_lua::LANGUAGE.into(),
336            tree_sitter_lua::HIGHLIGHTS_QUERY,
337            Some(tree_sitter_lua::TAGS_QUERY),
338        );
339        r.add(
340            "nix",
341            &["nix"],
342            tree_sitter_nix::LANGUAGE.into(),
343            tree_sitter_nix::HIGHLIGHTS_QUERY,
344            None,
345        );
346        r.add(
347            "xml",
348            &["xml", "xsd", "xslt", "svg"],
349            tree_sitter_xml::LANGUAGE_XML.into(),
350            tree_sitter_xml::XML_HIGHLIGHT_QUERY,
351            None,
352        );
353        r.add(
354            "swift",
355            &["swift"],
356            tree_sitter_swift::LANGUAGE.into(),
357            tree_sitter_swift::HIGHLIGHTS_QUERY,
358            Some(tree_sitter_swift::TAGS_QUERY),
359        );
360        r.add(
361            "scala",
362            &["scala", "sbt"],
363            tree_sitter_scala::LANGUAGE.into(),
364            tree_sitter_scala::HIGHLIGHTS_QUERY,
365            None,
366        );
367        r.layout_significant();
368        r.add(
369            "elixir",
370            &["ex", "exs"],
371            tree_sitter_elixir::LANGUAGE.into(),
372            tree_sitter_elixir::HIGHLIGHTS_QUERY,
373            Some(tree_sitter_elixir::TAGS_QUERY),
374        );
375        r.add(
376            "zig",
377            &["zig", "zon"],
378            tree_sitter_zig::LANGUAGE.into(),
379            tree_sitter_zig::HIGHLIGHTS_QUERY,
380            None,
381        );
382        r.add(
383            "haskell",
384            &["hs"],
385            tree_sitter_haskell::LANGUAGE.into(),
386            tree_sitter_haskell::HIGHLIGHTS_QUERY,
387            None,
388        );
389        r.layout_significant();
390        r.add(
391            "dart",
392            &["dart"],
393            tree_sitter_dart::LANGUAGE.into(),
394            tree_sitter_dart::HIGHLIGHTS_QUERY,
395            Some(tree_sitter_dart::TAGS_QUERY),
396        );
397        r.add(
398            "powershell",
399            &["ps1", "psm1", "psd1"],
400            tree_sitter_powershell::LANGUAGE.into(),
401            tree_sitter_powershell::HIGHLIGHTS_QUERY,
402            None,
403        );
404        r.register(
405            "svelte",
406            &["svelte"],
407            tree_sitter_svelte_ng::LANGUAGE.into(),
408            // the svelte query extends html's, and its injections carry
409            // `<script>` and `<style>` into the js and css grammars
410            format!(
411                "{}\n{}",
412                tree_sitter_html::HIGHLIGHTS_QUERY,
413                tree_sitter_svelte_ng::HIGHLIGHTS_QUERY
414            ),
415            tree_sitter_svelte_ng::INJECTIONS_QUERY,
416            None,
417        );
418        r.name_files("make", &["makefile", "gnumakefile"]);
419        r.name_files("dockerfile", &["dockerfile", "containerfile"]);
420        r.name_files(
421            "bash",
422            &[
423                ".env",
424                ".envrc",
425                ".bashrc",
426                ".bash_profile",
427                ".bash_aliases",
428                ".bash_logout",
429                ".profile",
430                ".zshrc",
431                ".zprofile",
432                ".zshenv",
433                ".zlogin",
434            ],
435        );
436
437        r
438    }
439
440    fn name_files(&mut self, name: &'static str, filenames: &'static [&'static str]) {
441        let Some(&idx) = self.by_name.get(name) else {
442            return;
443        };
444        for file in filenames {
445            self.by_filename.insert(file, idx);
446        }
447    }
448
449    fn add(
450        &mut self,
451        name: &'static str,
452        extensions: &'static [&'static str],
453        language: Language,
454        highlights: impl Into<Cow<'static, str>>,
455        tags: Option<&'static str>,
456    ) {
457        self.register(name, extensions, language, highlights, "", tags);
458    }
459
460    fn register(
461        &mut self,
462        name: &'static str,
463        extensions: &'static [&'static str],
464        language: Language,
465        highlights: impl Into<Cow<'static, str>>,
466        injections: impl Into<Cow<'static, str>>,
467        tags: Option<&'static str>,
468    ) {
469        let idx = self.entries.len();
470        self.entries.push(LangEntry {
471            name,
472            language,
473            highlights: highlights.into(),
474            injections: injections.into(),
475            tags_query: tags.map(Cow::Borrowed),
476            layout_significant: false,
477            config: OnceLock::new(),
478            tags: OnceLock::new(),
479        });
480        self.by_name.insert(name, idx);
481        for ext in extensions {
482            self.by_ext.insert(ext, idx);
483        }
484    }
485
486    /// Applies to the language registered last.
487    fn layout_significant(&mut self) {
488        if let Some(entry) = self.entries.last_mut() {
489            entry.layout_significant = true;
490        }
491    }
492
493    /// TypeScript's tags query covers only what it adds to JavaScript, so we
494    /// prepend JavaScript's to the language registered last.
495    fn tags_over_javascript(&mut self, tags: &'static str) {
496        if let Some(entry) = self.entries.last_mut() {
497            entry.tags_query = Some(Cow::Owned(format!(
498                "{}\n{tags}",
499                tree_sitter_javascript::TAGS_QUERY
500            )));
501        }
502    }
503
504    /// By exact file name, then extension.
505    pub fn for_path(&self, path: &str) -> Option<&LangEntry> {
506        let name = Path::new(path).file_name()?.to_str()?.to_ascii_lowercase();
507        if let Some(&idx) = self.by_filename.get(name.as_str()) {
508            return self.entries.get(idx);
509        }
510        let ext = Path::new(path).extension()?.to_str()?;
511        let &idx = self.by_ext.get(ext)?;
512        self.entries.get(idx)
513    }
514
515    /// [`Self::for_path`], then a naming convention, then the `#!` line.
516    pub fn for_file(&self, path: &str, content: &str) -> Option<&LangEntry> {
517        self.for_path(path)
518            .or_else(|| {
519                let name = Path::new(path).file_name()?.to_str()?;
520                self.by_convention(&name.to_ascii_lowercase())
521            })
522            .or_else(|| self.by_name(shebang_language(content.lines().next()?)?))
523    }
524
525    /// A known name with a suffix (`dockerfile.prod`, `.env.local`), or a
526    /// template suffix (`config.yml.example`). `name` is lowercase.
527    fn by_convention(&self, name: &str) -> Option<&LangEntry> {
528        let prefix = name
529            .get(1..)
530            .and_then(|rest| rest.find('.'))
531            .and_then(|dot| name.get(..=dot));
532        if let Some(&idx) = prefix.and_then(|prefix| self.by_filename.get(prefix)) {
533            return self.entries.get(idx);
534        }
535        let (stem, suffix) = name.rsplit_once('.')?;
536        if !TEMPLATE_SUFFIXES.contains(&suffix) {
537            return None;
538        }
539        self.for_path(stem).or_else(|| self.by_convention(stem))
540    }
541
542    pub fn by_name(&self, name: &str) -> Option<&LangEntry> {
543        let &idx = self.by_name.get(name)?;
544        self.entries.get(idx)
545    }
546
547    pub fn names(&self) -> Vec<&'static str> {
548        let mut names: Vec<&'static str> = self.entries.iter().map(|entry| entry.name).collect();
549        names.sort_unstable();
550        names
551    }
552
553    /// A markdown fence token, by grammar name then extension.
554    pub fn for_token(&self, token: &str) -> Option<&LangEntry> {
555        let token = token.trim().to_ascii_lowercase();
556        let token = match token.as_str() {
557            "c++" => "cpp",
558            "c#" | "csharp" => "cs",
559            "shell" => "bash",
560            "golang" => "go",
561            "terraform" => "hcl",
562            "docker" => "dockerfile",
563            "makefile" => "make",
564            "pwsh" | "ps" => "powershell",
565            other => other,
566        };
567        let &idx = self.by_name.get(token).or_else(|| self.by_ext.get(token))?;
568        self.entries.get(idx)
569    }
570
571    pub fn config_for_injection(&self, lang: &str) -> Option<&HighlightConfiguration> {
572        self.for_token(lang)?.config()
573    }
574
575    /// Inline markdown captures with their highlight name, narrowest first so
576    /// a first-match renderer picks the most specific.
577    pub fn markdown_inline_spans(&self, content: &str) -> Vec<(Range<usize>, &'static str)> {
578        if content.len() > MAX_PARSE_BYTES {
579            return Vec::new();
580        }
581        let (Some(query), Some(block), Some(inline)) = (
582            self.markdown_inline_query.as_ref(),
583            self.by_name
584                .get("markdown")
585                .and_then(|&i| self.entries.get(i)),
586            self.by_name
587                .get("markdown_inline")
588                .and_then(|&i| self.entries.get(i)),
589        ) else {
590            return Vec::new();
591        };
592
593        let mut bp = Parser::new();
594        if bp.set_language(&block.language).is_err() {
595            return Vec::new();
596        }
597        let Some(block_tree) = bp.parse(content, None) else {
598            return Vec::new();
599        };
600        let ranges = inline_node_ranges(&block_tree);
601        if ranges.is_empty() {
602            return Vec::new();
603        }
604
605        let mut ip = Parser::new();
606        if ip.set_included_ranges(&ranges).is_err() || ip.set_language(&inline.language).is_err() {
607            return Vec::new();
608        }
609        let Some(inline_tree) = ip.parse(content, None) else {
610            return Vec::new();
611        };
612
613        let names = query.capture_names();
614        let mut cursor = QueryCursor::new();
615        let mut spans = Vec::new();
616        let mut matches = cursor.matches(query, inline_tree.root_node(), content.as_bytes());
617        while let Some(m) = matches.next() {
618            for cap in m.captures {
619                let cname = names.get(cap.index as usize).copied().unwrap_or("");
620                if let Some(name) = recognized_highlight(cname) {
621                    spans.push((cap.node.byte_range(), name));
622                }
623            }
624        }
625        spans.sort_by_key(|(r, _)| r.end - r.start);
626        spans
627    }
628}
629
630fn inline_node_ranges(tree: &Tree) -> Vec<tree_sitter::Range> {
631    let mut ranges = Vec::new();
632    let mut cursor = tree.walk();
633    loop {
634        let node = cursor.node();
635        if node.kind() == "inline" && node.end_byte() > node.start_byte() {
636            ranges.push(node.range());
637        }
638        if cursor.goto_first_child() {
639            continue;
640        }
641        loop {
642            if cursor.goto_next_sibling() {
643                break;
644            }
645            if !cursor.goto_parent() {
646                return ranges;
647            }
648        }
649    }
650}
651
652/// The longest `HIGHLIGHT_NAMES` entry that is a dotted prefix of `capture`,
653/// the way tree-sitter resolves it.
654fn recognized_highlight(capture: &str) -> Option<&'static str> {
655    HIGHLIGHT_NAMES
656        .iter()
657        .copied()
658        .filter(|name| {
659            capture == *name
660                || capture
661                    .strip_prefix(name)
662                    .is_some_and(|r| r.starts_with('.'))
663        })
664        .max_by_key(|name| name.len())
665}
666
667impl Default for LanguageRegistry {
668    fn default() -> Self {
669        Self::build()
670    }
671}
672
673fn shebang_language(line: &str) -> Option<&'static str> {
674    let command = line.strip_prefix("#!")?.trim();
675    let mut words = command.split_whitespace();
676    let program = words.next()?;
677    let program = if program.ends_with("/env") {
678        words.find(|word| !word.starts_with('-'))?
679    } else {
680        program
681    };
682    let name = program.rsplit('/').next()?;
683    let name = name.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.');
684    let lang = match name {
685        "python" | "pypy" => "python",
686        "bash" | "sh" | "zsh" | "dash" | "ksh" | "ash" => "bash",
687        "node" | "nodejs" | "deno" | "bun" => "javascript",
688        "ruby" => "ruby",
689        "php" => "php",
690        "lua" | "luajit" => "lua",
691        "pwsh" => "powershell",
692        "elixir" => "elixir",
693        _ => return None,
694    };
695    Some(lang)
696}
697
698#[cfg(test)]
699mod tests {
700    use super::REGISTRY;
701
702    fn lang(path: &str) -> Option<&'static str> {
703        REGISTRY.for_file(path, "").map(|entry| entry.name)
704    }
705
706    #[test]
707    fn convention_names_pick_their_grammar() {
708        assert_eq!(lang("deploy/Dockerfile.prod"), Some("dockerfile"));
709        assert_eq!(lang("api.dockerfile"), Some("dockerfile"));
710        assert_eq!(lang(".env"), Some("bash"));
711        assert_eq!(lang("app/.env.example"), Some("bash"));
712        assert_eq!(lang("Makefile.local"), Some("make"));
713        assert_eq!(lang(".zshrc"), Some("bash"));
714    }
715
716    #[test]
717    fn a_template_suffix_falls_back_to_the_real_extension() {
718        assert_eq!(lang("config.yml.example"), Some("yaml"));
719        assert_eq!(lang("settings.json.dist"), Some("json"));
720        assert_eq!(lang("notes.example"), None, "nothing under the suffix");
721        assert_eq!(lang(".env.example"), Some("bash"));
722        assert_eq!(
723            REGISTRY.for_path("config.yml.example").map(|e| e.name),
724            None,
725            "the file kinds and Stats read the name and extension alone"
726        );
727    }
728
729    #[test]
730    fn a_shebang_names_the_language_of_a_script_with_no_extension() {
731        let script = "#!/usr/bin/env python3\nprint(1)\n";
732        assert_eq!(
733            REGISTRY.for_file("bin/tool", script).map(|e| e.name),
734            Some("python")
735        );
736        let shell = "#!/bin/sh -e\necho hi\n";
737        assert_eq!(
738            REGISTRY.for_file("run", shell).map(|e| e.name),
739            Some("bash")
740        );
741        assert_eq!(
742            REGISTRY.for_file("README", "plain text\n").map(|e| e.name),
743            None
744        );
745    }
746
747    #[test]
748    fn every_interpreter_a_shebang_names_is_a_bundled_grammar() {
749        let inferred = [
750            "python", "sh", "node", "ruby", "php", "lua", "pwsh", "elixir",
751        ]
752        .into_iter()
753        .filter_map(|program| super::shebang_language(&format!("#!/bin/{program}")));
754        for name in inferred {
755            assert!(REGISTRY.by_name(name).is_some(), "{name} is no grammar");
756        }
757    }
758
759    #[test]
760    fn exactly_the_layout_languages_are_layout_significant() {
761        let flagged: Vec<_> = super::REGISTRY
762            .entries
763            .iter()
764            .filter(|entry| entry.layout_significant)
765            .map(|entry| entry.name)
766            .collect();
767        assert_eq!(flagged, ["python", "yaml", "make", "scala", "haskell"]);
768    }
769}