1use serde::Serialize;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4pub enum LanguageId {
5 Rust,
6 TypeScript,
7 JavaScript,
8 Python,
9 Go,
10 Java,
11 C,
12 Cpp,
13 Ruby,
14 CSharp,
15 Kotlin,
16 Swift,
17 Php,
18 Bash,
19 Dart,
20 Scala,
21 Elixir,
22 Zig,
23 Gdscript,
24 Vue,
25 Svelte,
26 Tscn,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct LanguageCapabilities {
33 pub deps_edges: bool,
34 pub deep_queries: bool,
35 pub import_resolver: bool,
36}
37
38impl LanguageId {
39 pub fn id_str(&self) -> &'static str {
40 match self {
41 LanguageId::Rust => "rust",
42 LanguageId::TypeScript => "typescript",
43 LanguageId::JavaScript => "javascript",
44 LanguageId::Python => "python",
45 LanguageId::Go => "go",
46 LanguageId::Java => "java",
47 LanguageId::C => "c",
48 LanguageId::Cpp => "cpp",
49 LanguageId::Ruby => "ruby",
50 LanguageId::CSharp => "csharp",
51 LanguageId::Kotlin => "kotlin",
52 LanguageId::Swift => "swift",
53 LanguageId::Php => "php",
54 LanguageId::Bash => "bash",
55 LanguageId::Dart => "dart",
56 LanguageId::Scala => "scala",
57 LanguageId::Elixir => "elixir",
58 LanguageId::Zig => "zig",
59 LanguageId::Gdscript => "gdscript",
60 LanguageId::Vue => "vue",
61 LanguageId::Svelte => "svelte",
62 LanguageId::Tscn => "tscn",
63 }
64 }
65}
66
67pub fn capabilities(lang: LanguageId) -> LanguageCapabilities {
68 match lang {
69 LanguageId::Rust
71 | LanguageId::TypeScript
72 | LanguageId::JavaScript
73 | LanguageId::Python
74 | LanguageId::Go
75 | LanguageId::Java
76 | LanguageId::C
77 | LanguageId::Cpp
78 | LanguageId::Ruby
79 | LanguageId::CSharp
80 | LanguageId::Kotlin
81 | LanguageId::Swift
82 | LanguageId::Php
83 | LanguageId::Bash
84 | LanguageId::Dart
85 | LanguageId::Scala
86 | LanguageId::Elixir
87 | LanguageId::Zig
88 | LanguageId::Gdscript => LanguageCapabilities {
89 deps_edges: true,
90 deep_queries: true,
91 import_resolver: true,
92 },
93 LanguageId::Vue | LanguageId::Svelte => LanguageCapabilities {
95 deps_edges: true,
96 deep_queries: false,
97 import_resolver: false,
98 },
99 LanguageId::Tscn => LanguageCapabilities {
102 deps_edges: true,
103 deep_queries: false,
104 import_resolver: true,
105 },
106 }
107}
108
109pub fn language_for_ext(ext: &str) -> Option<LanguageId> {
110 let e = ext.trim().trim_start_matches('.').to_lowercase();
111 match e.as_str() {
112 "rs" => Some(LanguageId::Rust),
113 "ts" | "tsx" => Some(LanguageId::TypeScript),
114 "js" | "jsx" => Some(LanguageId::JavaScript),
115 "py" => Some(LanguageId::Python),
116 "go" => Some(LanguageId::Go),
117 "java" => Some(LanguageId::Java),
118 "c" | "h" => Some(LanguageId::C),
119 "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Some(LanguageId::Cpp),
120 "rb" => Some(LanguageId::Ruby),
121 "cs" => Some(LanguageId::CSharp),
122 "kt" | "kts" => Some(LanguageId::Kotlin),
123 "swift" => Some(LanguageId::Swift),
124 "php" => Some(LanguageId::Php),
125 "sh" | "bash" => Some(LanguageId::Bash),
126 "dart" => Some(LanguageId::Dart),
127 "scala" | "sc" => Some(LanguageId::Scala),
128 "ex" | "exs" => Some(LanguageId::Elixir),
129 "zig" => Some(LanguageId::Zig),
130 "gd" => Some(LanguageId::Gdscript),
131 "vue" => Some(LanguageId::Vue),
132 "svelte" => Some(LanguageId::Svelte),
133 "tscn" => Some(LanguageId::Tscn),
134 _ => None,
135 }
136}
137
138pub fn language_for_path(path: &str) -> Option<LanguageId> {
139 std::path::Path::new(path)
140 .extension()
141 .and_then(|e| e.to_str())
142 .and_then(language_for_ext)
143}
144
145pub fn is_indexable_ext(ext: &str) -> bool {
146 language_for_ext(ext).is_some()
147}
148
149pub const ALL_LANGUAGES: &[LanguageId] = &[
152 LanguageId::Rust,
153 LanguageId::TypeScript,
154 LanguageId::JavaScript,
155 LanguageId::Python,
156 LanguageId::Go,
157 LanguageId::Java,
158 LanguageId::C,
159 LanguageId::Cpp,
160 LanguageId::Ruby,
161 LanguageId::CSharp,
162 LanguageId::Kotlin,
163 LanguageId::Swift,
164 LanguageId::Php,
165 LanguageId::Bash,
166 LanguageId::Dart,
167 LanguageId::Scala,
168 LanguageId::Elixir,
169 LanguageId::Zig,
170 LanguageId::Gdscript,
171 LanguageId::Vue,
172 LanguageId::Svelte,
173 LanguageId::Tscn,
174];
175
176pub fn graph_supported_language_names() -> Vec<&'static str> {
178 ALL_LANGUAGES.iter().map(LanguageId::id_str).collect()
179}
180
181pub fn supports_call_graph(lang: LanguageId) -> bool {
186 matches!(
187 lang,
188 LanguageId::TypeScript
189 | LanguageId::JavaScript
190 | LanguageId::Rust
191 | LanguageId::Python
192 | LanguageId::Go
193 | LanguageId::Java
194 | LanguageId::Kotlin
195 | LanguageId::Gdscript
196 | LanguageId::CSharp
197 )
198}
199
200pub fn callgraph_supported_language_names() -> Vec<&'static str> {
202 ALL_LANGUAGES
203 .iter()
204 .filter(|l| supports_call_graph(**l))
205 .map(LanguageId::id_str)
206 .collect()
207}
208
209#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
213pub struct LanguageCapabilityRow {
214 pub language: &'static str,
215 pub files: usize,
216 pub symbols: bool,
217 pub imports: bool,
218 pub call_graph: bool,
219 pub symbols_found: Option<usize>,
222 pub imports_found: Option<usize>,
224 pub calls_found: Option<usize>,
227}
228
229pub fn language_capability_matrix<I, S>(file_paths: I) -> Vec<LanguageCapabilityRow>
233where
234 I: IntoIterator<Item = S>,
235 S: AsRef<str>,
236{
237 let mut counts: std::collections::HashMap<LanguageId, usize> = std::collections::HashMap::new();
238 for path in file_paths {
239 if let Some(lang) = language_for_path(path.as_ref()) {
240 *counts.entry(lang).or_default() += 1;
241 }
242 }
243 let mut rows: Vec<LanguageCapabilityRow> = counts
244 .into_iter()
245 .map(|(lang, files)| {
246 let caps = capabilities(lang);
247 LanguageCapabilityRow {
248 language: lang.id_str(),
249 files,
250 symbols: caps.deep_queries,
251 imports: caps.import_resolver,
252 call_graph: supports_call_graph(lang),
253 symbols_found: None,
254 imports_found: None,
255 calls_found: None,
256 }
257 })
258 .collect();
259 rows.sort_by(|a, b| {
260 b.files
261 .cmp(&a.files)
262 .then_with(|| a.language.cmp(b.language))
263 });
264 rows
265}
266
267pub fn language_capability_matrix_realized(
280 file_paths: &[String],
281 symbol_files: &[String],
282 import_from_files: &[String],
283 call_caller_files: Option<&[String]>,
284) -> Vec<LanguageCapabilityRow> {
285 use std::collections::HashMap;
286
287 fn tally(paths: &[String], acc: &mut HashMap<LanguageId, usize>) {
288 for p in paths {
289 if let Some(lang) = language_for_path(p) {
290 *acc.entry(lang).or_default() += 1;
291 }
292 }
293 }
294
295 let mut files: HashMap<LanguageId, usize> = HashMap::new();
296 let mut symbols: HashMap<LanguageId, usize> = HashMap::new();
297 let mut imports: HashMap<LanguageId, usize> = HashMap::new();
298 let mut calls: HashMap<LanguageId, usize> = HashMap::new();
299 tally(file_paths, &mut files);
300 tally(symbol_files, &mut symbols);
301 tally(import_from_files, &mut imports);
302 if let Some(callers) = call_caller_files {
303 tally(callers, &mut calls);
304 }
305
306 let mut rows: Vec<LanguageCapabilityRow> = files
307 .into_iter()
308 .map(|(lang, file_count)| {
309 let caps = capabilities(lang);
310 LanguageCapabilityRow {
311 language: lang.id_str(),
312 files: file_count,
313 symbols: caps.deep_queries,
314 imports: caps.import_resolver,
315 call_graph: supports_call_graph(lang),
316 symbols_found: Some(symbols.get(&lang).copied().unwrap_or(0)),
317 imports_found: Some(imports.get(&lang).copied().unwrap_or(0)),
318 calls_found: call_caller_files.map(|_| calls.get(&lang).copied().unwrap_or(0)),
319 }
320 })
321 .collect();
322 rows.sort_by(|a, b| {
323 b.files
324 .cmp(&a.files)
325 .then_with(|| a.language.cmp(b.language))
326 });
327 rows
328}
329
330fn unsupported_source_language_name(ext: &str) -> Option<&'static str> {
335 match ext.trim().trim_start_matches('.').to_lowercase().as_str() {
336 "lua" => Some("Lua"),
337 "luau" => Some("Luau"),
338 "r" => Some("R"),
339 "jl" => Some("Julia"),
340 "nim" => Some("Nim"),
341 "cr" => Some("Crystal"),
342 "clj" | "cljs" | "cljc" => Some("Clojure"),
343 "erl" | "hrl" => Some("Erlang"),
344 "hs" => Some("Haskell"),
345 "ml" | "mli" => Some("OCaml"),
346 "fs" | "fsx" => Some("F#"),
347 "pl" | "pm" => Some("Perl"),
348 "groovy" | "gradle" => Some("Groovy"),
349 "tf" => Some("Terraform"),
350 "sol" => Some("Solidity"),
351 "f90" | "f95" | "f03" => Some("Fortran"),
352 "pas" => Some("Pascal"),
353 "d" => Some("D"),
354 "sql" => Some("SQL"),
355 "tcl" => Some("Tcl"),
356 "raku" | "rakumod" => Some("Raku"),
357 _ => None,
358 }
359}
360
361pub fn scan_unsupported_source_languages(root: &str, max_entries: usize) -> Vec<(String, usize)> {
367 let mut counts: std::collections::HashMap<&'static str, usize> =
368 std::collections::HashMap::new();
369 let walker = ignore::WalkBuilder::new(root)
370 .hidden(true)
371 .git_ignore(true)
372 .git_global(true)
373 .git_exclude(true)
374 .max_depth(Some(20))
375 .build();
376 for entry in walker.flatten().take(max_entries) {
377 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
378 continue;
379 }
380 let ext = entry
381 .path()
382 .extension()
383 .and_then(|e| e.to_str())
384 .unwrap_or("");
385 if let Some(name) = unsupported_source_language_name(ext) {
386 *counts.entry(name).or_default() += 1;
387 }
388 }
389 let mut ranked: Vec<(String, usize)> = counts
390 .into_iter()
391 .map(|(k, c)| (k.to_string(), c))
392 .collect();
393 ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
394 ranked.truncate(5);
395 ranked
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn ext_mapping_basic() {
404 assert_eq!(language_for_ext("rs"), Some(LanguageId::Rust));
405 assert_eq!(language_for_ext(".tsx"), Some(LanguageId::TypeScript));
406 assert_eq!(language_for_ext("JS"), Some(LanguageId::JavaScript));
407 assert_eq!(language_for_ext("hxx"), Some(LanguageId::Cpp));
408 assert_eq!(language_for_ext("exs"), Some(LanguageId::Elixir));
409 assert_eq!(language_for_ext("unknown"), None);
410 }
411
412 #[test]
413 fn indexable_ext_true_for_known() {
414 assert!(is_indexable_ext("rs"));
415 assert!(is_indexable_ext("vue"));
416 assert!(!is_indexable_ext("md"));
417 }
418
419 #[test]
420 fn caps_are_deterministic() {
421 let c1 = capabilities(LanguageId::Rust);
422 let c2 = capabilities(LanguageId::Rust);
423 assert_eq!(c1, c2);
424 assert!(c1.deps_edges);
425 }
426
427 #[test]
428 fn all_languages_match_ext_table() {
429 for lang in ALL_LANGUAGES {
432 let names = graph_supported_language_names();
433 assert!(names.contains(&lang.id_str()));
434 }
435 assert!(graph_supported_language_names().contains(&"rust"));
436 assert_eq!(ALL_LANGUAGES.len(), graph_supported_language_names().len());
437 }
438
439 #[test]
440 fn callgraph_support_is_consistent() {
441 assert!(supports_call_graph(LanguageId::CSharp));
443 let names = callgraph_supported_language_names();
444 assert!(names.contains(&"csharp"));
445 assert!(names.contains(&"rust"));
446 assert!(names.contains(&"typescript"));
447 for name in &names {
450 assert!(graph_supported_language_names().contains(name));
451 }
452 assert!(names.len() <= ALL_LANGUAGES.len());
453 assert!(!supports_call_graph(LanguageId::Ruby));
455 }
456
457 #[test]
458 fn capability_matrix_reports_per_language_support() {
459 let paths = ["a.rs", "b.rs", "c.rb", "d.cs", "readme.md"];
460 let matrix = language_capability_matrix(paths);
461
462 assert_eq!(matrix.len(), 3);
464
465 let rust = matrix.iter().find(|r| r.language == "rust").unwrap();
466 assert_eq!(rust.files, 2);
467 assert!(rust.symbols && rust.imports && rust.call_graph);
468
469 let ruby = matrix.iter().find(|r| r.language == "ruby").unwrap();
471 assert!(ruby.symbols && ruby.imports && !ruby.call_graph);
472
473 let csharp = matrix.iter().find(|r| r.language == "csharp").unwrap();
475 assert!(csharp.symbols && csharp.imports && csharp.call_graph);
476
477 assert_eq!(matrix[0].language, "rust");
479 }
480
481 #[test]
482 fn realized_matrix_counts_actual_symbols_imports_calls() {
483 let files = vec!["a.rs".to_string(), "b.rs".to_string(), "c.rb".to_string()];
484 let symbol_files = vec!["a.rs".to_string(), "a.rs".to_string(), "c.rb".to_string()];
485 let import_from = vec!["a.rs".to_string()]; let callers = vec!["b.rs".to_string()]; let m = language_capability_matrix_realized(
489 &files,
490 &symbol_files,
491 &import_from,
492 Some(&callers),
493 );
494
495 let rust = m.iter().find(|r| r.language == "rust").unwrap();
496 assert_eq!(rust.files, 2);
497 assert_eq!(rust.symbols_found, Some(2));
498 assert_eq!(rust.imports_found, Some(1));
499 assert_eq!(rust.calls_found, Some(1));
500
501 let ruby = m.iter().find(|r| r.language == "ruby").unwrap();
502 assert_eq!(ruby.files, 1);
503 assert_eq!(ruby.symbols_found, Some(1));
504 assert_eq!(ruby.imports_found, Some(0)); assert_eq!(ruby.calls_found, Some(0));
506
507 let m2 = language_capability_matrix_realized(&files, &symbol_files, &import_from, None);
509 let rust2 = m2.iter().find(|r| r.language == "rust").unwrap();
510 assert_eq!(rust2.calls_found, None);
511 }
512
513 #[test]
514 fn unsupported_source_languages_named_but_not_indexed() {
515 assert_eq!(unsupported_source_language_name("lua"), Some("Lua"));
517 assert_eq!(unsupported_source_language_name(".luau"), Some("Luau"));
518 assert!(!is_indexable_ext("lua"));
519 assert!(!is_indexable_ext("luau"));
520 assert_eq!(unsupported_source_language_name("rs"), None);
522 assert_eq!(unsupported_source_language_name("md"), None);
523 assert_eq!(unsupported_source_language_name("json"), None);
524 }
525
526 #[test]
527 fn scan_reports_lua_project() {
528 let dir = tempfile::tempdir().unwrap();
529 std::fs::write(dir.path().join("init.lua"), "local x = 1").unwrap();
530 std::fs::write(dir.path().join("mod.luau"), "return {}").unwrap();
531 std::fs::write(dir.path().join("README.md"), "# docs").unwrap();
532 let found = scan_unsupported_source_languages(&dir.path().to_string_lossy(), 1000);
533 let names: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect();
534 assert!(names.contains(&"Lua"));
535 assert!(names.contains(&"Luau"));
536 }
537}