1use 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
28pub fn simplified_path(path: &Path) -> std::path::PathBuf {
33 dunce::simplified(path).to_path_buf()
34}
35
36pub 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 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 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}