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