Skip to main content

lean_ctx/core/
language_capabilities.rs

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    /// Godot `PackedScene` text format (`.tscn`): not source code, but carries
27    /// Scene→Script dependency edges.
28    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        // tree-sitter backed (deep_queries + resolver can be meaningful)
70        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        // templating languages: we can extract deps edges, but no deep_queries/resolver.
94        LanguageId::Vue | LanguageId::Svelte => LanguageCapabilities {
95            deps_edges: true,
96            deep_queries: false,
97            import_resolver: false,
98        },
99        // Godot scenes: no symbols (not source code), but resolved Scene→Script
100        // import edges via the GDScript `res://` resolver.
101        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
149/// Every language the property graph / code-map can index, for capability
150/// enumeration and UI hints. Keep in sync with `language_for_ext`.
151pub 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
176/// Friendly names of every graph-indexable language (e.g. for an empty-graph hint).
177pub fn graph_supported_language_names() -> Vec<&'static str> {
178    ALL_LANGUAGES.iter().map(LanguageId::id_str).collect()
179}
180
181/// Whether lean-ctx extracts call sites for a language (i.e. it can populate the
182/// call graph). Keep in sync with `deep_queries::calls::parse_call` — a language
183/// missing there yields zero call edges, which the dashboard must communicate
184/// honestly instead of suggesting an index rebuild that cannot help.
185pub 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
200/// Friendly names of every language with call-graph extraction support.
201pub 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/// Per-language capability row for a project: which analyses are available for a
210/// language and how many files use it. Backs the dashboard capability legend so
211/// each detected language is labelled honestly (symbols / import edges / calls).
212#[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    /// Symbols actually extracted for this language in *this* project. `None`
220    /// when realized counts weren't measured in the calling context.
221    pub symbols_found: Option<usize>,
222    /// Import/reexport edges whose source file is in this language.
223    pub imports_found: Option<usize>,
224    /// Call edges whose caller file is in this language. `None` when call data
225    /// isn't available in the calling context (e.g. the dependency-graph route).
226    pub calls_found: Option<usize>,
227}
228
229/// Build a capability matrix for the languages actually present in `file_paths`,
230/// sorted by file count (desc) then name. `symbols`/`imports` come from
231/// `capabilities()`; `call_graph` from `supports_call_graph()`.
232pub 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
267/// Like [`language_capability_matrix`] but enriched with *realized* counts for
268/// this project: how many symbols, import edges and (optionally) call edges were
269/// actually produced per language — not merely whether the language *could*
270/// produce them. This turns an honest "imports ✓" into "imports ✓ (142)" / "✓
271/// (0 found)", so an empty graph view explains itself.
272///
273/// Inputs are plain path lists so this stays decoupled from the index types:
274/// - `file_paths`: every indexed file (drives the per-language file count),
275/// - `symbol_files`: the file of each extracted symbol,
276/// - `import_from_files`: the source file of each import/reexport edge,
277/// - `call_caller_files`: the caller file of each call edge, or `None` when call
278///   data isn't available (then `calls_found` stays `None`).
279pub 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
330/// Maps a file extension to a human-readable *programming language* name that
331/// lean-ctx recognizes but does **not** graph-index. Returns `None` for
332/// graph-indexed languages and for non-code files (docs, data, config). Used
333/// only to explain an empty graph — e.g. a Lua/Luau project (#360).
334fn 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
361/// Bounded project scan returning programming languages present in `root` that
362/// lean-ctx does **not** graph-index, with file counts (descending, capped to 5).
363/// Honors .gitignore/hidden like the graph walker and stops after `max_entries`
364/// filesystem entries. Lets the dashboard turn a confusing empty graph into a
365/// clear "Lua is not graph-indexed" message instead of an endless loading state.
366pub 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        .require_git(false)
375        .max_depth(Some(20))
376        .filter_entry(crate::core::walk_filter::keep_entry)
377        .build();
378    for entry in walker.flatten().take(max_entries) {
379        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
380            continue;
381        }
382        let ext = entry
383            .path()
384            .extension()
385            .and_then(|e| e.to_str())
386            .unwrap_or("");
387        if let Some(name) = unsupported_source_language_name(ext) {
388            *counts.entry(name).or_default() += 1;
389        }
390    }
391    let mut ranked: Vec<(String, usize)> = counts
392        .into_iter()
393        .map(|(k, c)| (k.to_string(), c))
394        .collect();
395    ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
396    ranked.truncate(5);
397    ranked
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn ext_mapping_basic() {
406        assert_eq!(language_for_ext("rs"), Some(LanguageId::Rust));
407        assert_eq!(language_for_ext(".tsx"), Some(LanguageId::TypeScript));
408        assert_eq!(language_for_ext("JS"), Some(LanguageId::JavaScript));
409        assert_eq!(language_for_ext("hxx"), Some(LanguageId::Cpp));
410        assert_eq!(language_for_ext("exs"), Some(LanguageId::Elixir));
411        assert_eq!(language_for_ext("unknown"), None);
412    }
413
414    #[test]
415    fn indexable_ext_true_for_known() {
416        assert!(is_indexable_ext("rs"));
417        assert!(is_indexable_ext("vue"));
418        assert!(!is_indexable_ext("md"));
419    }
420
421    #[test]
422    fn caps_are_deterministic() {
423        let c1 = capabilities(LanguageId::Rust);
424        let c2 = capabilities(LanguageId::Rust);
425        assert_eq!(c1, c2);
426        assert!(c1.deps_edges);
427    }
428
429    #[test]
430    fn all_languages_match_ext_table() {
431        // Every enumerated language must be reachable via at least one extension,
432        // so the UI's "supported languages" list never drifts from reality.
433        for lang in ALL_LANGUAGES {
434            let names = graph_supported_language_names();
435            assert!(names.contains(&lang.id_str()));
436        }
437        assert!(graph_supported_language_names().contains(&"rust"));
438        assert_eq!(ALL_LANGUAGES.len(), graph_supported_language_names().len());
439    }
440
441    #[test]
442    fn callgraph_support_is_consistent() {
443        // C# must be call-graph capable (issue: NINA's empty Call Graph tab).
444        assert!(supports_call_graph(LanguageId::CSharp));
445        let names = callgraph_supported_language_names();
446        assert!(names.contains(&"csharp"));
447        assert!(names.contains(&"rust"));
448        assert!(names.contains(&"typescript"));
449        // Every call-graph language is also graph-indexable, and the list is a
450        // strict subset (some graph-indexed languages have no call extraction).
451        for name in &names {
452            assert!(graph_supported_language_names().contains(name));
453        }
454        assert!(names.len() <= ALL_LANGUAGES.len());
455        // A language without call extraction must report false.
456        assert!(!supports_call_graph(LanguageId::Ruby));
457    }
458
459    #[test]
460    fn capability_matrix_reports_per_language_support() {
461        let paths = ["a.rs", "b.rs", "c.rb", "d.cs", "readme.md"];
462        let matrix = language_capability_matrix(paths);
463
464        // Non-code files are excluded; three languages are detected.
465        assert_eq!(matrix.len(), 3);
466
467        let rust = matrix.iter().find(|r| r.language == "rust").unwrap();
468        assert_eq!(rust.files, 2);
469        assert!(rust.symbols && rust.imports && rust.call_graph);
470
471        // Ruby has symbols + imports but no call-graph extraction.
472        let ruby = matrix.iter().find(|r| r.language == "ruby").unwrap();
473        assert!(ruby.symbols && ruby.imports && !ruby.call_graph);
474
475        // C# is fully supported (the language behind the original bug report).
476        let csharp = matrix.iter().find(|r| r.language == "csharp").unwrap();
477        assert!(csharp.symbols && csharp.imports && csharp.call_graph);
478
479        // Sorted by file count desc → Rust (2 files) leads.
480        assert_eq!(matrix[0].language, "rust");
481    }
482
483    #[test]
484    fn realized_matrix_counts_actual_symbols_imports_calls() {
485        let files = vec!["a.rs".to_string(), "b.rs".to_string(), "c.rb".to_string()];
486        let symbol_files = vec!["a.rs".to_string(), "a.rs".to_string(), "c.rb".to_string()];
487        let import_from = vec!["a.rs".to_string()]; // one Rust import edge
488        let callers = vec!["b.rs".to_string()]; // one Rust call edge
489
490        let m = language_capability_matrix_realized(
491            &files,
492            &symbol_files,
493            &import_from,
494            Some(&callers),
495        );
496
497        let rust = m.iter().find(|r| r.language == "rust").unwrap();
498        assert_eq!(rust.files, 2);
499        assert_eq!(rust.symbols_found, Some(2));
500        assert_eq!(rust.imports_found, Some(1));
501        assert_eq!(rust.calls_found, Some(1));
502
503        let ruby = m.iter().find(|r| r.language == "ruby").unwrap();
504        assert_eq!(ruby.files, 1);
505        assert_eq!(ruby.symbols_found, Some(1));
506        assert_eq!(ruby.imports_found, Some(0)); // no Ruby import edges produced
507        assert_eq!(ruby.calls_found, Some(0));
508
509        // Without call data, `calls_found` stays None (honest "not measured").
510        let m2 = language_capability_matrix_realized(&files, &symbol_files, &import_from, None);
511        let rust2 = m2.iter().find(|r| r.language == "rust").unwrap();
512        assert_eq!(rust2.calls_found, None);
513    }
514
515    #[test]
516    fn unsupported_source_languages_named_but_not_indexed() {
517        // Lua/Luau (issue #360) are recognized as code yet never graph-indexed.
518        assert_eq!(unsupported_source_language_name("lua"), Some("Lua"));
519        assert_eq!(unsupported_source_language_name(".luau"), Some("Luau"));
520        assert!(!is_indexable_ext("lua"));
521        assert!(!is_indexable_ext("luau"));
522        // Graph-indexed languages and plain data/docs are not reported as "unsupported code".
523        assert_eq!(unsupported_source_language_name("rs"), None);
524        assert_eq!(unsupported_source_language_name("md"), None);
525        assert_eq!(unsupported_source_language_name("json"), None);
526    }
527
528    #[test]
529    fn scan_reports_lua_project() {
530        let dir = tempfile::tempdir().unwrap();
531        std::fs::write(dir.path().join("init.lua"), "local x = 1").unwrap();
532        std::fs::write(dir.path().join("mod.luau"), "return {}").unwrap();
533        std::fs::write(dir.path().join("README.md"), "# docs").unwrap();
534        let found = scan_unsupported_source_languages(&dir.path().to_string_lossy(), 1000);
535        let names: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect();
536        assert!(names.contains(&"Lua"));
537        assert!(names.contains(&"Luau"));
538    }
539}