1use std::path::Path;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4pub enum Language {
5 Rust,
6 TypeScript,
7 JavaScript,
8 Go,
9 Cpp,
10}
11
12impl Language {
13 #[must_use]
15 pub fn name(self) -> &'static str {
16 match self {
17 Self::Rust => "rust",
18 Self::TypeScript => "typescript",
19 Self::JavaScript => "javascript",
20 Self::Go => "go",
21 Self::Cpp => "c++",
22 }
23 }
24}
25
26impl Language {
27 #[must_use]
29 pub fn extensions(self) -> &'static [&'static str] {
30 match self {
31 Self::Rust => &["rs"],
32 Self::TypeScript => &["ts", "tsx"],
33 Self::JavaScript => &["js", "jsx", "mjs", "cjs"],
34 Self::Go => &["go"],
35 Self::Cpp => &["c", "cpp", "cc", "cxx", "h", "hpp", "hxx"],
36 }
37 }
38
39 #[must_use]
42 pub fn workspace_markers(self) -> &'static [&'static str] {
43 match self {
44 Self::Rust => &["Cargo.toml"],
45 Self::TypeScript => &["tsconfig.json"],
46 Self::JavaScript => &["package.json"],
47 Self::Go => &["go.mod"],
48 Self::Cpp => &["CMakeLists.txt", "compile_commands.json"],
49 }
50 }
51
52 pub const ALL: &'static [Language] = &[
54 Language::Rust,
55 Language::TypeScript,
56 Language::JavaScript,
57 Language::Go,
58 Language::Cpp,
59 ];
60}
61
62#[must_use]
65pub fn language_for_file(path: &Path) -> Option<Language> {
66 let ext = path.extension()?.to_str()?;
67 Language::ALL
68 .iter()
69 .copied()
70 .find(|lang| lang.extensions().contains(&ext))
71}
72
73impl std::fmt::Display for Language {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.write_str(self.name())
76 }
77}
78
79const MONOREPO_DIRS: &[&str] = &["packages", "apps", "libs", "src"];
81
82#[must_use]
86pub fn detect_languages(root: &Path) -> Vec<Language> {
87 let mut languages = Vec::new();
88
89 if Language::Rust
90 .workspace_markers()
91 .iter()
92 .any(|m| root.join(m).exists())
93 {
94 languages.push(Language::Rust);
95 }
96
97 let has_tsconfig = Language::TypeScript
99 .workspace_markers()
100 .iter()
101 .any(|m| root.join(m).exists());
102 let has_package_json = Language::JavaScript
103 .workspace_markers()
104 .iter()
105 .any(|m| root.join(m).exists());
106
107 if has_tsconfig || has_ts_files(root) {
108 languages.push(Language::TypeScript);
109 } else if has_package_json {
110 languages.push(Language::JavaScript);
111 }
112
113 if Language::Go
114 .workspace_markers()
115 .iter()
116 .any(|m| root.join(m).exists())
117 {
118 languages.push(Language::Go);
119 }
120
121 if Language::Cpp
122 .workspace_markers()
123 .iter()
124 .any(|m| root.join(m).exists())
125 {
126 languages.push(Language::Cpp);
127 }
128
129 languages
130}
131
132fn has_ts_files(root: &Path) -> bool {
133 let mut dirs = Vec::new();
134 let src = root.join("src");
135 if src.is_dir() {
136 dirs.push(src);
137 }
138 dirs.push(root.to_path_buf());
139
140 for &pkg_dir in MONOREPO_DIRS {
142 let pd = root.join(pkg_dir);
143 if let Ok(entries) = std::fs::read_dir(&pd) {
144 for entry in entries.filter_map(Result::ok) {
145 let pkg = entry.path();
146 if pkg.is_dir() {
147 if Language::TypeScript
149 .workspace_markers()
150 .iter()
151 .any(|m| pkg.join(m).exists())
152 {
153 return true;
154 }
155 let pkg_src = pkg.join("src");
156 if pkg_src.is_dir() {
157 dirs.push(pkg_src);
158 }
159 }
160 }
161 }
162 }
163
164 let ts_exts = Language::TypeScript.extensions();
165 for dir in &dirs {
166 let Ok(entries) = std::fs::read_dir(dir) else {
167 continue;
168 };
169 if entries.filter_map(Result::ok).any(|e| {
170 e.path()
171 .extension()
172 .and_then(|x| x.to_str())
173 .is_some_and(|x| ts_exts.contains(&x))
174 }) {
175 return true;
176 }
177 }
178 false
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn detects_rust_project() {
187 let dir = tempfile::tempdir().unwrap();
188 std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
189
190 let langs = detect_languages(dir.path());
191 assert_eq!(langs, vec![Language::Rust]);
192 }
193
194 #[test]
195 fn detects_typescript_project() {
196 let dir = tempfile::tempdir().unwrap();
197 std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
198
199 let langs = detect_languages(dir.path());
200 assert_eq!(langs, vec![Language::TypeScript]);
201 }
202
203 #[test]
204 fn detects_typescript_from_package_json_with_ts_files() {
205 let dir = tempfile::tempdir().unwrap();
206 std::fs::write(dir.path().join("package.json"), "{}").unwrap();
207 std::fs::create_dir(dir.path().join("src")).unwrap();
208 std::fs::write(dir.path().join("src/index.ts"), "").unwrap();
209
210 let langs = detect_languages(dir.path());
211 assert_eq!(langs, vec![Language::TypeScript]);
212 }
213
214 #[test]
215 fn detects_typescript_monorepo_with_packages() {
216 let dir = tempfile::tempdir().unwrap();
217 std::fs::write(dir.path().join("package.json"), "{}").unwrap();
218 let pkg = dir.path().join("packages/api");
219 std::fs::create_dir_all(&pkg).unwrap();
220 std::fs::write(pkg.join("tsconfig.json"), "{}").unwrap();
221
222 let langs = detect_languages(dir.path());
223 assert_eq!(langs, vec![Language::TypeScript]);
224 }
225
226 #[test]
227 fn detects_typescript_nested_under_src() {
228 let dir = tempfile::tempdir().unwrap();
230 let pkg = dir.path().join("src/frontend");
231 std::fs::create_dir_all(&pkg).unwrap();
232 std::fs::write(pkg.join("tsconfig.json"), "{}").unwrap();
233
234 let langs = detect_languages(dir.path());
235 assert_eq!(langs, vec![Language::TypeScript]);
236 }
237
238 #[test]
239 fn detects_javascript_from_package_json_without_ts() {
240 let dir = tempfile::tempdir().unwrap();
241 std::fs::write(dir.path().join("package.json"), "{}").unwrap();
242
243 let langs = detect_languages(dir.path());
244 assert_eq!(langs, vec![Language::JavaScript]);
245 }
246
247 #[test]
248 fn detects_go_project() {
249 let dir = tempfile::tempdir().unwrap();
250 std::fs::write(dir.path().join("go.mod"), "").unwrap();
251
252 let langs = detect_languages(dir.path());
253 assert_eq!(langs, vec![Language::Go]);
254 }
255
256 #[test]
257 fn detects_polyglot() {
258 let dir = tempfile::tempdir().unwrap();
259 std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
260 std::fs::write(dir.path().join("package.json"), "{}").unwrap();
261
262 let langs = detect_languages(dir.path());
263 assert_eq!(langs, vec![Language::Rust, Language::JavaScript]);
264 }
265
266 #[test]
267 fn empty_project_returns_empty() {
268 let dir = tempfile::tempdir().unwrap();
269 let langs = detect_languages(dir.path());
270 assert!(langs.is_empty());
271 }
272}