Skip to main content

krait/detect/
language.rs

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    /// Human-readable name for display.
14    #[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    /// File extensions associated with this language.
28    #[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    /// Workspace marker files that indicate this language's project root.
40    /// Used by `find_package_roots()` for monorepo workspace detection.
41    #[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    /// All language variants.
53    pub const ALL: &'static [Language] = &[
54        Language::Rust,
55        Language::TypeScript,
56        Language::JavaScript,
57        Language::Go,
58        Language::Cpp,
59    ];
60}
61
62/// Determine the language for a file based on its extension.
63/// Delegates to `Language::extensions()` — single source of truth.
64#[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
79/// Common JS/TS monorepo subdirectory conventions.
80const MONOREPO_DIRS: &[&str] = &["packages", "apps", "libs", "src"];
81
82/// Detect languages used in a project by scanning for config files.
83/// Marker file names come from `Language::workspace_markers()` — single source of truth.
84/// Returns languages in priority order.
85#[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    // TypeScript and JavaScript share package.json; tsconfig.json or .ts files disambiguate.
98    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    // Monorepo: scan well-known subdirectory conventions for tsconfig or .ts files
141    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                    // tsconfig.json in a package is a strong signal
148                    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        // Projects like `meet` where TS packages live under src/frontend, src/sdk/...
229        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}