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    Lua,
25    Luau,
26    Vue,
27    Svelte,
28    /// Godot `PackedScene` text format (`.tscn`): not source code, but carries
29    /// Scene→Script dependency edges.
30    Tscn,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct LanguageCapabilities {
35    pub deps_edges: bool,
36    pub deep_queries: bool,
37    pub import_resolver: bool,
38}
39
40impl LanguageId {
41    pub fn id_str(&self) -> &'static str {
42        match self {
43            LanguageId::Rust => "rust",
44            LanguageId::TypeScript => "typescript",
45            LanguageId::JavaScript => "javascript",
46            LanguageId::Python => "python",
47            LanguageId::Go => "go",
48            LanguageId::Java => "java",
49            LanguageId::C => "c",
50            LanguageId::Cpp => "cpp",
51            LanguageId::Ruby => "ruby",
52            LanguageId::CSharp => "csharp",
53            LanguageId::Kotlin => "kotlin",
54            LanguageId::Swift => "swift",
55            LanguageId::Php => "php",
56            LanguageId::Bash => "bash",
57            LanguageId::Dart => "dart",
58            LanguageId::Scala => "scala",
59            LanguageId::Elixir => "elixir",
60            LanguageId::Zig => "zig",
61            LanguageId::Gdscript => "gdscript",
62            LanguageId::Lua => "lua",
63            LanguageId::Luau => "luau",
64            LanguageId::Vue => "vue",
65            LanguageId::Svelte => "svelte",
66            LanguageId::Tscn => "tscn",
67        }
68    }
69}
70
71pub fn capabilities(lang: LanguageId) -> LanguageCapabilities {
72    match lang {
73        // tree-sitter backed (deep_queries + resolver can be meaningful)
74        LanguageId::Rust
75        | LanguageId::TypeScript
76        | LanguageId::JavaScript
77        | LanguageId::Python
78        | LanguageId::Go
79        | LanguageId::Java
80        | LanguageId::C
81        | LanguageId::Cpp
82        | LanguageId::Ruby
83        | LanguageId::CSharp
84        | LanguageId::Kotlin
85        | LanguageId::Swift
86        | LanguageId::Php
87        | LanguageId::Bash
88        | LanguageId::Dart
89        | LanguageId::Scala
90        | LanguageId::Elixir
91        | LanguageId::Zig
92        | LanguageId::Gdscript
93        | LanguageId::Lua
94        | LanguageId::Luau => LanguageCapabilities {
95            deps_edges: true,
96            deep_queries: true,
97            import_resolver: true,
98        },
99        // templating languages: we can extract deps edges, but no deep_queries/resolver.
100        LanguageId::Vue | LanguageId::Svelte => LanguageCapabilities {
101            deps_edges: true,
102            deep_queries: false,
103            import_resolver: false,
104        },
105        // Godot scenes: no symbols (not source code), but resolved Scene→Script
106        // import edges via the GDScript `res://` resolver.
107        LanguageId::Tscn => LanguageCapabilities {
108            deps_edges: true,
109            deep_queries: false,
110            import_resolver: true,
111        },
112    }
113}
114
115pub fn language_for_ext(ext: &str) -> Option<LanguageId> {
116    let e = ext.trim().trim_start_matches('.').to_lowercase();
117    match e.as_str() {
118        "rs" => Some(LanguageId::Rust),
119        "ts" | "tsx" => Some(LanguageId::TypeScript),
120        "js" | "jsx" => Some(LanguageId::JavaScript),
121        "py" => Some(LanguageId::Python),
122        "go" => Some(LanguageId::Go),
123        "java" => Some(LanguageId::Java),
124        "c" | "h" => Some(LanguageId::C),
125        "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Some(LanguageId::Cpp),
126        "rb" => Some(LanguageId::Ruby),
127        "cs" => Some(LanguageId::CSharp),
128        "kt" | "kts" => Some(LanguageId::Kotlin),
129        "swift" => Some(LanguageId::Swift),
130        "php" => Some(LanguageId::Php),
131        "sh" | "bash" => Some(LanguageId::Bash),
132        "dart" => Some(LanguageId::Dart),
133        "scala" | "sc" => Some(LanguageId::Scala),
134        "ex" | "exs" => Some(LanguageId::Elixir),
135        "zig" => Some(LanguageId::Zig),
136        "gd" => Some(LanguageId::Gdscript),
137        "lua" => Some(LanguageId::Lua),
138        "luau" => Some(LanguageId::Luau),
139        "vue" => Some(LanguageId::Vue),
140        "svelte" => Some(LanguageId::Svelte),
141        "tscn" => Some(LanguageId::Tscn),
142        _ => None,
143    }
144}
145
146pub fn language_for_path(path: &str) -> Option<LanguageId> {
147    std::path::Path::new(path)
148        .extension()
149        .and_then(|e| e.to_str())
150        .and_then(language_for_ext)
151}
152
153pub fn is_indexable_ext(ext: &str) -> bool {
154    language_for_ext(ext).is_some()
155}
156
157/// Every language the property graph / code-map can index, for capability
158/// enumeration and UI hints. Keep in sync with `language_for_ext`.
159pub const ALL_LANGUAGES: &[LanguageId] = &[
160    LanguageId::Rust,
161    LanguageId::TypeScript,
162    LanguageId::JavaScript,
163    LanguageId::Python,
164    LanguageId::Go,
165    LanguageId::Java,
166    LanguageId::C,
167    LanguageId::Cpp,
168    LanguageId::Ruby,
169    LanguageId::CSharp,
170    LanguageId::Kotlin,
171    LanguageId::Swift,
172    LanguageId::Php,
173    LanguageId::Bash,
174    LanguageId::Dart,
175    LanguageId::Scala,
176    LanguageId::Elixir,
177    LanguageId::Zig,
178    LanguageId::Gdscript,
179    LanguageId::Lua,
180    LanguageId::Luau,
181    LanguageId::Vue,
182    LanguageId::Svelte,
183    LanguageId::Tscn,
184];
185
186/// Friendly names of every graph-indexable language (e.g. for an empty-graph hint).
187pub fn graph_supported_language_names() -> Vec<&'static str> {
188    ALL_LANGUAGES.iter().map(LanguageId::id_str).collect()
189}
190
191/// Whether lean-ctx extracts call sites for a language (i.e. it can populate the
192/// call graph). Keep in sync with `deep_queries::calls::parse_call` — a language
193/// missing there yields zero call edges, which the dashboard must communicate
194/// honestly instead of suggesting an index rebuild that cannot help.
195pub fn supports_call_graph(lang: LanguageId) -> bool {
196    matches!(
197        lang,
198        LanguageId::TypeScript
199            | LanguageId::JavaScript
200            | LanguageId::Rust
201            | LanguageId::Python
202            | LanguageId::Go
203            | LanguageId::Java
204            | LanguageId::Kotlin
205            | LanguageId::Gdscript
206            | LanguageId::Lua
207            | LanguageId::Luau
208            | LanguageId::CSharp
209    )
210}
211
212/// Friendly names of every language with call-graph extraction support.
213pub fn callgraph_supported_language_names() -> Vec<&'static str> {
214    ALL_LANGUAGES
215        .iter()
216        .filter(|l| supports_call_graph(**l))
217        .map(LanguageId::id_str)
218        .collect()
219}
220
221/// Per-language capability row for a project: which analyses are available for a
222/// language and how many files use it. Backs the dashboard capability legend so
223/// each detected language is labelled honestly (symbols / import edges / calls).
224#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
225pub struct LanguageCapabilityRow {
226    pub language: &'static str,
227    pub files: usize,
228    pub symbols: bool,
229    pub imports: bool,
230    pub call_graph: bool,
231    /// Symbols actually extracted for this language in *this* project. `None`
232    /// when realized counts weren't measured in the calling context.
233    pub symbols_found: Option<usize>,
234    /// Import/reexport edges whose source file is in this language.
235    pub imports_found: Option<usize>,
236    /// Call edges whose caller file is in this language. `None` when call data
237    /// isn't available in the calling context (e.g. the dependency-graph route).
238    pub calls_found: Option<usize>,
239    /// Standalone language server for semantic verification and whether it
240    /// can run here (`None` = not measured in this context).
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub semantic_server: Option<crate::core::semantic::coverage::ServerStatus>,
243    /// File-level call edges of this language and how many are verified by a
244    /// semantic backend (`None` = no evidence-carrying graph in this context).
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub calls_verified: Option<usize>,
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub calls_with_evidence: Option<usize>,
249    /// What the last backend started for this language negotiated.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub semantic_negotiated: Option<crate::core::semantic::coverage::NegotiatedBackend>,
252}
253
254/// Build a capability matrix for the languages actually present in `file_paths`,
255/// sorted by file count (desc) then name. `symbols`/`imports` come from
256/// `capabilities()`; `call_graph` from `supports_call_graph()`.
257pub fn language_capability_matrix<I, S>(file_paths: I) -> Vec<LanguageCapabilityRow>
258where
259    I: IntoIterator<Item = S>,
260    S: AsRef<str>,
261{
262    let mut counts: std::collections::HashMap<LanguageId, usize> = std::collections::HashMap::new();
263    for path in file_paths {
264        if let Some(lang) = language_for_path(path.as_ref()) {
265            *counts.entry(lang).or_default() += 1;
266        }
267    }
268    let mut rows: Vec<LanguageCapabilityRow> = counts
269        .into_iter()
270        .map(|(lang, files)| {
271            let caps = capabilities(lang);
272            LanguageCapabilityRow {
273                language: lang.id_str(),
274                files,
275                symbols: caps.deep_queries,
276                imports: caps.import_resolver,
277                call_graph: supports_call_graph(lang),
278                symbols_found: None,
279                imports_found: None,
280                calls_found: None,
281                semantic_server: None,
282                semantic_negotiated: None,
283                calls_verified: None,
284                calls_with_evidence: None,
285            }
286        })
287        .collect();
288    rows.sort_by(|a, b| {
289        b.files
290            .cmp(&a.files)
291            .then_with(|| a.language.cmp(b.language))
292    });
293    rows
294}
295
296/// Like [`language_capability_matrix`] but enriched with *realized* counts for
297/// this project: how many symbols, import edges and (optionally) call edges were
298/// actually produced per language — not merely whether the language *could*
299/// produce them. This turns an honest "imports ✓" into "imports ✓ (142)" / "✓
300/// (0 found)", so an empty graph view explains itself.
301///
302/// Inputs are plain path lists so this stays decoupled from the index types:
303/// - `file_paths`: every indexed file (drives the per-language file count),
304/// - `symbol_files`: the file of each extracted symbol,
305/// - `import_from_files`: the source file of each import/reexport edge,
306/// - `call_caller_files`: the caller file of each call edge, or `None` when call
307///   data isn't available (then `calls_found` stays `None`).
308pub fn language_capability_matrix_realized(
309    file_paths: &[String],
310    symbol_files: &[String],
311    import_from_files: &[String],
312    call_caller_files: Option<&[String]>,
313) -> Vec<LanguageCapabilityRow> {
314    use std::collections::HashMap;
315
316    fn tally(paths: &[String], acc: &mut HashMap<LanguageId, usize>) {
317        for p in paths {
318            if let Some(lang) = language_for_path(p) {
319                *acc.entry(lang).or_default() += 1;
320            }
321        }
322    }
323
324    let mut files: HashMap<LanguageId, usize> = HashMap::new();
325    let mut symbols: HashMap<LanguageId, usize> = HashMap::new();
326    let mut imports: HashMap<LanguageId, usize> = HashMap::new();
327    let mut calls: HashMap<LanguageId, usize> = HashMap::new();
328    tally(file_paths, &mut files);
329    tally(symbol_files, &mut symbols);
330    tally(import_from_files, &mut imports);
331    if let Some(callers) = call_caller_files {
332        tally(callers, &mut calls);
333    }
334
335    let mut rows: Vec<LanguageCapabilityRow> = files
336        .into_iter()
337        .map(|(lang, file_count)| {
338            let caps = capabilities(lang);
339            LanguageCapabilityRow {
340                language: lang.id_str(),
341                files: file_count,
342                symbols: caps.deep_queries,
343                imports: caps.import_resolver,
344                call_graph: supports_call_graph(lang),
345                symbols_found: Some(symbols.get(&lang).copied().unwrap_or(0)),
346                imports_found: Some(imports.get(&lang).copied().unwrap_or(0)),
347                calls_found: call_caller_files.map(|_| calls.get(&lang).copied().unwrap_or(0)),
348                semantic_server: None,
349                semantic_negotiated: None,
350                calls_verified: None,
351                calls_with_evidence: None,
352            }
353        })
354        .collect();
355    rows.sort_by(|a, b| {
356        b.files
357            .cmp(&a.files)
358            .then_with(|| a.language.cmp(b.language))
359    });
360    rows
361}
362
363/// Maps a file extension to a human-readable *programming language* name that
364/// lean-ctx recognizes but does **not** graph-index. Returns `None` for
365/// graph-indexed languages and for non-code files (docs, data, config). Used
366/// only to explain an empty graph — e.g. an R or Julia project. (Lua/Luau are
367/// now first-class graph-indexed languages, see #360.)
368fn unsupported_source_language_name(ext: &str) -> Option<&'static str> {
369    match ext.trim().trim_start_matches('.').to_lowercase().as_str() {
370        "r" => Some("R"),
371        "jl" => Some("Julia"),
372        "nim" => Some("Nim"),
373        "cr" => Some("Crystal"),
374        "clj" | "cljs" | "cljc" => Some("Clojure"),
375        "erl" | "hrl" => Some("Erlang"),
376        "hs" => Some("Haskell"),
377        "ml" | "mli" => Some("OCaml"),
378        "fs" | "fsx" => Some("F#"),
379        "pl" | "pm" => Some("Perl"),
380        "groovy" | "gradle" => Some("Groovy"),
381        "tf" => Some("Terraform"),
382        "sol" => Some("Solidity"),
383        "f90" | "f95" | "f03" => Some("Fortran"),
384        "pas" => Some("Pascal"),
385        "d" => Some("D"),
386        "sql" => Some("SQL"),
387        "tcl" => Some("Tcl"),
388        "raku" | "rakumod" => Some("Raku"),
389        _ => None,
390    }
391}
392
393/// Bounded project scan returning programming languages present in `root` that
394/// lean-ctx does **not** graph-index, with file counts (descending, capped to 5).
395/// Honors .gitignore/hidden like the graph walker and stops after `max_entries`
396/// filesystem entries. Lets the dashboard turn a confusing empty graph into a
397/// clear "Lua is not graph-indexed" message instead of an endless loading state.
398pub fn scan_unsupported_source_languages(root: &str, max_entries: usize) -> Vec<(String, usize)> {
399    let mut counts: std::collections::HashMap<&'static str, usize> =
400        std::collections::HashMap::new();
401    let walker = ignore::WalkBuilder::new(root)
402        .hidden(true)
403        .git_ignore(true)
404        .git_global(true)
405        .git_exclude(true)
406        .require_git(false)
407        .max_depth(Some(20))
408        .filter_entry(crate::core::walk_filter::keep_entry)
409        .build();
410    for entry in walker.flatten().take(max_entries) {
411        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
412            continue;
413        }
414        let ext = entry
415            .path()
416            .extension()
417            .and_then(|e| e.to_str())
418            .unwrap_or("");
419        if let Some(name) = unsupported_source_language_name(ext) {
420            *counts.entry(name).or_default() += 1;
421        }
422    }
423    let mut ranked: Vec<(String, usize)> = counts
424        .into_iter()
425        .map(|(k, c)| (k.to_string(), c))
426        .collect();
427    ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
428    ranked.truncate(5);
429    ranked
430}
431
432#[cfg(test)]
433pub mod tests {
434    use super::*;
435
436    #[test]
437    fn ext_mapping_basic() {
438        assert_eq!(language_for_ext("rs"), Some(LanguageId::Rust));
439        assert_eq!(language_for_ext(".tsx"), Some(LanguageId::TypeScript));
440        assert_eq!(language_for_ext("JS"), Some(LanguageId::JavaScript));
441        assert_eq!(language_for_ext("hxx"), Some(LanguageId::Cpp));
442        assert_eq!(language_for_ext("exs"), Some(LanguageId::Elixir));
443        assert_eq!(language_for_ext("unknown"), None);
444    }
445
446    #[test]
447    fn indexable_ext_true_for_known() {
448        assert!(is_indexable_ext("rs"));
449        assert!(is_indexable_ext("vue"));
450        assert!(!is_indexable_ext("md"));
451    }
452
453    #[test]
454    fn caps_are_deterministic() {
455        let c1 = capabilities(LanguageId::Rust);
456        let c2 = capabilities(LanguageId::Rust);
457        assert_eq!(c1, c2);
458        assert!(c1.deps_edges);
459    }
460
461    #[test]
462    fn all_languages_match_ext_table() {
463        // Every enumerated language must be reachable via at least one extension,
464        // so the UI's "supported languages" list never drifts from reality.
465        for lang in ALL_LANGUAGES {
466            let names = graph_supported_language_names();
467            assert!(names.contains(&lang.id_str()));
468        }
469        assert!(graph_supported_language_names().contains(&"rust"));
470        assert_eq!(ALL_LANGUAGES.len(), graph_supported_language_names().len());
471    }
472
473    #[test]
474    fn callgraph_support_is_consistent() {
475        // C# must be call-graph capable (issue: NINA's empty Call Graph tab).
476        assert!(supports_call_graph(LanguageId::CSharp));
477        let names = callgraph_supported_language_names();
478        assert!(names.contains(&"csharp"));
479        assert!(names.contains(&"rust"));
480        assert!(names.contains(&"typescript"));
481        // Every call-graph language is also graph-indexable, and the list is a
482        // strict subset (some graph-indexed languages have no call extraction).
483        for name in &names {
484            assert!(graph_supported_language_names().contains(name));
485        }
486        assert!(names.len() <= ALL_LANGUAGES.len());
487        // A language without call extraction must report false.
488        assert!(!supports_call_graph(LanguageId::Ruby));
489    }
490
491    #[test]
492    fn capability_matrix_reports_per_language_support() {
493        let paths = ["a.rs", "b.rs", "c.rb", "d.cs", "readme.md"];
494        let matrix = language_capability_matrix(paths);
495
496        // Non-code files are excluded; three languages are detected.
497        assert_eq!(matrix.len(), 3);
498
499        let rust = matrix.iter().find(|r| r.language == "rust").unwrap();
500        assert_eq!(rust.files, 2);
501        assert!(rust.symbols && rust.imports && rust.call_graph);
502
503        // Ruby has symbols + imports but no call-graph extraction.
504        let ruby = matrix.iter().find(|r| r.language == "ruby").unwrap();
505        assert!(ruby.symbols && ruby.imports && !ruby.call_graph);
506
507        // C# is fully supported (the language behind the original bug report).
508        let csharp = matrix.iter().find(|r| r.language == "csharp").unwrap();
509        assert!(csharp.symbols && csharp.imports && csharp.call_graph);
510
511        // Sorted by file count desc → Rust (2 files) leads.
512        assert_eq!(matrix[0].language, "rust");
513    }
514
515    #[test]
516    fn realized_matrix_counts_actual_symbols_imports_calls() {
517        let files = vec!["a.rs".to_string(), "b.rs".to_string(), "c.rb".to_string()];
518        let symbol_files = vec!["a.rs".to_string(), "a.rs".to_string(), "c.rb".to_string()];
519        let import_from = vec!["a.rs".to_string()]; // one Rust import edge
520        let callers = vec!["b.rs".to_string()]; // one Rust call edge
521
522        let m = language_capability_matrix_realized(
523            &files,
524            &symbol_files,
525            &import_from,
526            Some(&callers),
527        );
528
529        let rust = m.iter().find(|r| r.language == "rust").unwrap();
530        assert_eq!(rust.files, 2);
531        assert_eq!(rust.symbols_found, Some(2));
532        assert_eq!(rust.imports_found, Some(1));
533        assert_eq!(rust.calls_found, Some(1));
534
535        let ruby = m.iter().find(|r| r.language == "ruby").unwrap();
536        assert_eq!(ruby.files, 1);
537        assert_eq!(ruby.symbols_found, Some(1));
538        assert_eq!(ruby.imports_found, Some(0)); // no Ruby import edges produced
539        assert_eq!(ruby.calls_found, Some(0));
540
541        // Without call data, `calls_found` stays None (honest "not measured").
542        let m2 = language_capability_matrix_realized(&files, &symbol_files, &import_from, None);
543        let rust2 = m2.iter().find(|r| r.language == "rust").unwrap();
544        assert_eq!(rust2.calls_found, None);
545    }
546
547    #[test]
548    fn lua_luau_are_first_class_indexed() {
549        // Lua/Luau (issue #360) are now graph-indexed, not "unsupported code".
550        assert_eq!(language_for_ext("lua"), Some(LanguageId::Lua));
551        assert_eq!(language_for_ext(".luau"), Some(LanguageId::Luau));
552        assert!(is_indexable_ext("lua"));
553        assert!(is_indexable_ext("luau"));
554        assert!(unsupported_source_language_name("lua").is_none());
555        assert!(unsupported_source_language_name("luau").is_none());
556        // They participate in symbols, import edges and the call graph.
557        assert!(supports_call_graph(LanguageId::Lua));
558        assert!(supports_call_graph(LanguageId::Luau));
559        let names = graph_supported_language_names();
560        assert!(names.contains(&"lua"));
561        assert!(names.contains(&"luau"));
562    }
563
564    #[test]
565    fn unsupported_source_languages_named_but_not_indexed() {
566        // Languages still recognized as code yet never graph-indexed.
567        assert_eq!(unsupported_source_language_name("r"), Some("R"));
568        assert_eq!(unsupported_source_language_name(".jl"), Some("Julia"));
569        assert!(!is_indexable_ext("r"));
570        assert!(!is_indexable_ext("jl"));
571        // Graph-indexed languages and plain data/docs are not reported as "unsupported code".
572        assert_eq!(unsupported_source_language_name("rs"), None);
573        assert_eq!(unsupported_source_language_name("lua"), None);
574        assert_eq!(unsupported_source_language_name("md"), None);
575        assert_eq!(unsupported_source_language_name("json"), None);
576    }
577
578    #[test]
579    fn scan_reports_unsupported_project() {
580        let dir = tempfile::tempdir().unwrap();
581        std::fs::write(dir.path().join("analysis.r"), "x <- 1").unwrap();
582        std::fs::write(dir.path().join("model.jl"), "x = 1").unwrap();
583        std::fs::write(dir.path().join("README.md"), "# docs").unwrap();
584        let found = scan_unsupported_source_languages(&dir.path().to_string_lossy(), 1000);
585        let names: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect();
586        assert!(names.contains(&"R"));
587        assert!(names.contains(&"Julia"));
588    }
589}