Skip to main content

mcpls_core/config/
language_map.rs

1//! File-to-language detection.
2//!
3//! [`LanguageMap`] is the one place that decides which language a file path
4//! belongs to: by extension when the file has one, otherwise by its
5//! extensionless name. The translator, the document tracker and the
6//! tool-support report all read it, so they cannot disagree.
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use super::{FileExtension, FileName, LanguageId, PatternTarget};
12
13/// What identifies a file to a [`LanguageMap`].
14///
15/// A file with an extension is identified by it, so a name key never competes
16/// with an extension key: [`FileName`] has no dot.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum FileKey {
19    /// The file's extension, as `Path::extension` reports it.
20    Extension(FileExtension),
21    /// The name of a file without an extension.
22    Name(FileName),
23    /// The file has no extension or name that can be configured, such as a
24    /// dotfile or a name with non-ASCII characters.
25    Unmappable,
26}
27
28impl FileKey {
29    /// The key identifying `path`.
30    ///
31    /// # Examples
32    ///
33    /// ```
34    /// use std::path::Path;
35    ///
36    /// use mcpls_core::config::{FileExtension, FileKey, FileName};
37    ///
38    /// assert_eq!(
39    ///     FileKey::of(Path::new("src/main.rs")),
40    ///     FileKey::Extension(FileExtension::from_static("rs"))
41    /// );
42    /// assert_eq!(
43    ///     FileKey::of(Path::new("build/Makefile")),
44    ///     FileKey::Name(FileName::from_static("Makefile"))
45    /// );
46    /// assert_eq!(FileKey::of(Path::new(".gitignore")), FileKey::Unmappable);
47    /// ```
48    #[must_use]
49    pub fn of(path: &Path) -> Self {
50        let by_name = || {
51            path.file_name()
52                .and_then(|name| name.to_str())
53                .and_then(|name| FileName::new(name).ok())
54                .map_or(Self::Unmappable, Self::Name)
55        };
56        path.extension().map_or_else(by_name, |extension| {
57            extension
58                .to_str()
59                .and_then(|extension| FileExtension::new(extension).ok())
60                .map_or(Self::Unmappable, Self::Extension)
61        })
62    }
63}
64
65/// Maps file extensions and extensionless file names to language ids.
66///
67/// # Examples
68///
69/// ```
70/// use std::path::Path;
71///
72/// use mcpls_core::config::{FileName, LanguageId, LanguageMap, PatternTarget};
73///
74/// let mut map = LanguageMap::default();
75/// map.insert(
76///     PatternTarget::Name(FileName::from_static("Makefile")),
77///     LanguageId::new("make").unwrap(),
78/// );
79/// assert_eq!(map.detect(Path::new("Makefile")), "make");
80/// assert_eq!(map.detect(Path::new("main.rs")), LanguageId::PLAINTEXT);
81/// ```
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83pub struct LanguageMap {
84    by_extension: HashMap<FileExtension, LanguageId>,
85    by_name: HashMap<FileName, LanguageId>,
86}
87
88impl LanguageMap {
89    /// Maps `target` to `language`, replacing an earlier mapping of the same
90    /// extension or name.
91    pub fn insert(&mut self, target: PatternTarget, language: LanguageId) {
92        match target {
93            PatternTarget::Extension(extension) => {
94                self.by_extension.insert(extension, language);
95            }
96            PatternTarget::Name(name) => {
97                self.by_name.insert(name, language);
98            }
99        }
100    }
101
102    /// The language of `path`, or [`LanguageId::PLAINTEXT`] when its extension
103    /// or name is not mapped.
104    #[must_use]
105    pub fn detect(&self, path: &Path) -> LanguageId {
106        match FileKey::of(path) {
107            FileKey::Extension(extension) => self.by_extension.get(&extension),
108            FileKey::Name(name) => self.by_name.get(&name),
109            FileKey::Unmappable => None,
110        }
111        .cloned()
112        .unwrap_or(LanguageId::PLAINTEXT)
113    }
114
115    /// Every language some extension or name maps to, with repeats when
116    /// several keys map to one language.
117    pub fn languages(&self) -> impl Iterator<Item = &LanguageId> {
118        self.by_extension.values().chain(self.by_name.values())
119    }
120}
121
122impl From<HashMap<FileExtension, LanguageId>> for LanguageMap {
123    fn from(by_extension: HashMap<FileExtension, LanguageId>) -> Self {
124        Self {
125            by_extension,
126            by_name: HashMap::new(),
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn map() -> LanguageMap {
136        let mut map = LanguageMap::from(HashMap::from([
137            (
138                FileExtension::from_static("rs"),
139                LanguageId::from_static("rust"),
140            ),
141            (
142                FileExtension::from_static("R"),
143                LanguageId::from_static("r"),
144            ),
145        ]));
146        map.insert(
147            PatternTarget::Name(FileName::from_static("Dockerfile")),
148            LanguageId::from_static("dockerfile"),
149        );
150        map
151    }
152
153    #[test]
154    fn test_detect_by_extension_and_name() {
155        let map = map();
156        assert_eq!(map.detect(Path::new("src/main.rs")), "rust");
157        assert_eq!(map.detect(Path::new("a/b/Dockerfile")), "dockerfile");
158    }
159
160    #[test]
161    fn test_detect_is_case_sensitive() {
162        let map = map();
163        assert_eq!(map.detect(Path::new("x.r")), LanguageId::PLAINTEXT);
164        assert_eq!(map.detect(Path::new("x.R")), "r");
165        assert_eq!(map.detect(Path::new("dockerfile")), LanguageId::PLAINTEXT);
166    }
167
168    #[test]
169    fn test_detect_falls_back_to_plaintext() {
170        let map = map();
171        for unmapped in ["x.unknown", "Makefile", ".gitignore", "x.", "x.r\u{e9}"] {
172            assert_eq!(map.detect(Path::new(unmapped)), LanguageId::PLAINTEXT);
173        }
174    }
175
176    #[test]
177    fn test_a_file_with_an_extension_never_matches_a_name() {
178        let map = map();
179        assert_eq!(
180            map.detect(Path::new("Dockerfile.bak")),
181            LanguageId::PLAINTEXT
182        );
183    }
184
185    #[test]
186    fn test_file_key_of() {
187        assert_eq!(
188            FileKey::of(Path::new("a.tar.gz")),
189            FileKey::Extension(FileExtension::from_static("gz"))
190        );
191        assert_eq!(
192            FileKey::of(Path::new("Makefile")),
193            FileKey::Name(FileName::from_static("Makefile"))
194        );
195        for unmappable in [".gitignore", "x.", "r\u{e9}", "a.\u{e9}", "", ".."] {
196            assert_eq!(FileKey::of(Path::new(unmappable)), FileKey::Unmappable);
197        }
198    }
199
200    #[test]
201    fn test_languages_lists_both_maps() {
202        let map = map();
203        let languages: Vec<&LanguageId> = map.languages().collect();
204        assert_eq!(languages.len(), 3);
205        assert!(languages.contains(&&LanguageId::from_static("dockerfile")));
206    }
207}