use crate::options::Dialect;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Confidence {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Detection {
pub dialect: Option<Dialect>,
pub confidence: Confidence,
pub reason: &'static str,
}
impl Detection {
const NONE: Detection = Detection {
dialect: None,
confidence: Confidence::Low,
reason: "no extension or content signal",
};
const fn some(dialect: Dialect, confidence: Confidence, reason: &'static str) -> Detection {
Detection {
dialect: Some(dialect),
confidence,
reason,
}
}
}
impl Dialect {
#[must_use]
pub fn extensions(self) -> &'static [&'static str] {
use Dialect::*;
match self {
Scheme => &["ss", "sld", "sls"],
SchemeSuperset | Gauche | Mosh | Gambit => &["scm"],
Guile => &["scm"],
Racket => &["rkt", "rktl", "rktd"],
Clojure => &["clj", "cljs", "cljc"],
Phel => &["phel"],
Edn => &["edn"],
CommonLisp => &["lisp", "lsp", "cl"],
EmacsLisp => &["el", "eld"],
AutoLisp => &["lsp"],
Janet => &["janet", "jdn"],
Hy => &["hy"],
Fennel => &["fnl"],
Lfe => &["lfe"],
Islisp => &[],
}
}
#[must_use]
pub fn from_extension(ext: &str) -> &'static [Dialect] {
use Dialect::*;
let ext = ext.trim_start_matches('.');
let lower = ext.to_ascii_lowercase();
match lower.as_str() {
"el" | "eld" => &[EmacsLisp],
"scm" => &[SchemeSuperset, Guile],
"ss" | "sld" | "sls" => &[Scheme],
"rkt" | "rktl" | "rktd" => &[Racket],
"clj" | "cljs" | "cljc" | "cljx" => &[Clojure],
"edn" => &[Edn],
"phel" => &[Phel],
"fnl" => &[Fennel],
"janet" | "jdn" => &[Janet],
"hy" => &[Hy],
"lfe" => &[Lfe],
"cl" | "lisp" => &[CommonLisp],
"lsp" => &[CommonLisp, AutoLisp],
_ => &[],
}
}
}
#[must_use]
pub fn detect(filename: Option<&str>, source: &str) -> Detection {
if source.trim_start().starts_with("#lang") {
return Detection::some(Dialect::Racket, Confidence::High, "#lang directive");
}
if let Some(d) = shebang_dialect(source) {
return Detection::some(d, Confidence::High, "shebang interpreter");
}
if let Some(name) = filename {
if let Some(ext) = name.rsplit('.').next().filter(|e| *e != name) {
let candidates = Dialect::from_extension(ext);
match candidates {
[only] => return Detection::some(*only, Confidence::High, "unambiguous extension"),
[_, ..] => {
if let Some(d) = disambiguate(candidates, source) {
return Detection::some(d, Confidence::Medium, "extension + content");
}
return Detection::some(
candidates[0],
Confidence::Medium,
"extension (default)",
);
}
[] => {}
}
}
}
if let Some(d) = content_dialect(source) {
return Detection::some(d, Confidence::Low, "content marker");
}
Detection::NONE
}
#[must_use]
pub fn detect_project<I, N, S>(files: I) -> Option<Dialect>
where
I: IntoIterator<Item = (N, S)>,
N: AsRef<str>,
S: AsRef<str>,
{
let mut scores: Vec<(Dialect, u32)> = Vec::new();
for (name, source) in files {
let d = detect(Some(name.as_ref()), source.as_ref());
let (Some(dialect), weight) = (d.dialect, confidence_weight(d.confidence)) else {
continue;
};
match scores.iter_mut().find(|(k, _)| *k == dialect) {
Some((_, s)) => *s += weight,
None => scores.push((dialect, weight)),
}
}
scores
.into_iter()
.max_by(|a, b| {
a.1.cmp(&b.1)
.then_with(|| all_index(b.0).cmp(&all_index(a.0)))
})
.map(|(d, _)| d)
}
fn confidence_weight(c: Confidence) -> u32 {
match c {
Confidence::High => 3,
Confidence::Medium => 2,
Confidence::Low => 1,
}
}
fn all_index(d: Dialect) -> usize {
Dialect::ALL
.iter()
.position(|&x| x == d)
.unwrap_or(usize::MAX)
}
fn shebang_dialect(source: &str) -> Option<Dialect> {
let first = source.lines().next()?;
let rest = first.strip_prefix("#!")?;
for tok in rest.split(|c: char| c.is_whitespace()).rev() {
let base = tok.rsplit(['/', '\\']).next().unwrap_or(tok);
let d = match base {
"guile" => Dialect::Guile,
"gosh" => Dialect::SchemeSuperset,
"scheme" | "chez" | "chezscheme" | "petite" | "csi" | "chibi-scheme" => Dialect::Scheme,
"racket" => Dialect::Racket,
"sbcl" | "ccl" | "clisp" | "ecl" | "abcl" => Dialect::CommonLisp,
"clojure" | "clj" | "bb" => Dialect::Clojure,
"janet" => Dialect::Janet,
"hy" => Dialect::Hy,
"fennel" => Dialect::Fennel,
"emacs" => Dialect::EmacsLisp,
"lfe" | "lfescript" => Dialect::Lfe,
_ => continue,
};
return Some(d);
}
None
}
fn disambiguate(candidates: &[Dialect], source: &str) -> Option<Dialect> {
if candidates.contains(&Dialect::Guile) {
if source.contains("(define-module") || source.contains("(use-modules") {
return Some(Dialect::Guile);
}
return Some(Dialect::SchemeSuperset);
}
if candidates.contains(&Dialect::AutoLisp) {
if source.contains("(defun c:")
|| source.contains("(vl-")
|| source.contains("(vlax-")
|| source.contains("(command ")
{
return Some(Dialect::AutoLisp);
}
return Some(Dialect::CommonLisp);
}
None
}
fn content_dialect(source: &str) -> Option<Dialect> {
if source.contains("(define-library")
|| source.contains("(import (scheme")
|| source.contains("(import (rnrs")
{
return Some(Dialect::Scheme);
}
if source.contains("(defpackage") || source.contains("(in-package") {
return Some(Dialect::CommonLisp);
}
if source.contains("lexical-binding:")
|| source.contains(";;;###autoload")
|| source.contains("(provide '")
{
return Some(Dialect::EmacsLisp);
}
if source.contains("(ns ") {
return Some(Dialect::Clojure);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unambiguous_extension_is_high() {
assert_eq!(
detect(Some("init.el"), "(defun f ())"),
Detection::some(
Dialect::EmacsLisp,
Confidence::High,
"unambiguous extension"
)
);
assert_eq!(
detect(Some("core.clj"), "(ns app)").dialect,
Some(Dialect::Clojure)
);
assert_eq!(
detect(Some("data.edn"), "{:a 1}").dialect,
Some(Dialect::Edn)
);
}
#[test]
fn lang_directive_beats_everything() {
let d = detect(Some("weird.txt"), "#lang typed/racket\n(define x 1)");
assert_eq!(d.dialect, Some(Dialect::Racket));
assert_eq!(d.confidence, Confidence::High);
assert_eq!(
detect(None, " #lang racket").dialect,
Some(Dialect::Racket)
);
}
#[test]
fn shebang_env_and_direct() {
assert_eq!(
detect(None, "#!/usr/bin/env fennel\n(local x 1)").dialect,
Some(Dialect::Fennel)
);
assert_eq!(
detect(None, "#!/usr/bin/guile -s\n!#\n(display 1)").dialect,
Some(Dialect::Guile)
);
assert_eq!(
detect(Some("script"), "#!/usr/bin/sbcl --script\n(print 1)").dialect,
Some(Dialect::CommonLisp)
);
}
#[test]
fn shared_scm_extension_disambiguates_guile() {
let d = detect(Some("lib.scm"), "(define (f x) x)");
assert_eq!(d.dialect, Some(Dialect::SchemeSuperset));
assert_eq!(d.confidence, Confidence::Medium);
let d = detect(Some("lib.scm"), "(define-module (a b))\n(define x 1)");
assert_eq!(d.dialect, Some(Dialect::Guile));
}
#[test]
fn shared_lsp_extension_disambiguates_autolisp() {
assert_eq!(
detect(Some("cmd.lsp"), "(defun c:hello () (princ))").dialect,
Some(Dialect::AutoLisp)
);
assert_eq!(
detect(Some("pkg.lsp"), "(defpackage :app)").dialect,
Some(Dialect::CommonLisp)
);
assert_eq!(
detect(Some("x.lsp"), "(+ 1 2)").dialect,
Some(Dialect::CommonLisp)
);
}
#[test]
fn content_only_markers() {
assert_eq!(
detect(None, "(define-library (foo) (export bar))").dialect,
Some(Dialect::Scheme)
);
assert_eq!(
detect(None, "(in-package :cl-user)").dialect,
Some(Dialect::CommonLisp)
);
assert_eq!(
detect(None, ";;; -*- lexical-binding: t -*-\n(defun f ())").dialect,
Some(Dialect::EmacsLisp)
);
let d = detect(None, "(define-library (x))");
assert_eq!(d.confidence, Confidence::Low);
}
#[test]
fn nothing_detected_is_none_not_a_wrong_guess() {
let d = detect(Some("mystery.txt"), "(+ 1 2)");
assert_eq!(d.dialect, None);
assert_eq!(detect(None, "(+ 1 2)").dialect, None);
}
#[test]
fn extension_registry_round_trips_and_shares() {
assert_eq!(Dialect::from_extension("el"), &[Dialect::EmacsLisp]);
assert_eq!(Dialect::from_extension(".EL"), &[Dialect::EmacsLisp]); assert_eq!(
Dialect::from_extension("scm"),
&[Dialect::SchemeSuperset, Dialect::Guile]
);
assert_eq!(
Dialect::from_extension("lsp"),
&[Dialect::CommonLisp, Dialect::AutoLisp]
);
assert!(Dialect::from_extension("txt").is_empty());
assert!(Dialect::EmacsLisp.extensions().contains(&"el"));
}
#[test]
fn project_aggregates_order_independently() {
let files = [
("a.clj", "(ns a)"),
("b.clj", "(ns b)"),
("weird.el", "(defun f ())"),
];
assert_eq!(detect_project(files), Some(Dialect::Clojure));
let mut rev = files;
rev.reverse();
assert_eq!(detect_project(rev), Some(Dialect::Clojure));
assert_eq!(detect_project(Vec::<(&str, &str)>::new()), None);
}
}