// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Language resolution (§8.1). //! //! Precedence, highest first: an explicit `.gitattributes` override //! (`fabrica-language` / `linguist-language`, resolved by the git layer and passed //! in), a shebang, a vim/emacs modeline, an exact filename, the longest matching //! extension, then plain text. Name matching is case-insensitive and defers to //! `inkjet`'s own token table (which already knows `rs`/`rust`, `py`/`python`, …), //! with a small alias table on top for names it lacks. An unknown name simply //! falls through rather than erroring. use std::path::Path; use inkjet::Language; /// A resolved language: either a concrete grammar with the token it was named by, /// or plain text. Opaque so callers never depend on `inkjet` directly. #[derive(Clone)] pub struct Lang { language: Option, name: Option, } impl Lang { /// The plain-text language (no highlighting). #[must_use] pub fn plain() -> Self { Self { language: None, name: None, } } /// Whether this is plain text (nothing to highlight). #[must_use] pub fn is_plain(&self) -> bool { self.language.is_none() } /// The display name of the resolved language (the token it matched), if any. #[must_use] pub fn name(&self) -> Option<&str> { self.name.as_deref() } /// The underlying grammar, for the highlighter. pub(crate) fn language(&self) -> Option { self.language } /// Build from a token, consulting fabrica's alias table then inkjet's. fn from_token(token: &str) -> Option { let token = token.trim().to_ascii_lowercase(); if token.is_empty() { return None; } let canonical = ALIASES .iter() .find(|(alias, _)| *alias == token) .map_or(token.as_str(), |(_, canonical)| *canonical); Language::from_token(canonical).map(|language| Self { language: Some(language), name: Some(canonical.to_string()), }) } } /// Aliases inkjet's own token table does not carry. Checked before inkjet. const ALIASES: &[(&str, &str)] = &[ ("terraform", "hcl"), ("tf", "hcl"), ("shell", "bash"), ("sh", "bash"), ("yml", "yaml"), ("rs", "rust"), ("md", "markdown"), ]; /// Exact filenames that name a language regardless of extension. const FILENAMES: &[(&str, &str)] = &[ ("dockerfile", "dockerfile"), ("containerfile", "dockerfile"), ("makefile", "make"), ("gnumakefile", "make"), ("cmakelists.txt", "cmake"), ("flake.lock", "json"), ("cargo.lock", "toml"), ("go.mod", "gomod"), ("go.sum", "gosum"), ("pkgbuild", "bash"), (".gitconfig", "gitconfig"), (".gitattributes", "gitattributes"), (".gitignore", "gitignore"), ]; /// Resolve the language of `path` given its `content` and any attribute override. /// /// `attr_override` is the `fabrica-language`/`linguist-language` value the git /// attribute resolver produced (already in precedence order); an unrecognized name /// falls through to the content- and name-based steps rather than erroring. #[must_use] pub fn resolve(path: &Path, content: &[u8], attr_override: Option<&str>) -> Lang { // 1–2. Attribute override. if let Some(name) = attr_override && let Some(lang) = Lang::from_token(name) { return lang; } let filename = path .file_name() .and_then(|n| n.to_str()) .unwrap_or_default(); // Text-derived hints (shebang, modeline) only make sense for textual content. let text = std::str::from_utf8(content).ok(); // 4. Shebang. if let Some(text) = text && let Some(lang) = shebang_language(text) { return lang; } // 5. Modeline. if let Some(text) = text && let Some(lang) = modeline_language(text) { return lang; } // 6. Exact filename. let lower = filename.to_ascii_lowercase(); if let Some((_, token)) = FILENAMES.iter().find(|(name, _)| *name == lower) && let Some(lang) = Lang::from_token(token) { return lang; } // 7. Extension, longest compound first (`d.ts` before `ts`). if let Some(lang) = extension_language(filename) { return lang; } // 8. Plain text. Lang::plain() } /// Try each dotted suffix of `filename` from longest to shortest as a language /// token: `archive.tar.gz` yields `tar.gz` then `gz`. fn extension_language(filename: &str) -> Option { let parts: Vec<&str> = filename.split('.').collect(); // Skip index 0 (the stem); each later index starts a candidate suffix. for start in 1..parts.len() { let candidate = parts[start..].join("."); if let Some(lang) = Lang::from_token(&candidate) { return Some(lang); } } None } /// Extract a language from a `#!` first line, normalizing `env python3` → `python`. fn shebang_language(text: &str) -> Option { let first = text.lines().next()?; let rest = first.strip_prefix("#!")?; let mut words = rest.split_whitespace(); let mut interp = words.next()?; // `/usr/bin/env python` → take the interpreter after `env`. let base = interp.rsplit('/').next().unwrap_or(interp); if base == "env" { interp = words.next()?; } else { interp = base; } // Strip a trailing version suffix: `python3` → `python`, `ruby2.7` → `ruby`. let name: String = interp .trim_end_matches(|c: char| c.is_ascii_digit() || c == '.') .to_string(); Lang::from_token(if name.is_empty() { interp } else { &name }) } /// Extract a language from a vim (`vim: set ft=python:`) or emacs /// (`-*- mode: python -*-`) modeline in the first or last five lines. fn modeline_language(text: &str) -> Option { let lines: Vec<&str> = text.lines().collect(); let head = lines.iter().take(5); let tail = lines.iter().rev().take(5); for line in head.chain(tail) { if let Some(lang) = parse_modeline(line) { return Some(lang); } } None } /// Parse one line for a vim `ft=`/`filetype=` or emacs `mode:` directive. fn parse_modeline(line: &str) -> Option { // vim: look for `ft=NAME` or `filetype=NAME`. for key in ["filetype=", "ft="] { if let Some(idx) = line.find(key) { let after = &line[idx + key.len()..]; let value: String = after .chars() .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') .collect(); if let Some(lang) = Lang::from_token(&value) { return Some(lang); } } } // emacs: `-*- ... mode: NAME ... -*-`. if line.contains("-*-") && let Some(idx) = line.find("mode:") { let after = line[idx + "mode:".len()..].trim_start(); let value: String = after .chars() .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') .collect(); if let Some(lang) = Lang::from_token(&value) { return Some(lang); } } None } #[cfg(test)] mod tests { use super::*; fn name(path: &str, content: &str, over: Option<&str>) -> Option { resolve(Path::new(path), content.as_bytes(), over) .name() .map(str::to_string) } #[test] fn extension_resolves_rust() { assert_eq!( name("src/main.rs", "fn main() {}", None).as_deref(), Some("rust") ); } #[test] fn attribute_override_beats_extension() { // A `.rs` file forced to python by an attribute. assert_eq!( name("weird.rs", "fn main() {}", Some("python")).as_deref(), Some("python") ); } #[test] fn unknown_override_falls_through_to_extension() { assert_eq!( name("main.rs", "fn main() {}", Some("no-such-language")).as_deref(), Some("rust") ); } #[test] fn shebang_resolves_and_strips_version() { assert_eq!( name("script", "#!/usr/bin/env python3\nprint(1)\n", None).as_deref(), Some("python") ); assert_eq!( name("run", "#!/bin/bash\necho hi\n", None).as_deref(), Some("bash") ); } #[test] fn modeline_vim_and_emacs() { assert_eq!( name("notes", "# vim: set ft=ruby:\nputs 1\n", None).as_deref(), Some("ruby") ); assert_eq!( name("notes", "-*- mode: python -*-\nx = 1\n", None).as_deref(), Some("python") ); } #[test] fn exact_filenames() { assert_eq!( name("Dockerfile", "FROM x\n", None).as_deref(), Some("dockerfile") ); assert_eq!( name("Cargo.lock", "[[package]]\n", None).as_deref(), Some("toml") ); assert_eq!(name("flake.lock", "{}\n", None).as_deref(), Some("json")); } #[test] fn compound_extension_resolves_via_a_suffix() { // `types.d.ts` resolves by trying `d.ts` then `ts`; either way it is a // recognized language, not plain text. let lang = resolve(Path::new("types.d.ts"), b"export {}\n", None); assert!(!lang.is_plain()); } #[test] fn unknown_extension_is_plain() { let lang = resolve(Path::new("data.zzz"), b"noise", None); assert!(lang.is_plain()); assert_eq!(lang.name(), None); } #[test] fn alias_table_maps_terraform() { assert_eq!( name("main.tf", "resource {}\n", None).as_deref(), Some("hcl") ); } }