use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::sync::LazyLock;
use typed_path::Utf8WindowsPath;
pub use available_languages as available;
#[must_use]
pub fn guess(name: Option<&str>, source: &str) -> Language {
Language::guess(name, source)
}
#[must_use]
pub fn get(name: &str) -> Option<LanguageInfo> {
name.parse::<Language>()
.ok()
.map(|language| language.info())
}
macro_rules! define_languages {
(
always {
$(
$a_variant:ident {
id: $a_id:expr,
name: $a_name:expr,
parser: $a_parser:expr,
from_str: [$($a_str:expr),* $(,)?],
globs: [$($a_glob:expr),* $(,)?],
emacs: [$($a_emacs:expr),* $(,)?],
shebang: [$($a_shebang:expr),* $(,)?]
}
),* $(,)?
}
gated {
$(
[$g_feat:expr] $g_variant:ident {
id: $g_id:expr,
name: $g_name:expr,
parser: $g_parser:expr,
from_str: [$($g_str:expr),* $(,)?],
globs: [$($g_glob:expr),* $(,)?],
emacs: [$($g_emacs:expr),* $(,)?],
shebang: [$($g_shebang:expr),* $(,)?]
}
),* $(,)?
}
) => {
static FILENAME_GLOBS: &[(&str, &[&str])] = &[
$(($a_id, &[$($a_glob),*]),)*
$(($g_id, &[$($g_glob),*]),)*
];
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Language {
$(
$a_variant,
)*
$(
#[cfg(feature = $g_feat)]
$g_variant,
)*
}
impl Default for Language {
fn default() -> Self {
Language::PlainText
}
}
impl Language {
pub fn iter() -> impl Iterator<Item = Language> {
let always: &[Language] = &[
$(Language::$a_variant,)*
];
let gated: &[Language] = &[
$(
#[cfg(feature = $g_feat)]
Language::$g_variant,
)*
];
always.iter().copied().chain(gated.iter().copied())
}
pub fn language_globs(language: Language) -> Vec<glob::Pattern> {
let glob_strs: &'static [&'static str] = match language {
$(
Language::$a_variant => &[$($a_glob),*],
)*
$(
#[cfg(feature = $g_feat)]
Language::$g_variant => &[$($g_glob),*],
)*
};
glob_strs
.iter()
.map(|name| glob::Pattern::new(name).expect("failed to guess language by path"))
.collect()
}
pub fn name(&self) -> &'static str {
match self {
$(
Language::$a_variant => $a_name,
)*
$(
#[cfg(feature = $g_feat)]
Language::$g_variant => $g_name,
)*
}
}
pub fn aliases(&self) -> &'static [&'static str] {
let all: &'static [&'static str] = match self {
$(
Language::$a_variant => &[$($a_str),*],
)*
$(
#[cfg(feature = $g_feat)]
Language::$g_variant => &[$($g_str),*],
)*
};
match all.split_first() {
Some((first, rest)) if *first == self.id_name() => rest,
_ => all,
}
}
pub fn globs(&self) -> &'static [&'static str] {
match self {
$(
Language::$a_variant => &[$($a_glob),*],
)*
$(
#[cfg(feature = $g_feat)]
Language::$g_variant => &[$($g_glob),*],
)*
}
}
pub fn emacs_modes(&self) -> &'static [&'static str] {
match self {
$(
Language::$a_variant => &[$($a_emacs),*],
)*
$(
#[cfg(feature = $g_feat)]
Language::$g_variant => &[$($g_emacs),*],
)*
}
}
pub fn shebangs(&self) -> &'static [&'static str] {
match self {
$(
Language::$a_variant => &[$($a_shebang),*],
)*
$(
#[cfg(feature = $g_feat)]
Language::$g_variant => &[$($g_shebang),*],
)*
}
}
pub fn extensions(&self) -> Vec<&'static str> {
self.globs()
.iter()
.copied()
.filter(|glob| glob.starts_with("*."))
.collect()
}
pub fn info(&self) -> LanguageInfo {
LanguageInfo {
id: self.id_name(),
name: self.name(),
parser: match self {
$(Language::$a_variant => $a_parser,)*
$(#[cfg(feature = $g_feat)] Language::$g_variant => $g_parser,)*
},
aliases: self.aliases(),
extensions: self.extensions(),
globs: self.globs(),
emacs_modes: self.emacs_modes(),
shebangs: self.shebangs(),
}
}
pub fn id_name(&self) -> &'static str {
match self {
$(
Language::$a_variant => $a_id,
)*
$(
#[cfg(feature = $g_feat)]
Language::$g_variant => $g_id,
)*
}
}
fn from_emacs_mode_header(src: &str) -> Option<Language> {
static MODE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"-\*-.*?mode: *([a-zA-Z0-9_+-]+).*-\*-").unwrap());
static SHORTHAND_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"-\*-(.+)-\*-").unwrap());
for line in split_on_newlines(src).take(2) {
let mode_name: String = match (MODE_RE.captures(line), SHORTHAND_RE.captures(line)) {
(Some(cap), _) | (_, Some(cap)) => cap[1].into(),
_ => "".into(),
};
let lang = match mode_name.to_ascii_lowercase().trim() {
$(
$($a_emacs => Some(Language::$a_variant),)*
)*
$(
$(
#[cfg(feature = $g_feat)]
$g_emacs => Some(Language::$g_variant),
)*
)*
_ => None,
};
if lang.is_some() {
return lang;
}
}
None
}
fn from_shebang(src: &str) -> Option<Language> {
static RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^#![ \t]*(?:/usr/bin/env[ \t]+(?:(?:-S|--split-string)[ \t]+)?)?([^ \t]+)").unwrap()
});
let first_line = split_on_newlines(src).next()?;
let cap = RE.captures(first_line)?;
let interpreter_path = Path::new(&cap[1]);
let name = normalize_shebang_command(&interpreter_path.file_name()?.to_string_lossy());
NORMALIZED_SHEBANGS
.iter()
.find(|(_, candidates)| candidates.iter().any(|candidate| *candidate == name))
.map(|(language, _)| *language)
}
}
impl std::str::FromStr for Language {
type Err = LanguageParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let original = s;
let s = s.trim();
if s.is_empty() {
return Ok(Language::PlainText);
}
let s_lower = s.to_ascii_lowercase();
let exact = match s_lower.as_str() {
$(
$($a_str => Some(Language::$a_variant),)*
)*
$(
$(
#[cfg(feature = $g_feat)]
$g_str => Some(Language::$g_variant),
)*
)*
_ => None,
};
if let Some(lang) = exact {
return Ok(lang);
}
let path = Utf8WindowsPath::new(&s_lower);
if let Some(lang) = Language::from_glob(path) {
return Ok(lang);
}
if let Some(lang) = Language::from_extension(&s_lower) {
return Ok(lang);
}
Err(LanguageParseError(original.to_string()))
}
}
pub fn available_languages() -> Vec<LanguageInfo> {
let mut languages: Vec<LanguageInfo> = Language::iter().map(|l| l.info()).collect();
languages.sort_unstable_by_key(|language| language.id);
languages
}
#[deprecated(since = "2.4.0", note = "use `available_languages()`, which carries aliases and detection metadata")]
pub fn available_languages_map() -> HashMap<String, (String, Vec<String>)> {
Language::iter()
.map(|language| {
(
language.id_name().to_string(),
(
language.name().to_string(),
language.globs().iter().map(|g| (*g).to_string()).collect(),
),
)
})
.collect()
}
};
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize)]
pub struct LanguageInfo {
pub id: &'static str,
pub name: &'static str,
pub parser: Option<&'static str>,
pub aliases: &'static [&'static str],
pub extensions: Vec<&'static str>,
pub globs: &'static [&'static str],
pub emacs_modes: &'static [&'static str],
pub shebangs: &'static [&'static str],
}
include!(concat!(env!("OUT_DIR"), "/languages_data.rs"));
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanguageParseError(String);
impl std::fmt::Display for LanguageParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "unknown language or file type: {}", self.0)
}
}
impl std::error::Error for LanguageParseError {}
impl Language {
pub fn guess(language: Option<&str>, src: &str) -> Self {
if let Some(input) = language {
if let Ok(lang) = input.parse() {
return lang;
}
}
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
if let Some(lang) = Self::from_emacs_mode_header(src) {
return lang;
}
if let Some(lang) = Self::from_shebang(src) {
return lang;
}
#[cfg(feature = "lang-html")]
if Self::looks_like_html(src) {
return Language::HTML;
}
#[cfg(feature = "lang-xml")]
if Self::looks_like_xml(src) {
return Language::XML;
}
Language::PlainText
}
fn from_glob(path: &Utf8WindowsPath) -> Option<Self> {
Self::first_matching_glob(path.file_name()?)
}
fn from_extension(token: &str) -> Option<Self> {
let token = token.strip_prefix('.').unwrap_or(token);
Self::first_matching_glob(&format!("*.{}", token.to_ascii_lowercase()))
}
fn first_matching_glob(candidate: &str) -> Option<Self> {
let id = id_matching_glob(candidate)?;
Language::iter().find(|language| language.id_name() == id)
}
#[allow(dead_code)]
fn looks_like_xml(src: &str) -> bool {
src.trim_start().to_lowercase().starts_with("<?xml")
}
#[allow(dead_code)]
fn looks_like_html(src: &str) -> bool {
src.trim_start()
.to_lowercase()
.starts_with("<!doctype html")
}
}
impl std::fmt::Display for Language {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.id_name())
}
}
fn split_on_newlines(s: &str) -> impl Iterator<Item = &str> {
s.split('\n').map(|l| {
if let Some(l) = l.strip_suffix('\r') {
l
} else {
l
}
})
}
fn normalize_shebang_command(command: &str) -> String {
static VERSION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+(?:\.\d+)*$").unwrap());
let command = command.to_ascii_lowercase();
VERSION_RE.replace(&command, "").into_owned()
}
struct FilenameIndex {
literals: HashMap<String, &'static str>,
extensions: HashMap<String, &'static str>,
patterns: Vec<(&'static str, glob::Pattern)>,
}
impl FilenameIndex {
fn id_for(&self, candidate: &str) -> Option<&'static str> {
let mut best = self.literals.get(candidate).copied();
if let Some((_, extension)) = candidate.rsplit_once('.') {
best = lower_id(best, self.extensions.get(extension).copied());
}
for (id, pattern) in &self.patterns {
if pattern.matches(candidate) {
best = lower_id(best, Some(id));
}
}
best
}
}
fn lower_id(left: Option<&'static str>, right: Option<&'static str>) -> Option<&'static str> {
match (left, right) {
(Some(left), Some(right)) => Some(left.min(right)),
(found, None) | (None, found) => found,
}
}
static FILENAME_INDEX: LazyLock<FilenameIndex> = LazyLock::new(|| {
let mut index = FilenameIndex {
literals: HashMap::new(),
extensions: HashMap::new(),
patterns: Vec::new(),
};
let mut entries: Vec<_> = FILENAME_GLOBS.iter().collect();
entries.sort_by_key(|(id, _)| *id);
for (id, globs) in entries {
for glob in *globs {
let glob = glob.to_ascii_lowercase();
if let Some(extension) = glob.strip_prefix("*.") {
if !extension.contains(['*', '?', '[']) {
index.extensions.entry(extension.to_string()).or_insert(id);
continue;
}
}
if glob.contains(['*', '?', '[']) {
let pattern = glob::Pattern::new(&glob).expect("catalog glob is a valid pattern");
index.patterns.push((id, pattern));
} else {
index.literals.entry(glob).or_insert(id);
}
}
}
index
});
fn id_matching_glob(candidate: &str) -> Option<&'static str> {
FILENAME_INDEX.id_for(candidate)
}
pub fn language_id_for_filename(path: &str) -> Option<&'static str> {
let path = path.trim().to_ascii_lowercase();
id_matching_glob(Utf8WindowsPath::new(&path).file_name()?)
}
static NORMALIZED_SHEBANGS: LazyLock<Vec<(Language, Vec<String>)>> = LazyLock::new(|| {
Language::iter()
.map(|language| {
let commands = language
.shebangs()
.iter()
.map(|shebang| normalize_shebang_command(shebang))
.collect();
(language, commands)
})
.collect()
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_two_languages_share_a_normalized_shebang() {
let mut owners: HashMap<&str, Vec<&'static str>> = HashMap::new();
for (language, commands) in NORMALIZED_SHEBANGS.iter() {
for command in commands {
owners
.entry(command.as_str())
.or_default()
.push(language.id_name());
}
}
let collisions: Vec<_> = owners
.iter()
.filter(|(_, languages)| {
languages
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
> 1
})
.collect();
assert!(
collisions.is_empty(),
"shebangs normalize onto one another: {collisions:?}"
);
}
#[test]
fn no_name_is_claimed_twice_or_shadows_a_glob() {
let mut owners: HashMap<String, &'static str> = HashMap::new();
for language in Language::iter() {
let id = language.id_name();
for name in std::iter::once(id).chain(language.aliases().iter().copied()) {
let name = name.to_ascii_lowercase();
if let Some(owner) = owners.insert(name.clone(), id) {
panic!("`{name}` is claimed twice, by {owner} and by {id}");
}
if let Some(owner) = id_matching_glob(&name) {
assert_eq!(owner, id, "`{name}` names {id} but is {owner}'s file name");
}
if let Some(owner) = id_matching_glob(&format!("*.{name}")) {
assert_eq!(owner, id, "`{name}` names {id} but is {owner}'s extension");
}
}
}
assert!(
FILENAME_GLOBS.len() > 110,
"found {} languages in the catalog, expected the whole of it",
FILENAME_GLOBS.len()
);
}
#[test]
fn no_alias_repeats_its_own_id() {
for language in Language::iter() {
let id = language.id_name();
for alias in language.aliases() {
assert!(
!alias.eq_ignore_ascii_case(id),
"{id} lists its own id as an alias"
);
}
}
}
#[test]
fn plaintext_metadata_and_detection_are_consistent() {
let plaintext = Language::PlainText.info();
assert_eq!(plaintext.name, "Plain Text");
assert_eq!(plaintext.aliases, ["text", "txt", "plain"]);
assert_eq!(plaintext.emacs_modes, ["fundamental", "text"]);
assert!(
plaintext.extensions.is_empty(),
"plaintext claims an extension"
);
assert!(plaintext.globs.is_empty(), "plaintext claims a glob");
assert!(plaintext.shebangs.is_empty(), "plaintext claims a shebang");
for name in std::iter::once(plaintext.id).chain(plaintext.aliases.iter().copied()) {
assert_eq!(
Language::guess(Some(name), "#!/usr/bin/env bash"),
Language::PlainText
);
}
assert_eq!(
Language::guess(None, "// -*- mode: text -*-\nfn main() {}"),
Language::PlainText
);
}
#[cfg(feature = "lang-commonlisp")]
#[test]
fn emacs_mode_header_needs_no_semicolon() {
assert_eq!(
Language::guess(None, "; -*- mode: Lisp -*-"),
Language::CommonLisp
);
assert_eq!(
Language::guess(None, "; -*- mode: Lisp; eval: (auto-fill-mode 1); -*-"),
Language::CommonLisp
);
}
#[cfg(feature = "lang-python")]
#[test]
fn emacs_mode_header_may_follow_another_file_variable() {
assert_eq!(
Language::guess(None, "# -*- coding: utf-8; mode: python; -*-"),
Language::Python
);
}
#[test]
fn a_shebang_has_to_open_the_line() {
assert_eq!(
Language::guess(None, "bar = 1 #!/bin/bash"),
Language::PlainText
);
#[cfg(feature = "lang-bash")]
assert_eq!(Language::guess(None, "#!/bin/bash"), Language::Bash);
}
#[cfg(feature = "lang-typescript")]
#[test]
fn shebang_skips_env_split_string_flag() {
assert_eq!(
Language::guess(None, "#!/usr/bin/env -S deno run --allow-read"),
Language::TypeScript
);
assert_eq!(
Language::guess(None, "#!/usr/bin/env --split-string deno run --allow-read"),
Language::TypeScript
);
}
}