use std::{borrow::Borrow, path::Path};
use lazy_static::lazy_static;
use regex::Regex;
use strum::{EnumIter, IntoEnumIterator};
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter)]
pub(crate) enum Language {
Bash,
C,
CPlusPlus,
CSharp,
CMake,
Css,
Go,
Hcl,
Html,
Java,
JavaScript,
JavascriptJsx,
Json,
Kotlin,
Make,
Nix,
Php,
Python,
R,
Ruby,
Rust,
Scala,
Scss,
Sql,
Toml,
TypeScript,
TypeScriptTsx,
Xml,
Yaml,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum LanguageOverride {
Language(Language),
PlainText,
}
pub(crate) fn language_override_from_name(name: &str) -> Option<LanguageOverride> {
let name = name.trim().to_lowercase();
if name == "text" {
return Some(LanguageOverride::PlainText);
}
for language in Language::iter() {
let lang_name = language_name(language);
if lang_name.to_lowercase() == name {
return Some(LanguageOverride::Language(language));
}
}
None
}
pub(crate) fn language_name(language: Language) -> &'static str {
match language {
Bash => "Bash",
C => "C",
CMake => "CMake",
CPlusPlus => "C++",
CSharp => "C#",
Css => "CSS",
Go => "Go",
Hcl => "HCL",
Html => "HTML",
Java => "Java",
JavaScript => "JavaScript",
JavascriptJsx => "JavaScript JSX",
Json => "JSON",
Kotlin => "Kotlin",
Make => "Make",
Nix => "Nix",
Php => "PHP",
Python => "Python",
R => "R",
Ruby => "Ruby",
Rust => "Rust",
Scala => "Scala",
Scss => "SCSS",
Sql => "SQL",
Toml => "TOML",
TypeScript => "TypeScript",
TypeScriptTsx => "TypeScript TSX",
Xml => "XML",
Yaml => "YAML",
}
}
use Language::*;
pub(crate) fn language_globs(language: Language) -> Vec<glob::Pattern> {
let glob_strs: &'static [&'static str] = match language {
Bash => &[
"*.bash",
"*.bats",
"*.cgi",
"*.command",
"*.env",
"*.fcgi",
"*.ksh",
"*.sh",
"*.sh.in",
"*.tmux",
"*.tool",
"*.zsh",
".bash_aliases",
".bash_history",
".bash_logout",
".bash_profile",
".bashrc",
".cshrc",
".env",
".env.example",
".flaskenv",
".kshrc",
".login",
".profile",
".zlogin",
".zlogout",
".zprofile",
".zshenv",
".zshrc",
"9fs",
"PKGBUILD",
"bash_aliases",
"bash_logout",
"bash_profile",
"bashrc",
"cshrc",
"gradlew",
"kshrc",
"login",
"man",
"profile",
"zlogin",
"zlogout",
"zprofile",
"zshenv",
"zshrc",
],
C => &["*.c"],
CMake => &["*.cmake", "*.cmake.in", "CMakeLists.txt"],
CPlusPlus => &[
"*.cc", "*.cpp", "*.h", "*.hh", "*.hpp", "*.ino", "*.cxx", "*.cu",
],
CSharp => &["*.cs"],
Css => &["*.css"],
Go => &["*.go"],
Hcl => &["*.hcl", "*.nomad", "*.tf", "*.tfvars", "*.workflow"],
Html => &["*.html", "*.htm", "*.xhtml"],
Java => &["*.java"],
JavaScript => &["*.cjs", "*.js", "*.mjs", "*.snap"],
Json => &[
"*.json",
"*.avsc",
"*.geojson",
"*.gltf",
"*.har",
"*.ice",
"*.JSON-tmLanguage",
"*.jsonl",
"*.mcmeta",
"*.tfstate",
"*.tfstate.backup",
"*.topojson",
"*.webapp",
"*.webmanifest",
".arcconfig",
".auto-changelog",
".c8rc",
".htmlhintrc",
".imgbotconfig",
".nycrc",
".tern-config",
".tern-project",
".watchmanconfig",
"Pipfile.lock",
"composer.lock",
"mcmod.info",
],
JavascriptJsx => &["*.jsx"],
Kotlin => &["*.kt", "*.ktm", "*.kts"],
Make => &[
"*.mak",
"*.d",
"*.make",
"*.makefile",
"*.mk",
"*.mkfile",
"BSDmakefile",
"GNUmakefile",
"Kbuild",
"Makefile",
"Makefile.am",
"Makefile.boot",
"Makefile.frag",
"Makefile.in",
"Makefile.inc",
"Makefile.wat",
"makefile",
"makefile.sco",
"mkfile",
],
Nix => &["*.nix"],
Php => &["*.php"],
Python => &["*.py", "*.py3", "*.pyi", "*.bzl", "TARGETS", "BUCK", "DEPS"],
R => &["*.R", "*.r", "*.rd", "*.rsx", ".Rprofile", "expr-dist"],
Ruby => &[
"*.rb",
"*.builder",
"*.spec",
"*.rake",
"Gemfile",
"Rakefile",
],
Rust => &["*.rs"],
Scala => &["*.scala", "*.sbt", "*.sc"],
Scss => &["*.scss"],
Sql => &["*.sql", "*.pgsql"],
Toml => &[
"*.toml",
"Cargo.lock",
"Gopkg.lock",
"Pipfile",
"poetry.lock",
],
TypeScript => &["*.ts"],
TypeScriptTsx => &["*.tsx"],
Xml => &[
"*.ant",
"*.csproj",
"*.plist",
"*.resx",
"*.svg",
"*.ui",
"*.vbproj",
"*.xaml",
"*.xml",
"*.xsl",
"*.xslt",
"App.config",
"nuget.config",
"packages.config",
".classpath",
".cproject",
".project",
],
Yaml => &["*.yaml", "*.yml"],
};
glob_strs
.iter()
.map(|name| {
glob::Pattern::new(name).expect("Glob in difftastic source should be well-formed")
})
.collect()
}
fn looks_like_hacklang(path: &Path, src: &str) -> bool {
if let Some(extension) = path.extension() {
if extension == "php" && src.starts_with("<?hh") {
return true;
}
}
false
}
pub(crate) fn guess(
path: &Path,
src: &str,
overrides: &[(LanguageOverride, Vec<glob::Pattern>)],
) -> Option<Language> {
if let Some(file_name) = path.file_name() {
let file_name = file_name.to_string_lossy();
for (lang_override, patterns) in overrides {
for pattern in patterns {
if pattern.matches(&file_name) {
return match lang_override {
LanguageOverride::Language(lang) => Some(*lang),
LanguageOverride::PlainText => {
None
}
}
}
}
}
}
if let Some(lang) = from_emacs_mode_header(src) {
return Some(lang);
}
if let Some(lang) = from_shebang(src) {
return Some(lang);
}
if let Some(lang) = from_glob(path) {
return Some(lang);
}
None
}
fn from_emacs_mode_header(src: &str) -> Option<Language> {
lazy_static! {
static ref MODE_RE: Regex = Regex::new(r"-\*-.*mode:([^;]+?);.*-\*-").unwrap();
static ref SHORTHAND_RE: Regex = Regex::new(r"-\*-(.+)-\*-").unwrap();
}
for line in src.lines().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() {
"c" => Some(C),
"csharp" => Some(CSharp),
"css" => Some(Css),
"c++" => Some(CPlusPlus),
"go" => Some(Go),
"hcl" => Some(Hcl),
"html" => Some(Html),
"java" => Some(Java),
"js" | "js2" => Some(JavaScript),
"nxml" => Some(Xml),
"python" => Some(Python),
"rjsx" => Some(JavascriptJsx),
"ruby" => Some(Ruby),
"rust" => Some(Rust),
"scala" => Some(Scala),
"scss" => Some(Scss),
"sh" => Some(Bash),
"sql" => Some(Sql),
"toml" => Some(Toml),
"typescript" => Some(TypeScript),
"yaml" => Some(Yaml),
_ => None,
};
if lang.is_some() {
return lang;
}
}
None
}
fn from_shebang(src: &str) -> Option<Language> {
lazy_static! {
static ref RE: Regex = Regex::new(r"#!(?:/usr/bin/env )?([^ ]+)").unwrap();
}
if let Some(first_line) = src.lines().next() {
if let Some(cap) = RE.captures(first_line) {
let interpreter_path = Path::new(&cap[1]);
if let Some(name) = interpreter_path.file_name() {
match name.to_string_lossy().borrow() {
"ash" | "bash" | "dash" | "ksh" | "mksh" | "pdksh" | "rc" | "sh" | "zsh" => {
return Some(Bash)
}
"tcc" => return Some(C),
"chakra" | "d8" | "gjs" | "js" | "node" | "nodejs" | "qjs" | "rhino" | "v8"
| "v8-shell" => return Some(JavaScript),
"python" | "python2" | "python3" => return Some(Python),
"Rscript" => return Some(R),
"ruby" | "macruby" | "rake" | "jruby" | "rbx" => return Some(Ruby),
"deno" | "ts-node" => return Some(TypeScript),
_ => {}
}
}
}
}
None
}
fn from_glob(path: &Path) -> Option<Language> {
match path.file_name() {
Some(name) => {
let name = name.to_string_lossy().into_owned();
for language in Language::iter() {
for glob in language_globs(language) {
if glob.matches(&name) {
return Some(language);
}
}
}
None
}
None => None,
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_guess_by_extension() {
let path = Path::new("foo.java");
assert_eq!(guess(path, "", &[]), Some(Java));
}
#[test]
fn test_guess_by_whole_name() {
let path = Path::new("foo/.bashrc");
assert_eq!(guess(path, "", &[]), Some(Bash));
}
#[test]
fn test_guess_by_shebang() {
let path = Path::new("foo");
assert_eq!(guess(path, "#!/bin/bash", &[]), Some(Bash));
}
#[test]
fn test_guess_by_env_shebang() {
let path = Path::new("foo");
assert_eq!(guess(path, "#!/usr/bin/env python", &[]), Some(Python));
}
#[test]
fn test_guess_by_emacs_mode_shorthand_no_spaces() {
let path = Path::new("foo");
assert_eq!(guess(path, "# -*-python-*-", &[]), Some(Python));
}
#[test]
fn test_guess_unknown() {
let path = Path::new("jfkdlsjfkdsljfkdsljf");
assert_eq!(guess(path, "", &[]), None);
}
#[test]
fn test_guess_override() {
let path = Path::new("foo.el");
assert_eq!(
guess(
path,
"",
&[(
LanguageOverride::Language(Css),
vec![glob::Pattern::new("*.el").unwrap()],
)]
),
Some(Css)
);
}
}