Skip to main content

meta_ast/input/
mod.rs

1//! File discovery and language detection.
2//!
3//! Walks a directory tree, maps file extensions to `LangId` via a
4//! pre-computed extension map, and returns sorted file lists.
5
6use std::path::Path;
7
8use crate::language::{LangId, spec_for};
9
10use std::collections::HashMap;
11use std::sync::LazyLock;
12
13static EXT_MAP: LazyLock<HashMap<&'static str, LangId>> = LazyLock::new(|| {
14    LangId::all()
15        .iter()
16        .flat_map(|&id| {
17            let spec = spec_for(id);
18            spec.extensions.iter().map(move |&ext| (ext, id))
19        })
20        .collect()
21});
22
23pub fn detect_language(path: &Path) -> Option<LangId> {
24    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
25    EXT_MAP.get(ext.as_str()).copied()
26}
27
28/// Strip Windows verbatim prefix via `dunce` and return an owned path.
29///
30/// Use for filesystem walks and reads so downstream `std::fs` calls see a
31/// consistent path on all platforms.
32pub fn simplified_path(path: &Path) -> std::path::PathBuf {
33    dunce::simplified(path).to_path_buf()
34}
35
36/// Return a portable display string for a path.
37///
38/// Applies `dunce::simplified`, converts lossy to UTF-8, and normalizes
39/// backslashes to `/` on Windows only. On Unix a backslash stays a valid
40/// filename char, so `a\b.py` and `a/b.py` remain distinct.
41pub fn portable_path(path: &Path) -> String {
42    let simplified = dunce::simplified(path);
43    let value = simplified.to_string_lossy();
44    #[cfg(windows)]
45    {
46        value.replace('\\', "/")
47    }
48    #[cfg(not(windows))]
49    {
50        value.into_owned()
51    }
52}
53
54pub fn discover_files(
55    root: &Path,
56    languages: Option<&[LangId]>,
57) -> Result<Vec<(std::path::PathBuf, LangId)>, std::io::Error> {
58    let mut results = Vec::new();
59
60    if root.is_file() {
61        if let Some(lang_id) = detect_language(root)
62            && languages.is_none_or(|langs| langs.contains(&lang_id))
63        {
64            // The walk branch strips the Windows verbatim prefix; the single file
65            // branch must hand out the same key form.
66            results.push((simplified_path(root), lang_id));
67        }
68        return Ok(results);
69    }
70
71    if !root.exists() {
72        return Err(std::io::Error::new(
73            std::io::ErrorKind::NotFound,
74            format!("path does not exist: {}", root.display()),
75        ));
76    }
77
78    for entry in ignore::WalkBuilder::new(root)
79        .build()
80        .filter_map(|e| e.ok())
81    {
82        // Strip the verbatim `\\?\` prefix on Windows so downstream std::fs
83        // reads and tree-sitter paths resolve consistently.
84        let path = simplified_path(&entry.into_path());
85        if !path.is_file() {
86            continue;
87        }
88        if let Some(lang_id) = detect_language(&path) {
89            results.push((path, lang_id));
90        }
91    }
92
93    if let Some(langs) = languages {
94        results.retain(|(_, id)| langs.contains(id));
95    }
96
97    results.sort_by(|a, b| a.0.cmp(&b.0));
98    Ok(results)
99}
100
101#[cfg(test)]
102mod tests {
103    use std::path::PathBuf;
104
105    use super::*;
106    use crate::language::LangId;
107
108    #[test]
109    fn detect_python_extensions() {
110        assert_eq!(
111            detect_language(&PathBuf::from("foo.py")),
112            Some(LangId::Python)
113        );
114        assert_eq!(
115            detect_language(&PathBuf::from("foo.pyi")),
116            Some(LangId::Python)
117        );
118    }
119
120    #[test]
121    fn detect_javascript_extensions() {
122        assert_eq!(
123            detect_language(&PathBuf::from("foo.js")),
124            Some(LangId::JavaScript)
125        );
126        assert_eq!(
127            detect_language(&PathBuf::from("foo.mjs")),
128            Some(LangId::JavaScript)
129        );
130        assert_eq!(
131            detect_language(&PathBuf::from("foo.cjs")),
132            Some(LangId::JavaScript)
133        );
134    }
135
136    #[test]
137    fn detect_typescript_extensions() {
138        assert_eq!(
139            detect_language(&PathBuf::from("foo.ts")),
140            Some(LangId::TypeScript)
141        );
142        assert_eq!(
143            detect_language(&PathBuf::from("foo.cts")),
144            Some(LangId::TypeScript)
145        );
146        assert_eq!(
147            detect_language(&PathBuf::from("foo.mts")),
148            Some(LangId::TypeScript)
149        );
150    }
151
152    #[test]
153    fn detect_tsx_extension() {
154        assert_eq!(
155            detect_language(&PathBuf::from("foo.tsx")),
156            Some(LangId::Tsx)
157        );
158        assert_ne!(
159            detect_language(&PathBuf::from("foo.tsx")),
160            Some(LangId::TypeScript)
161        );
162    }
163
164    #[test]
165    fn detect_c_cpp_extensions() {
166        assert_eq!(detect_language(&PathBuf::from("foo.c")), Some(LangId::C));
167        assert_eq!(detect_language(&PathBuf::from("foo.cc")), Some(LangId::Cpp));
168        assert_eq!(
169            detect_language(&PathBuf::from("foo.cpp")),
170            Some(LangId::Cpp)
171        );
172        assert_eq!(
173            detect_language(&PathBuf::from("foo.cxx")),
174            Some(LangId::Cpp)
175        );
176    }
177
178    #[test]
179    fn detect_rust_go() {
180        assert_eq!(
181            detect_language(&PathBuf::from("foo.rs")),
182            Some(LangId::Rust)
183        );
184        assert_eq!(detect_language(&PathBuf::from("foo.go")), Some(LangId::Go));
185    }
186
187    #[test]
188    fn detect_unknown_returns_none() {
189        assert_eq!(detect_language(&PathBuf::from("foo.md")), None);
190        assert_eq!(detect_language(&PathBuf::from("foo.txt")), None);
191        assert_eq!(detect_language(&PathBuf::from("README")), None);
192    }
193
194    #[test]
195    fn detect_case_insensitive() {
196        assert_eq!(
197            detect_language(&PathBuf::from("foo.PY")),
198            Some(LangId::Python)
199        );
200        assert_eq!(
201            detect_language(&PathBuf::from("foo.Rs")),
202            Some(LangId::Rust)
203        );
204    }
205
206    #[test]
207    fn discover_files_finds_fixtures() {
208        let root = PathBuf::from("tests/fixtures/python");
209        let files = discover_files(&root, None).unwrap();
210        assert!(!files.is_empty());
211        assert!(files.iter().all(|(_, lang)| *lang == LangId::Python));
212    }
213
214    #[test]
215    fn discover_files_filters_by_language() {
216        let root = PathBuf::from("tests/fixtures/mixed");
217        let files = discover_files(&root, Some(&[LangId::Python])).unwrap();
218        assert!(files.iter().all(|(_, lang)| *lang == LangId::Python));
219        assert!(!files.is_empty());
220    }
221
222    #[test]
223    fn discover_files_empty_dir() {
224        let tmp = std::env::temp_dir().join("meta_ast_test_empty");
225        std::fs::create_dir_all(&tmp).unwrap();
226        let files = discover_files(&tmp, None).unwrap();
227        assert!(files.is_empty());
228        std::fs::remove_dir(&tmp).unwrap();
229    }
230
231    #[test]
232    fn discover_single_file() {
233        let path = PathBuf::from("tests/fixtures/python/simple_functions.py");
234        let files = discover_files(&path, None).unwrap();
235        assert_eq!(files.len(), 1);
236        assert_eq!(files[0].1, LangId::Python);
237    }
238
239    #[test]
240    fn discover_single_file_honors_language_filter() {
241        let path = PathBuf::from("tests/fixtures/mixed/app.py");
242        let matching = discover_files(&path, Some(&[LangId::Python])).unwrap();
243        assert_eq!(matching.len(), 1);
244        assert_eq!(matching[0].1, LangId::Python);
245
246        let excluded = discover_files(&path, Some(&[LangId::Go])).unwrap();
247        assert!(excluded.is_empty());
248    }
249
250    #[test]
251    fn discover_files_respects_gitignore() {
252        let root = PathBuf::from("tests/fixtures/mixed");
253        let files = discover_files(&root, None).unwrap();
254        let paths: Vec<_> = files
255            .iter()
256            .map(|(p, _)| p.file_name().unwrap().to_str().unwrap())
257            .collect();
258        assert!(
259            !paths.contains(&"test.generated.py"),
260            "gitignored file should be excluded"
261        );
262    }
263
264    #[test]
265    fn discover_files_sorted() {
266        let root = PathBuf::from("tests/fixtures/mixed");
267        let files = discover_files(&root, None).unwrap();
268        let paths: Vec<_> = files.iter().map(|(p, _)| p).collect();
269        let mut sorted = paths.clone();
270        sorted.sort();
271        assert_eq!(paths, sorted);
272    }
273
274    #[test]
275    fn ext_map_covers_all_catalog_extensions() {
276        for id in LangId::all() {
277            let spec = spec_for(id);
278            for &ext in spec.extensions {
279                assert_eq!(
280                    detect_language(&PathBuf::from(format!("foo.{ext}"))),
281                    Some(id),
282                    "extension {ext:?} should map to {id:?}"
283                );
284            }
285        }
286    }
287
288    #[test]
289    fn portable_path_uses_dunce_and_preserves_unix_backslash() {
290        use std::path::Path;
291        let slash = portable_path(Path::new("a/b.py"));
292        let backslash = portable_path(Path::new("a\\b.py"));
293        assert_eq!(slash, "a/b.py");
294        #[cfg(not(windows))]
295        assert_ne!(backslash, slash);
296        #[cfg(windows)]
297        assert_eq!(backslash, slash);
298    }
299
300    #[test]
301    fn simplified_path_returns_owned_path() {
302        use std::path::Path;
303        let out = simplified_path(Path::new("a/b.py"));
304        assert_eq!(out, PathBuf::from("a/b.py"));
305    }
306
307    #[test]
308    fn header_extensions_are_detected() {
309        assert_eq!(detect_language(&PathBuf::from("foo.h")), Some(LangId::C));
310        assert_eq!(
311            detect_language(&PathBuf::from("foo.hpp")),
312            Some(LangId::Cpp)
313        );
314    }
315}