Skip to main content

declint_core/
lang.rs

1//! Guessing a file's editor language id from its extension.
2//!
3//! The names are common Neovim filetypes, which is what a Neovim LSP
4//! client sends as `languageId` — the same vocabulary as a config's
5//! `languages` key. `declint check --language` always wins over this
6//! table; files whose extension is unknown get no language, and only
7//! configs without a `languages` key apply to them.
8
9use std::path::Path;
10
11/// The language id for well-known extension-less filenames
12/// (`Dockerfile`, `Containerfile`), matched on the file's basename.
13fn language_from_filename(path: &Path) -> Option<String> {
14    let name = path.file_name()?.to_str()?.to_ascii_lowercase();
15    // Suffixed variants (`Dockerfile.web`, `Containerfile.prod`) count
16    // as the same language.
17    let language = if name == "dockerfile" || name.starts_with("dockerfile.")
18        || name == "containerfile" || name.starts_with("containerfile.")
19    {
20        "dockerfile"
21    } else if name == "makefile" || name == "gnumakefile" {
22        "make"
23    } else {
24        return None;
25    };
26    Some(language.to_string())
27}
28
29/// The language id for a path: well-known extension-less filenames
30/// first (`Dockerfile`), then the extension.
31pub fn language_from_path(path: &Path) -> Option<String> {
32    language_from_filename(path).or_else(|| language_from_extension(path))
33}
34
35/// The language id for `path`'s extension, or `None` when unknown.
36pub fn language_from_extension(path: &Path) -> Option<String> {
37    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
38    let language = match ext.as_str() {
39        "md" | "markdown" => "markdown",
40        "py" | "pyi" | "pyw" => "python",
41        "rb" => "ruby",
42        "sh" | "bash" | "zsh" => "sh",
43        "fish" => "fish",
44        "ps1" | "psm1" => "powershell",
45        "js" | "mjs" | "cjs" | "jsx" => "javascript",
46        "ts" | "mts" | "cts" | "tsx" => "typescript",
47        "c" | "h" => "c",
48        "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => "cpp",
49        "cs" => "cs",
50        "rs" => "rust",
51        "go" => "go",
52        "java" => "java",
53        "kt" | "kts" => "kotlin",
54        "swift" => "swift",
55        "dart" => "dart",
56        "zig" => "zig",
57        "lua" => "lua",
58        "vim" => "vim",
59        "nix" => "nix",
60        "php" => "php",
61        "pl" | "pm" => "perl",
62        "r" => "r",
63        "jl" => "julia",
64        "hs" => "haskell",
65        "ex" | "exs" => "elixir",
66        "erl" | "hrl" => "erlang",
67        "clj" | "cljs" | "cljc" | "edn" => "clojure",
68        "sql" => "sql",
69        "html" | "htm" => "html",
70        "vue" => "vue",
71        "svelte" => "svelte",
72        "erb" | "html.erb" => "erb",
73        "hbs" | "handlebars" => "handlebars",
74        "css" => "css",
75        "scss" => "scss",
76        "json" | "jsonc" => "json",
77        "yaml" | "yml" => "yaml",
78        "toml" => "toml",
79        "ini" | "cfg" | "conf" | "properties" => "ini",
80        "xml" => "xml",
81        "txt" | "text" | "log" => "text",
82        _ => return None,
83    };
84    Some(language.to_string())
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    fn lang(name: &str) -> Option<String> {
92        language_from_extension(Path::new(name))
93    }
94
95    #[test]
96    fn common_extensions() {
97        assert_eq!(lang("README.md").as_deref(), Some("markdown"));
98        assert_eq!(lang("app.py").as_deref(), Some("python"));
99        assert_eq!(lang("run.sh").as_deref(), Some("sh"));
100        assert_eq!(lang("main.rs").as_deref(), Some("rust"));
101        assert_eq!(lang("conf.ini").as_deref(), Some("ini"));
102        assert_eq!(lang("x.toml").as_deref(), Some("toml"));
103    }
104
105    #[test]
106    fn case_and_paths() {
107        assert_eq!(lang("/a/b/NOTES.MD").as_deref(), Some("markdown"));
108        assert_eq!(lang("archive.TAR.GZ"), None);
109    }
110
111    #[test]
112    fn unknown_extensions() {
113        assert_eq!(lang("Makefile"), None);
114        assert_eq!(lang("data.bin"), None);
115        assert_eq!(lang("no_extension"), None);
116    }
117}
118
119#[cfg(test)]
120mod filename_tests {
121    use super::*;
122
123    #[test]
124    fn extensionless_filenames() {
125        assert_eq!(
126            language_from_path(Path::new("docker/Dockerfile")).as_deref(),
127            Some("dockerfile")
128        );
129        assert_eq!(
130            language_from_path(Path::new("deploy/Containerfile.prod")).as_deref(),
131            Some("dockerfile")
132        );
133        assert_eq!(
134            language_from_path(Path::new("Makefile")).as_deref(),
135            Some("make")
136        );
137        assert_eq!(language_from_path(Path::new("src/main.rs")).as_deref(), Some("rust"));
138    }
139
140    #[test]
141    fn case_insensitive_names() {
142        assert_eq!(
143            language_from_path(Path::new("docker/dockerfile")).as_deref(),
144            Some("dockerfile")
145        );
146    }
147}