Skip to main content

strop_syntax/
languages.rs

1//! The curated language registry (0002 §2.2: statically linked, always).
2//! All highlight queries are Helix-vendored (queries/ per language, MPL-2.0) —
3//! one upstream, one review surface, divergence watched weekly (0002 §7).
4//! Adding a language is one row here plus one crate in Cargo.toml — never a
5//! download, never a dlopen. C++ included specifically: its scanner is C++,
6//! the musl static-libstdc++ path the release gate guards (0002 §5).
7//!
8//! Detection for a file path goes: exact basename → extension → shebang
9//! (first line, only consulted when the extension is unknown or absent).
10//! `detect` is pure over `(path, first_line)` so callers own the one
11//! cheap read; `Highlighter::for_path` does exactly that.
12
13use std::path::Path;
14
15use tree_sitter::Language;
16
17pub struct LanguageSpec {
18    pub name: &'static str,
19    pub language: Language,
20    pub highlights: &'static str,
21}
22
23// tree-sitter-toml 0.20 targets an old ABI (a second tree-sitter in the
24// tree — not worth it); TOML joins when a 0.24-ABI grammar crate exists.
25macro_rules! lang_fn {
26    ($name:literal, $f:expr, $q:expr) => {
27        LanguageSpec {
28            name: $name,
29            language: $f.into(),
30            highlights: $q,
31        }
32    };
33}
34
35/// Extension (with dot) → spec. First match wins.
36pub fn for_extension(ext: &str) -> Option<LanguageSpec> {
37    Some(match ext {
38        ".rs" => lang_fn!(
39            "rust",
40            tree_sitter_rust::LANGUAGE,
41            include_str!("../queries/rust/highlights.scm")
42        ),
43        ".py" | ".pyi" => {
44            lang_fn!(
45                "python",
46                tree_sitter_python::LANGUAGE,
47                include_str!("../queries/python/highlights.scm")
48            )
49        }
50        ".js" | ".jsx" | ".mjs" | ".cjs" => {
51            lang_fn!(
52                "javascript",
53                tree_sitter_javascript::LANGUAGE,
54                include_str!("../queries/javascript/highlights.scm")
55            )
56        }
57        ".ts" => lang_fn!(
58            "typescript",
59            tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
60            include_str!("../queries/typescript/highlights.scm")
61        ),
62        ".tsx" => lang_fn!(
63            "tsx",
64            tree_sitter_typescript::LANGUAGE_TSX,
65            include_str!("../queries/tsx/highlights.scm")
66        ),
67        ".go" => lang_fn!(
68            "go",
69            tree_sitter_go::LANGUAGE,
70            include_str!("../queries/go/highlights.scm")
71        ),
72        ".c" | ".h" => lang_fn!(
73            "c",
74            tree_sitter_c::LANGUAGE,
75            include_str!("../queries/c/highlights.scm")
76        ),
77        ".cpp" | ".cc" | ".cxx" | ".hpp" | ".hh" => {
78            lang_fn!(
79                "cpp",
80                tree_sitter_cpp::LANGUAGE,
81                include_str!("../queries/cpp/highlights.scm")
82            )
83        }
84        ".json" => lang_fn!(
85            "json",
86            tree_sitter_json::LANGUAGE,
87            include_str!("../queries/json/highlights.scm")
88        ),
89        ".sh" | ".bash" => lang_fn!(
90            "bash",
91            tree_sitter_bash::LANGUAGE,
92            include_str!("../queries/bash/highlights.scm")
93        ),
94        ".fish" => lang_fn!(
95            "fish",
96            tree_sitter_fish::language(),
97            include_str!("../queries/fish/highlights.scm")
98        ),
99        ".lua" => lang_fn!(
100            "lua",
101            tree_sitter_lua::LANGUAGE,
102            include_str!("../queries/lua/highlights.scm")
103        ),
104        ".sql" => lang_fn!(
105            "sql",
106            tree_sitter_sequel::LANGUAGE,
107            include_str!("../queries/sql/highlights.scm")
108        ),
109        _ => return None,
110    })
111}
112
113/// Basenames that imply a language regardless of extension. Checked
114/// before the extension so dotfiles (`Path::extension` sees none for
115/// `.bashrc`) and Arch build scripts resolve.
116fn for_basename(name: &str) -> Option<LanguageSpec> {
117    (matches!(name, ".bashrc" | ".bash_profile" | ".profile" | "PKGBUILD")).then(|| {
118        lang_fn!(
119            "bash",
120            tree_sitter_bash::LANGUAGE,
121            include_str!("../queries/bash/highlights.scm")
122        )
123    })
124}
125
126/// Interpreter named by a shebang line → spec. Only shells live here;
127/// one row per language we actually ship a grammar for.
128fn for_interpreter(interp: &str) -> Option<LanguageSpec> {
129    Some(match interp {
130        "bash" | "sh" | "dash" | "zsh" => lang_fn!(
131            "bash",
132            tree_sitter_bash::LANGUAGE,
133            include_str!("../queries/bash/highlights.scm")
134        ),
135        "fish" => lang_fn!(
136            "fish",
137            tree_sitter_fish::language(),
138            include_str!("../queries/fish/highlights.scm")
139        ),
140        _ => return None,
141    })
142}
143
144/// First shebang token as a bare interpreter name: `#!/bin/bash` →
145/// `bash`, `#!/usr/bin/env -S fish -e` → `fish`. Returns `None` for
146/// anything that isn't a shebang line.
147pub fn interpreter_of(first_line: &str) -> Option<&str> {
148    let mut tokens = first_line.strip_prefix("#!")?.split_whitespace();
149    let program = tokens.next()?;
150    // `env` indirection: the interpreter is the next non-flag word
151    // (`-S`/`--split-string` and friends).
152    let program = if basename(program) == Some("env") {
153        tokens.find(|t| !t.starts_with('-'))?
154    } else {
155        program
156    };
157    basename(program)
158}
159
160fn basename(program: &str) -> Option<&str> {
161    Path::new(program)
162        .file_name()
163        .and_then(|n| n.to_str())
164        .filter(|n| !n.is_empty())
165}
166
167/// Shebang line → spec.
168pub fn for_shebang(first_line: &str) -> Option<LanguageSpec> {
169    for_interpreter(interpreter_of(first_line)?)
170}
171
172/// Path → spec, pure in `first_line`: exact basename first, then the
173/// extension table, then — only when the extension is unknown or
174/// absent — whatever the (already-read) first line shebangs to.
175pub fn detect(path: &str, first_line: Option<&str>) -> Option<LanguageSpec> {
176    let p = Path::new(path);
177    if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
178        if let Some(spec) = for_basename(name) {
179            return Some(spec);
180        }
181    }
182    let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy()));
183    if let Some(spec) = ext.as_deref().and_then(for_extension) {
184        return Some(spec);
185    }
186    first_line.and_then(for_shebang)
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn covers_the_curated_set() {
195        for ext in [
196            ".rs", ".py", ".js", ".ts", ".tsx", ".go", ".c", ".cpp", ".json", ".sh", ".fish",
197            ".lua", ".sql",
198        ] {
199            assert!(for_extension(ext).is_some(), "missing {ext}");
200        }
201        assert!(for_extension(".xyz").is_none());
202    }
203
204    #[test]
205    fn exact_filenames_beat_extension_and_shebang() {
206        for name in [".bashrc", ".bash_profile", ".profile", "PKGBUILD"] {
207            let spec = detect(&format!("/home/tarek/{name}"), None)
208                .unwrap_or_else(|| panic!("{name} unresolved"));
209            assert_eq!(spec.name, "bash", "{name}");
210        }
211        // exact basename wins even against a contradictory shebang
212        let spec = detect("/home/tarek/.bashrc", Some("#!/usr/bin/env fish\n")).unwrap();
213        assert_eq!(spec.name, "bash");
214        // "PKGBUILD.fish" is not an exact basename — extension rules
215        assert_eq!(detect("PKGBUILD.fish", None).unwrap().name, "fish");
216    }
217
218    #[test]
219    fn shebang_resolves_when_extension_unknown_or_absent() {
220        for (line, lang) in [
221            ("#!/bin/bash\n", "bash"),
222            ("#!/bin/bash -euo pipefail\n", "bash"),
223            ("#!/usr/bin/env bash\n", "bash"),
224            ("#!/usr/bin/env -S bash --norc\n", "bash"),
225            ("#!/bin/sh\n", "bash"),
226            ("#!/usr/bin/env zsh\n", "bash"),
227            ("#!/usr/bin/fish\n", "fish"),
228            ("#!/usr/bin/env fish\n", "fish"),
229        ] {
230            let spec = detect("some-script", Some(line))
231                .unwrap_or_else(|| panic!("unresolved shebang {line:?}"));
232            assert_eq!(spec.name, lang, "{line:?}");
233        }
234        // unknown extension still defers to the shebang
235        assert_eq!(
236            detect("weird.tool", Some("#!/bin/bash\n")).unwrap().name,
237            "bash"
238        );
239        // no shebang, no extension, no dice
240        assert!(detect("README", Some("# comment\n")).is_none());
241        assert!(detect("run.pl", Some("#!/usr/bin/perl\n")).is_none());
242        assert!(detect("empty", Some("")).is_none());
243    }
244
245    #[test]
246    fn known_extension_beats_shebang() {
247        let spec = detect("x.fish", Some("#!/bin/bash\n")).unwrap();
248        assert_eq!(spec.name, "fish");
249    }
250}