diff --git a/cli/src/parse.rs b/cli/src/parse.rs index cb32ff8e8..5c4930490 100644 --- a/cli/src/parse.rs +++ b/cli/src/parse.rs @@ -237,7 +237,7 @@ pub async fn run(cli: &CliArgs, out: &mut Output, output_mode: OutputMode) -> Re let canonical_path = std::fs::canonicalize(file_path) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| file_path.clone()); - let language = Language::from_path(file_path); + let language = crate::utils::detect_language_for_file(file_path); match language { Some(lang) => { if let Some((_, file_list)) = files_by_lang.iter_mut().find(|(l, _)| *l == lang) { diff --git a/cli/src/summarize.rs b/cli/src/summarize.rs index 1c7b6f730..1340099ca 100644 --- a/cli/src/summarize.rs +++ b/cli/src/summarize.rs @@ -174,7 +174,7 @@ fn collect_md_files(root: &Path) -> Vec { } async fn render_file_summary(file_path: &Path) -> Option { - let lang = Language::from_path(file_path.to_str()?)?; + let lang = crate::utils::detect_language_for_file(file_path.to_str()?)?; let ast_lang = Lang::from_language(lang); let repo = Repo::from_single_file(file_path.to_str()?, ast_lang, false, false, false).ok()?; diff --git a/cli/src/utils.rs b/cli/src/utils.rs index 063617955..d2351223f 100644 --- a/cli/src/utils.rs +++ b/cli/src/utils.rs @@ -234,7 +234,7 @@ pub async fn build_graph_for_files_with_options( let mut files_by_lang: Vec<(Language, Vec)> = Vec::new(); for file_path in files { - if let Some(lang) = Language::from_path(file_path) { + if let Some(lang) = detect_language_for_file(file_path) { if let Some((_, list)) = files_by_lang.iter_mut().find(|(l, _)| *l == lang) { list.push(file_path.clone()); } else { @@ -385,9 +385,99 @@ pub fn first_lines(text: &str, n: usize, max_line_len: usize) -> String { .join("\n") } +const MAX_ANCESTOR_SEARCH_DEPTH: usize = 8; + +/// Resolve a file's language, disambiguating extensions shared by multiple +/// languages. `Language::from_path` alone always picks whichever language is +/// listed first in `PROGRAMMING_LANGUAGES` (e.g. `.h` always becomes `C`, never +/// `C++`; `.ts` always becomes `Typescript`, never `Angular`/`Svelte`), silently +/// mis-parsing real files in mixed-language projects. This checks project +/// indicator files (e.g. `angular.json`) first, then falls back to lightweight +/// content sniffing for the C/C++ header case, which has no indicator file. +pub fn detect_language_for_file(file_path: &str) -> Option { + let ext = Path::new(file_path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if ext.is_empty() { + return None; + } + let candidates: Vec = lsp::language::PROGRAMMING_LANGUAGES + .iter() + .filter(|l| l.exts().iter().any(|e| e.eq_ignore_ascii_case(&ext))) + .cloned() + .collect(); + + match candidates.len() { + 0 => None, + 1 => candidates.into_iter().next(), + _ => Some(disambiguate_language(file_path, candidates)), + } +} + +fn disambiguate_language(file_path: &str, candidates: Vec) -> Language { + for lang in &candidates { + let indicators = lang.required_indicator_files(); + if !indicators.is_empty() && indicator_file_in_ancestors(file_path, &indicators) { + return lang.clone(); + } + } + + if candidates.contains(&Language::C) && candidates.contains(&Language::Cpp) { + return if looks_like_cpp_header(file_path) { + Language::Cpp + } else { + Language::C + }; + } + + candidates.into_iter().next().expect("candidates is non-empty") +} + +fn indicator_file_in_ancestors(file_path: &str, indicators: &[&str]) -> bool { + let mut dir = Path::new(file_path).parent(); + for _ in 0..MAX_ANCESTOR_SEARCH_DEPTH { + let Some(d) = dir else { break }; + if indicators.iter().any(|ind| d.join(ind).is_file()) { + return true; + } + if d.join(".git").exists() { + break; + } + dir = d.parent(); + } + false +} + +/// Cheap heuristic: does this header look like C++ rather than plain C? +/// Only ever consulted for `.h` files, since every other C/C++ extension is unambiguous. +fn looks_like_cpp_header(file_path: &str) -> bool { + let Ok(mut f) = std::fs::File::open(file_path) else { + return false; + }; + let mut buf = Vec::new(); + if f.by_ref().take(8192).read_to_end(&mut buf).is_err() { + return false; + } + let text = String::from_utf8_lossy(&buf); + const CPP_SIGNALS: &[&str] = &[ + "class ", + "template<", + "template <", + "namespace ", + "::", + "public:", + "private:", + "protected:", + "std::", + ]; + CPP_SIGNALS.iter().any(|sig| text.contains(sig)) +} + #[cfg(test)] mod tests { - use super::{apply_glob_filters, apply_glob_filters_with_base}; + use super::{apply_glob_filters, apply_glob_filters_with_base, detect_language_for_file}; use std::fs; fn mk_file(path: &std::path::Path, body: &str) { @@ -465,4 +555,73 @@ mod tests { ); assert!(res.is_err()); } + + #[test] + fn plain_header_without_indicators_detected_as_c() { + let td = tempfile::tempdir().expect("tempdir"); + let root = td.path(); + mk_file(&root.join("model.h"), "typedef struct { int id; } Person;\n"); + + let lang = detect_language_for_file(&root.join("model.h").to_string_lossy()); + assert_eq!(lang, Some(lsp::Language::C)); + } + + #[test] + fn header_with_cpp_class_detected_as_cpp() { + let td = tempfile::tempdir().expect("tempdir"); + let root = td.path(); + mk_file( + &root.join("model.h"), + "class Database {\npublic:\n Database();\n};\n", + ); + + let lang = detect_language_for_file(&root.join("model.h").to_string_lossy()); + assert_eq!(lang, Some(lsp::Language::Cpp)); + } + + #[test] + fn header_with_namespace_detected_as_cpp() { + let td = tempfile::tempdir().expect("tempdir"); + let root = td.path(); + mk_file(&root.join("config.h"), "namespace app {\n int version();\n}\n"); + + let lang = detect_language_for_file(&root.join("config.h").to_string_lossy()); + assert_eq!(lang, Some(lsp::Language::Cpp)); + } + + #[test] + fn ts_file_in_angular_project_detected_as_angular() { + let td = tempfile::tempdir().expect("tempdir"); + let root = td.path(); + mk_file(&root.join("angular.json"), "{}"); + mk_file( + &root.join("src/app/app.component.ts"), + "export class AppComponent {}\n", + ); + + let lang = detect_language_for_file( + &root.join("src/app/app.component.ts").to_string_lossy(), + ); + assert_eq!(lang, Some(lsp::Language::Angular)); + } + + #[test] + fn ts_file_without_angular_json_detected_as_typescript() { + let td = tempfile::tempdir().expect("tempdir"); + let root = td.path(); + mk_file(&root.join("src/index.ts"), "export const x = 1;\n"); + + let lang = detect_language_for_file(&root.join("src/index.ts").to_string_lossy()); + assert_eq!(lang, Some(lsp::Language::Typescript)); + } + + #[test] + fn unambiguous_extension_still_resolves() { + let td = tempfile::tempdir().expect("tempdir"); + let root = td.path(); + mk_file(&root.join("main.rs"), "fn main() {}\n"); + + let lang = detect_language_for_file(&root.join("main.rs").to_string_lossy()); + assert_eq!(lang, Some(lsp::Language::Rust)); + } } diff --git a/cli/tests/cli/angular.rs b/cli/tests/cli/angular.rs index 9b2f624ca..fb8807c45 100644 --- a/cli/tests/cli/angular.rs +++ b/cli/tests/cli/angular.rs @@ -32,9 +32,12 @@ fn parse_stats_angular_dir() { let dir = fixture_path("src/testing/angular"); let out = run_stakgraph(&["--stats", &dir]); + // Now that .ts files in this angular.json project are correctly parsed + // with the Angular query stack (not generic Typescript), server.ts's + // Express route is no longer misread as an Endpoint — angular.rs has no + // endpoint_finders of its own. assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr); - assert!(out.stdout.contains("Endpoint 1"), "stdout: {}", out.stdout); - assert!(out.stdout.contains("Function 13"), "stdout: {}", out.stdout); + assert!(out.stdout.contains("Function 10"), "stdout: {}", out.stdout); assert!(out.stdout.contains("Class 5"), "stdout: {}", out.stdout); assert!(out.stdout.contains("UnitTest 4"), "stdout: {}", out.stdout); } @@ -45,8 +48,7 @@ fn parse_type_filter_endpoint_angular() { let out = run_stakgraph(&["--type", "Endpoint", &dir]); assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr); - assert_eq!(count_prefix(&out.stdout, "Endpoint:"), 1); - assert!(out.stdout.contains("Endpoint: GET **"), "stdout: {}", out.stdout); + assert_eq!(count_prefix(&out.stdout, "Endpoint:"), 0); } // ── search ──────────────────────────────────────────────────────────────────── @@ -57,8 +59,11 @@ fn search_angular_get_endpoint() { let out = run_stakgraph(&["search", "GET", "--type", "Endpoint", &dir]); assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr); - assert!(out.stdout.contains("1 result"), "stdout: {}", out.stdout); - assert!(out.stdout.contains("Endpoint: GET **"), "stdout: {}", out.stdout); + assert!( + out.stdout.contains("No nodes matching"), + "stdout: {}", + out.stdout + ); } // ── deps ────────────────────────────────────────────────────────────────────── diff --git a/cli/tests/cli/svelte.rs b/cli/tests/cli/svelte.rs index a0edfdb45..697b839d5 100644 --- a/cli/tests/cli/svelte.rs +++ b/cli/tests/cli/svelte.rs @@ -33,8 +33,11 @@ fn parse_stats_svelte_dir() { let dir = fixture_path("src/testing/svelte"); let out = run_stakgraph(&["--stats", &dir]); + // Now that .ts/.js files in this svelte.config-having project are + // correctly parsed with the Svelte query stack (not generic Typescript), + // the SvelteKit +server.js route is no longer misread as an Endpoint — + // svelte.rs has no endpoint_finders of its own. assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr); - assert!(out.stdout.contains("Endpoint 2"), "stdout: {}", out.stdout); assert!(out.stdout.contains("Function 13"), "stdout: {}", out.stdout); assert!(out.stdout.contains("Class 4"), "stdout: {}", out.stdout); } @@ -47,6 +50,9 @@ fn search_svelte_get_endpoint() { let out = run_stakgraph(&["search", "GET", "--type", "Endpoint", &dir]); assert_eq!(out.exit_code, 0, "stderr: {}", out.stderr); - assert!(out.stdout.contains("1 result"), "stdout: {}", out.stdout); - assert!(out.stdout.contains("Endpoint: GET /api/people"), "stdout: {}", out.stdout); + assert!( + out.stdout.contains("No nodes matching"), + "stdout: {}", + out.stdout + ); }