use serde::Serialize;
use super::js;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum FileType {
Markdown,
Html,
Css,
Javascript,
Typescript,
Json,
Yaml,
Properties,
Toml,
Ini,
Xml,
Unknown,
}
pub(crate) fn determine_file_type(language_id: &str) -> FileType {
match language_id {
"markdown" => FileType::Markdown,
"html" => FileType::Html,
"css" => FileType::Css,
"javascript" => FileType::Javascript,
"typescript" => FileType::Typescript,
"json" => FileType::Json,
"yaml" | "yml" => FileType::Yaml,
"properties" => FileType::Properties,
"toml" => FileType::Toml,
"ini" => FileType::Ini,
"xml" => FileType::Xml,
_ => FileType::Unknown,
}
}
pub(crate) const FALLBACK_FORMAT: &str = "plaintext";
pub(crate) const SUPPORTED_FORMATS: [&str; 13] = [
"markdown",
"html",
"css",
"javascript",
"typescript",
"json",
"yaml",
"properties",
"toml",
"ini",
"xml",
"csv",
"plaintext",
];
const ALIASES: [(&str, &str); 40] = [
("markdown", "markdown"),
("md", "markdown"),
("mdx", "markdown"),
("mdown", "markdown"),
("mkd", "markdown"),
("html", "html"),
("htm", "html"),
("xhtml", "html"),
("css", "css"),
("scss", "css"),
("less", "css"),
("javascript", "javascript"),
("js", "javascript"),
("jsx", "javascript"),
("mjs", "javascript"),
("cjs", "javascript"),
("typescript", "typescript"),
("ts", "typescript"),
("tsx", "typescript"),
("mts", "typescript"),
("cts", "typescript"),
("json", "json"),
("jsonc", "json"),
("yaml", "yaml"),
("yml", "yaml"),
("properties", "properties"),
("env", "properties"),
("toml", "toml"),
("ini", "ini"),
("cfg", "ini"),
("conf", "ini"),
("xml", "xml"),
("svg", "xml"),
("xsl", "xml"),
("pom", "xml"),
("csv", "csv"),
("tsv", "csv"),
("plaintext", "plaintext"),
("txt", "plaintext"),
("log", "plaintext"),
];
fn normalise(value: &str) -> String {
let trimmed = js::trim(value).to_lowercase();
trimmed.strip_prefix('.').unwrap_or(&trimmed).to_string()
}
fn alias(key: &str) -> Option<&'static str> {
ALIASES
.iter()
.find(|(from, _)| *from == key)
.map(|(_, to)| *to)
}
pub(crate) fn resolve_format(format: Option<&str>, filename: Option<&str>) -> Option<&'static str> {
let format = format.filter(|value| !value.is_empty());
let filename = filename.filter(|value| !value.is_empty());
if let Some(format) = format
&& let Some(direct) = alias(&normalise(format))
{
return Some(direct);
}
if let Some(filename) = filename
&& let Some(by_name) = from_filename(filename)
{
return Some(by_name);
}
(format.is_some() || filename.is_some()).then_some(FALLBACK_FORMAT)
}
fn from_filename(filename: &str) -> Option<&'static str> {
let bare = normalise(filename);
if let Some(whole) = alias(&bare) {
return Some(whole);
}
let extension = filename.rsplit_once('.').map(|(_, ext)| ext)?;
alias(&normalise(extension))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_ids_map_to_file_types() {
assert_eq!(determine_file_type("markdown"), FileType::Markdown);
assert_eq!(determine_file_type("yml"), FileType::Yaml);
assert_eq!(determine_file_type("python"), FileType::Unknown);
}
#[test]
fn an_explicit_format_wins() {
assert_eq!(resolve_format(Some("md"), None), Some("markdown"));
assert_eq!(resolve_format(Some(".TOML"), None), Some("toml"));
}
#[test]
fn a_filename_resolves_by_extension() {
assert_eq!(resolve_format(None, Some("README.md")), Some("markdown"));
assert_eq!(resolve_format(None, Some("a/b/app.yaml")), Some("yaml"));
assert_eq!(resolve_format(None, Some("data.xml")), Some("xml"));
}
#[test]
fn nothing_recognisable_falls_back_rather_than_refusing() {
assert_eq!(resolve_format(Some("python"), None), Some(FALLBACK_FORMAT));
assert_eq!(
resolve_format(None, Some("script.py")),
Some(FALLBACK_FORMAT)
);
assert_eq!(
resolve_format(None, Some("Makefile")),
Some(FALLBACK_FORMAT)
);
}
#[test]
fn an_empty_name_is_no_name() {
assert_eq!(resolve_format(Some(""), None), None);
assert_eq!(resolve_format(None, Some("")), None);
assert_eq!(resolve_format(Some(""), Some("")), None);
assert_eq!(resolve_format(Some(""), Some("a.md")), Some("markdown"));
assert_eq!(resolve_format(Some(" "), None), Some(FALLBACK_FORMAT));
}
#[test]
fn a_name_is_trimmed_by_javascripts_whitespace_set() {
assert_eq!(resolve_format(Some("\u{feff}json"), None), Some("json"));
assert_eq!(resolve_format(Some("json\u{feff}"), None), Some("json"));
assert_eq!(resolve_format(None, Some("\u{feff}a.md")), Some("markdown"));
assert_eq!(
resolve_format(Some("\u{85}json"), None),
Some(FALLBACK_FORMAT)
);
assert_eq!(
resolve_format(Some("json\u{85}"), None),
Some(FALLBACK_FORMAT)
);
}
#[test]
fn naming_neither_returns_none() {
assert_eq!(resolve_format(None, None), None);
}
#[test]
fn a_filename_is_consulted_when_the_format_hint_is_not_known() {
assert_eq!(
resolve_format(Some("klingon"), Some("README.md")),
Some("markdown")
);
}
#[test]
fn every_advertised_format_resolves() {
for format in SUPPORTED_FORMATS {
assert!(resolve_format(Some(format), None).is_some(), "{format}");
}
}
#[test]
fn every_alias_lands_on_an_advertised_format() {
for (from, to) in ALIASES {
assert!(SUPPORTED_FORMATS.contains(&to), "{from} -> {to}");
}
}
#[test]
fn the_fallback_is_an_advertised_format() {
assert!(SUPPORTED_FORMATS.contains(&FALLBACK_FORMAT));
assert_eq!(determine_file_type(FALLBACK_FORMAT), FileType::Unknown);
}
#[test]
fn the_alias_table_matches_the_shared_contract() {
let shared: std::collections::BTreeMap<String, String> =
serde_json::from_str(include_str!("../../fixtures/aliases.json"))
.expect("the alias contract is valid JSON");
let mine: std::collections::BTreeMap<String, String> = ALIASES
.iter()
.map(|(from, to)| ((*from).to_string(), (*to).to_string()))
.collect();
assert_eq!(mine.len(), ALIASES.len(), "an alias is listed twice");
assert_eq!(mine, shared);
}
}