fabrica

hanna/fabrica

10034 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Language resolution (§8.1).
6//!
7//! Precedence, highest first: an explicit `.gitattributes` override
8//! (`fabrica-language` / `linguist-language`, resolved by the git layer and passed
9//! in), a shebang, a vim/emacs modeline, an exact filename, the longest matching
10//! extension, then plain text. Name matching is case-insensitive and defers to
11//! `inkjet`'s own token table (which already knows `rs`/`rust`, `py`/`python`, …),
12//! with a small alias table on top for names it lacks. An unknown name simply
13//! falls through rather than erroring.
14
15use std::path::Path;
16
17use inkjet::Language;
18
19/// A resolved language: either a concrete grammar with the token it was named by,
20/// or plain text. Opaque so callers never depend on `inkjet` directly.
21#[derive(Clone)]
22pub struct Lang {
23 language: Option<Language>,
24 name: Option<String>,
25}
26
27impl Lang {
28 /// The plain-text language (no highlighting).
29 #[must_use]
30 pub fn plain() -> Self {
31 Self {
32 language: None,
33 name: None,
34 }
35 }
36
37 /// Whether this is plain text (nothing to highlight).
38 #[must_use]
39 pub fn is_plain(&self) -> bool {
40 self.language.is_none()
41 }
42
43 /// The display name of the resolved language (the token it matched), if any.
44 #[must_use]
45 pub fn name(&self) -> Option<&str> {
46 self.name.as_deref()
47 }
48
49 /// The underlying grammar, for the highlighter.
50 pub(crate) fn language(&self) -> Option<Language> {
51 self.language
52 }
53
54 /// Build from a token, consulting fabrica's alias table then inkjet's.
55 fn from_token(token: &str) -> Option<Self> {
56 let token = token.trim().to_ascii_lowercase();
57 if token.is_empty() {
58 return None;
59 }
60 let canonical = ALIASES
61 .iter()
62 .find(|(alias, _)| *alias == token)
63 .map_or(token.as_str(), |(_, canonical)| *canonical);
64 Language::from_token(canonical).map(|language| Self {
65 language: Some(language),
66 name: Some(canonical.to_string()),
67 })
68 }
69}
70
71/// Aliases inkjet's own token table does not carry. Checked before inkjet.
72const ALIASES: &[(&str, &str)] = &[
73 ("terraform", "hcl"),
74 ("tf", "hcl"),
75 ("shell", "bash"),
76 ("sh", "bash"),
77 ("yml", "yaml"),
78 ("rs", "rust"),
79 ("md", "markdown"),
80];
81
82/// Exact filenames that name a language regardless of extension.
83const FILENAMES: &[(&str, &str)] = &[
84 ("dockerfile", "dockerfile"),
85 ("containerfile", "dockerfile"),
86 ("makefile", "make"),
87 ("gnumakefile", "make"),
88 ("cmakelists.txt", "cmake"),
89 ("flake.lock", "json"),
90 ("cargo.lock", "toml"),
91 ("go.mod", "gomod"),
92 ("go.sum", "gosum"),
93 ("pkgbuild", "bash"),
94 (".gitconfig", "gitconfig"),
95 (".gitattributes", "gitattributes"),
96 (".gitignore", "gitignore"),
97];
98
99/// Resolve the language of `path` given its `content` and any attribute override.
100///
101/// `attr_override` is the `fabrica-language`/`linguist-language` value the git
102/// attribute resolver produced (already in precedence order); an unrecognized name
103/// falls through to the content- and name-based steps rather than erroring.
104#[must_use]
105pub fn resolve(path: &Path, content: &[u8], attr_override: Option<&str>) -> Lang {
106 // 1–2. Attribute override.
107 if let Some(name) = attr_override
108 && let Some(lang) = Lang::from_token(name)
109 {
110 return lang;
111 }
112
113 let filename = path
114 .file_name()
115 .and_then(|n| n.to_str())
116 .unwrap_or_default();
117
118 // Text-derived hints (shebang, modeline) only make sense for textual content.
119 let text = std::str::from_utf8(content).ok();
120
121 // 4. Shebang.
122 if let Some(text) = text
123 && let Some(lang) = shebang_language(text)
124 {
125 return lang;
126 }
127
128 // 5. Modeline.
129 if let Some(text) = text
130 && let Some(lang) = modeline_language(text)
131 {
132 return lang;
133 }
134
135 // 6. Exact filename.
136 let lower = filename.to_ascii_lowercase();
137 if let Some((_, token)) = FILENAMES.iter().find(|(name, _)| *name == lower)
138 && let Some(lang) = Lang::from_token(token)
139 {
140 return lang;
141 }
142
143 // 7. Extension, longest compound first (`d.ts` before `ts`).
144 if let Some(lang) = extension_language(filename) {
145 return lang;
146 }
147
148 // 8. Plain text.
149 Lang::plain()
150}
151
152/// Try each dotted suffix of `filename` from longest to shortest as a language
153/// token: `archive.tar.gz` yields `tar.gz` then `gz`.
154fn extension_language(filename: &str) -> Option<Lang> {
155 let parts: Vec<&str> = filename.split('.').collect();
156 // Skip index 0 (the stem); each later index starts a candidate suffix.
157 for start in 1..parts.len() {
158 let candidate = parts[start..].join(".");
159 if let Some(lang) = Lang::from_token(&candidate) {
160 return Some(lang);
161 }
162 }
163 None
164}
165
166/// Extract a language from a `#!` first line, normalizing `env python3` → `python`.
167fn shebang_language(text: &str) -> Option<Lang> {
168 let first = text.lines().next()?;
169 let rest = first.strip_prefix("#!")?;
170 let mut words = rest.split_whitespace();
171 let mut interp = words.next()?;
172 // `/usr/bin/env python` → take the interpreter after `env`.
173 let base = interp.rsplit('/').next().unwrap_or(interp);
174 if base == "env" {
175 interp = words.next()?;
176 } else {
177 interp = base;
178 }
179 // Strip a trailing version suffix: `python3` → `python`, `ruby2.7` → `ruby`.
180 let name: String = interp
181 .trim_end_matches(|c: char| c.is_ascii_digit() || c == '.')
182 .to_string();
183 Lang::from_token(if name.is_empty() { interp } else { &name })
184}
185
186/// Extract a language from a vim (`vim: set ft=python:`) or emacs
187/// (`-*- mode: python -*-`) modeline in the first or last five lines.
188fn modeline_language(text: &str) -> Option<Lang> {
189 let lines: Vec<&str> = text.lines().collect();
190 let head = lines.iter().take(5);
191 let tail = lines.iter().rev().take(5);
192 for line in head.chain(tail) {
193 if let Some(lang) = parse_modeline(line) {
194 return Some(lang);
195 }
196 }
197 None
198}
199
200/// Parse one line for a vim `ft=`/`filetype=` or emacs `mode:` directive.
201fn parse_modeline(line: &str) -> Option<Lang> {
202 // vim: look for `ft=NAME` or `filetype=NAME`.
203 for key in ["filetype=", "ft="] {
204 if let Some(idx) = line.find(key) {
205 let after = &line[idx + key.len()..];
206 let value: String = after
207 .chars()
208 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
209 .collect();
210 if let Some(lang) = Lang::from_token(&value) {
211 return Some(lang);
212 }
213 }
214 }
215 // emacs: `-*- ... mode: NAME ... -*-`.
216 if line.contains("-*-")
217 && let Some(idx) = line.find("mode:")
218 {
219 let after = line[idx + "mode:".len()..].trim_start();
220 let value: String = after
221 .chars()
222 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
223 .collect();
224 if let Some(lang) = Lang::from_token(&value) {
225 return Some(lang);
226 }
227 }
228 None
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn name(path: &str, content: &str, over: Option<&str>) -> Option<String> {
236 resolve(Path::new(path), content.as_bytes(), over)
237 .name()
238 .map(str::to_string)
239 }
240
241 #[test]
242 fn extension_resolves_rust() {
243 assert_eq!(
244 name("src/main.rs", "fn main() {}", None).as_deref(),
245 Some("rust")
246 );
247 }
248
249 #[test]
250 fn attribute_override_beats_extension() {
251 // A `.rs` file forced to python by an attribute.
252 assert_eq!(
253 name("weird.rs", "fn main() {}", Some("python")).as_deref(),
254 Some("python")
255 );
256 }
257
258 #[test]
259 fn unknown_override_falls_through_to_extension() {
260 assert_eq!(
261 name("main.rs", "fn main() {}", Some("no-such-language")).as_deref(),
262 Some("rust")
263 );
264 }
265
266 #[test]
267 fn shebang_resolves_and_strips_version() {
268 assert_eq!(
269 name("script", "#!/usr/bin/env python3\nprint(1)\n", None).as_deref(),
270 Some("python")
271 );
272 assert_eq!(
273 name("run", "#!/bin/bash\necho hi\n", None).as_deref(),
274 Some("bash")
275 );
276 }
277
278 #[test]
279 fn modeline_vim_and_emacs() {
280 assert_eq!(
281 name("notes", "# vim: set ft=ruby:\nputs 1\n", None).as_deref(),
282 Some("ruby")
283 );
284 assert_eq!(
285 name("notes", "-*- mode: python -*-\nx = 1\n", None).as_deref(),
286 Some("python")
287 );
288 }
289
290 #[test]
291 fn exact_filenames() {
292 assert_eq!(
293 name("Dockerfile", "FROM x\n", None).as_deref(),
294 Some("dockerfile")
295 );
296 assert_eq!(
297 name("Cargo.lock", "[[package]]\n", None).as_deref(),
298 Some("toml")
299 );
300 assert_eq!(name("flake.lock", "{}\n", None).as_deref(), Some("json"));
301 }
302
303 #[test]
304 fn compound_extension_resolves_via_a_suffix() {
305 // `types.d.ts` resolves by trying `d.ts` then `ts`; either way it is a
306 // recognized language, not plain text.
307 let lang = resolve(Path::new("types.d.ts"), b"export {}\n", None);
308 assert!(!lang.is_plain());
309 }
310
311 #[test]
312 fn unknown_extension_is_plain() {
313 let lang = resolve(Path::new("data.zzz"), b"noise", None);
314 assert!(lang.is_plain());
315 assert_eq!(lang.name(), None);
316 }
317
318 #[test]
319 fn alias_table_maps_terraform() {
320 assert_eq!(
321 name("main.tf", "resource {}\n", None).as_deref(),
322 Some("hcl")
323 );
324 }
325}