diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 5de98978..e2d45fec 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -82,6 +82,12 @@ pub enum IdeEvent { status: ServerStatus, message: Option, }, + /// A running server lacks a capability; the UI offers the managed tool as a replacement. + LanguageServerWarning { + name: String, + message: String, + install_tool: String, + }, /// A stopped language server left the project's list (disabled in settings, or a /// fallback that a preferred server took over from again). LanguageServerRemoved { diff --git a/crates/ide-lsp/src/clang_tidy.rs b/crates/ide-lsp/src/clang_tidy.rs new file mode 100644 index 00000000..351ae473 --- /dev/null +++ b/crates/ide-lsp/src/clang_tidy.rs @@ -0,0 +1,126 @@ +//! Apple clangd accepts `.clang-tidy` projects but ships without clang-tidy checks. + +use crate::{LspManager, Server}; +use ide_core::IdeEvent; +use std::path::Path; + +pub(crate) const MESSAGE: &str = "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them."; + +fn is_apple_clangd(path: &Path) -> bool { + if path == Path::new("/usr/bin/clangd") { + return true; + } + if !path.ends_with("usr/bin/clangd") { + return false; + } + path.starts_with("/Library/Developer/CommandLineTools") + || path.ancestors().any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "Toolchains") + && ancestor.parent().is_some_and(|developer| { + developer.ends_with("Contents/Developer") + && developer.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|name| { + let name = name.to_string_lossy(); + name.starts_with("Xcode") && name.ends_with(".app") + }) + }) + }) +} + +fn has_config(server_root: &Path, project_root: &Path, file: &Path) -> bool { + server_root.join(".clang-tidy").is_file() + || file + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|dir| dir.starts_with(project_root)) + .any(|dir| dir.join(".clang-tidy").is_file()) +} + +impl LspManager { + pub(crate) async fn check_clang_tidy(&self, server: &Server) { + if server.name != "clangd" + || !cfg!(target_os = "macos") + || self.shown_messages.lock().contains(&("clangd".into(), MESSAGE.into())) + { + return; + } + let binary = server.resolved_binary.read().clone(); + let Some(binary) = binary else { return }; + let files: Vec<_> = self + .docs + .lock() + .values() + .filter(|doc| { + matches!(doc.language.as_str(), "c" | "cpp" | "objective-c" | "objective-cpp") + && doc.servers.iter().any(|attached| attached.server.id == server.id) + }) + .map(|doc| doc.path.clone()) + .collect(); + let root = server.root.clone(); + let Some(me) = self.me.upgrade() else { return }; + let _ = tokio::task::spawn_blocking(move || { + if !is_apple_clangd(&binary) && !std::fs::canonicalize(&binary).is_ok_and(|path| is_apple_clangd(&path)) { + return; + } + if !me.shutting_down.load(std::sync::atomic::Ordering::Acquire) + && files.iter().any(|file| has_config(&root, &me.project_root_for(file), file)) + && me.first_message("clangd", MESSAGE) + { + me.sink.emit(IdeEvent::LanguageServerWarning { + name: "clangd".into(), + message: MESSAGE.into(), + install_tool: "clangd".into(), + }); + } + }) + .await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apple_paths() { + for path in [ + "/usr/bin/clangd", + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clangd", + "/Library/Developer/CommandLineTools/usr/bin/clangd", + ] { + assert!(is_apple_clangd(Path::new(path)), "{path}"); + } + for path in [ + "/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", + "/opt/homebrew/opt/llvm/bin/clangd", + "/usr/local/bin/clangd", + "/usr/bin/clangd-custom", + "/Library/Developer/CommandLineTools-custom/usr/bin/clangd", + "/Applications/Other.app/Contents/Developer/Toolchains/LLVM.xctoolchain/usr/bin/clangd", + ] { + assert!(!is_apple_clangd(Path::new(path)), "{path}"); + } + } + + #[test] + fn config_in_server_root_or_file_ancestors_stays_inside_project() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + let server = project.join("build"); + let source = project.join("src/nested"); + let file = source.join("main.cpp"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&server).unwrap(); + std::fs::write(dir.path().join(".clang-tidy"), "").unwrap(); + assert!(!has_config(&server, &project, &file)); + for root in [&server, &source, &project.join("src"), &project] { + let config = root.join(".clang-tidy"); + std::fs::write(&config, "Checks: '-*,modernize-use-nullptr'").unwrap(); + assert!(has_config(&server, &project, &file), "{}", root.display()); + std::fs::remove_file(config).unwrap(); + } + std::fs::create_dir(source.join(".clang-tidy")).unwrap(); + assert!(!has_config(&server, &project, &file)); + } +} diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 36ae556c..5c7d53c0 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -43,6 +43,18 @@ pub trait Launcher: Send + Sync + 'static { self.launch(config, root, handler, on_stderr) } + /// Starts a server and reports its executable when the launcher runs a local process. + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel).map(|peer| (peer, None)) + } + /// Whether `config` could start without a `Missing` error. fn available(&self, _config: &LanguageServerConfig, _root: &Path) -> bool { true @@ -72,6 +84,17 @@ impl Launcher for ProcessLauncher { on_stderr: Box, cancel: &CancellationToken, ) -> Result { + self.launch_resolved(config, root, handler, on_stderr, cancel).map(|(peer, _)| peer) + } + + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { let search = server_search_path(config); let binary = ide_core::tool_path::find_command(&config.command, &search, root) .and_then(|b| resolve_rustup_proxy(&config.command, b)); @@ -87,10 +110,12 @@ impl Launcher for ProcessLauncher { handler, on_stderr, cancel, - ); + ) + .map(|peer| (peer, None)); } let binary = binary.ok_or(LaunchError::Missing)?; Peer::spawn(&binary.to_string_lossy(), &config.args, root, &env, handler, on_stderr) + .map(|peer| (peer, Some(binary.clone()))) .map_err(|e| LaunchError::Failed(format!("failed to start {}: {e}", binary.display()))) } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 52145184..d8ead994 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -8,6 +8,7 @@ mod angular; mod caps; +mod clang_tidy; mod convert; mod external_project; mod file_ops; @@ -472,13 +473,16 @@ impl LspManager { if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { return Err(LaunchError::Config("Project is in Restricted Mode".into())); } - me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) + me.launcher.launch_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await .unwrap_or_else(|e| Err(LaunchError::Failed(e.to_string()))); let peer = match launched { - Ok(peer) => peer, + Ok((peer, binary)) => { + *server.resolved_binary.write() = binary; + peer + } Err(LaunchError::Missing) => { let cfg = server.effective_config(); let message = @@ -587,6 +591,7 @@ impl LspManager { } } } + self.check_clang_tidy(&server).await; server.refresh_busy_status(&self.sink); server.announce_ready(generation); self.spawn_supervisor(server, generation, peer); @@ -810,6 +815,12 @@ impl LspManager { } for s in &servers { self.ensure_started(s); + if s.running().is_some() + && let Some(me) = self.me.upgrade() + { + let server = s.clone(); + self.runtime.spawn(async move { me.check_clang_tidy(&server).await }); + } } } diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 81c21b02..f15a893e 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -79,6 +79,7 @@ pub struct Server { pub config: LanguageServerConfig, /// `config` with the settings hook applied, as the current instance was launched. effective: Mutex>, + pub resolved_binary: RwLock>, languages: Mutex>, state: Mutex, /// Fired when the instance being started is abandoned (stop / restart while starting). @@ -114,6 +115,7 @@ impl Server { root, config, effective: Mutex::new(None), + resolved_binary: RwLock::new(None), languages: Mutex::new(vec![language]), state: Mutex::new(State { generation: 0, conn: Conn::Idle }), start_cancel: Mutex::new(CancellationToken::new()), diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 7bfb45cc..d2136467 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -21,6 +21,7 @@ use std::time::Duration; struct MockState { caps: Mutex, missing: std::sync::atomic::AtomicBool, + binary: Mutex>, launches: AtomicUsize, notifications: Mutex>, requests: Mutex>, @@ -148,6 +149,18 @@ impl MockLauncher { } impl Launcher for MockLauncher { + fn launch_resolved( + &self, + config: &LanguageServerConfig, + root: &Path, + handler: Arc, + on_stderr: Box, + cancel: &ide_core::CancellationToken, + ) -> Result<(Peer, Option), LaunchError> { + self.launch_cancellable(config, root, handler, on_stderr, cancel) + .map(|peer| (peer, self.state(&config.name).binary.lock().clone())) + } + fn launch( &self, config: &LanguageServerConfig, @@ -2836,3 +2849,68 @@ async fn formatting_provider_selection_tracks_dynamic_registration() { .unwrap(); assert!(!h.mgr.can_format(doc).await.unwrap()); } + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn apple_clangd_warns_for_tidy_config_once_across_languages_and_restarts() { + for language in ["c", "cpp", "objective-c", "objective-cpp"] { + let h = harness_for(language, "src", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("main.src", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + let id = h.mgr.servers()[0].id; + h.mgr.restart(id).await.unwrap(); + h.wait_status("clangd", ServerStatus::Ready).await; + h.open("other.src", "int *p = 0;"); + h.mgr.shutdown_all().await; + let events = h.events.lock(); + let warnings: Vec<_> = events + .iter() + .filter_map(|e| match e { + IdeEvent::LanguageServerWarning { name, message, install_tool } => Some((name, message, install_tool)), + _ => None, + }) + .collect(); + assert_eq!(warnings.len(), 1, "{language}"); + assert_eq!(warnings[0].0, "clangd"); + assert!(warnings[0].1.contains(".clang-tidy")); + assert_eq!(warnings[0].2, "clangd"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_warning_requires_apple_binary_and_a_config() { + for (binary, config) in [ + ("/usr/bin/clangd", false), + ("/opt/homebrew/opt/llvm/bin/clangd", true), + ("/Users/me/Library/Application Support/openidea/tools/clangd/bin/clangd", true), + ] { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from(binary)); + if config { + std::fs::write(h.root.join(".clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + } + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + h.mgr.shutdown_all().await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. })), "{binary}"); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn clang_tidy_config_for_a_later_opened_file_warns_on_a_running_server() { + let h = harness_for("cpp", "cpp", &[("clangd", incremental_caps())], &[]); + *h.launcher.state("clangd").binary.lock() = Some(PathBuf::from("/usr/bin/clangd")); + h.open("main.cpp", "int *p = 0;"); + h.wait_status("clangd", ServerStatus::Ready).await; + assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))); + std::fs::create_dir(h.root.join("nested")).unwrap(); + std::fs::write(h.root.join("nested/.clang-tidy"), "Checks: '-*,modernize-use-nullptr'").unwrap(); + h.open("nested/other.cpp", "int *p = 0;"); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServerWarning { .. }))).await; + assert_eq!(h.mgr.servers().len(), 1); + h.mgr.shutdown_all().await; +} diff --git a/src/bindings.ts b/src/bindings.ts index b3ea339c..e2eb8a2a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3619,6 +3619,8 @@ cancellable: boolean } | { type: "indexState"; indexing: boolean; files: number; symbols: number } | /** Git status / branch information should be refreshed. */ { type: "vcsChanged"; root: IpcPath } | { type: "languageServer"; server: ServerId; name: string; status: ServerStatus; message: string | null } | +/** A running server lacks a capability; the UI offers the managed tool as a replacement. */ +{ type: "languageServerWarning"; name: string; message: string; installTool: string } | /** * A stopped language server left the project's list (disabled in settings, or a * fallback that a preferred server took over from again). diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index 0d4bd57c..49c64365 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -7,7 +7,7 @@ import { useTasks } from "@/workbench/tasks"; import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend"; import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store"; import { filterTools, LanguageServersPage } from "../LanguageServersPage"; -import { installMissingServerPrompts, missingKey, promptInstall } from "../missing"; +import { installMissingServerPrompts, installServerWarningPrompts, missingKey, promptInstall } from "../missing"; import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm"; type Note = Parameters[0]; @@ -273,3 +273,49 @@ describe("tools.install action", () => { } }); }); + +describe("language server capability warning", () => { + const event = { + type: "languageServerWarning", + name: "clangd", + message: + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.", + installTool: "clangd", + } as const; + + it("offers a managed replacement for system clangd once and restarts it after installation", async () => { + backend.tools.push({ + ...sampleTools()[0]!, + name: "clangd", + displayName: "clangd", + command: "clangd", + status: "onPath", + path: "/usr/bin/clangd", + languages: ["C", "C++", "Objective-C", "Objective-C++"], + }); + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]).toMatchObject({ title: "clangd", level: "warning", sticky: true, body: event.message }); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Install", "Language Servers…"]); + notes[0]!.actions![0]!.run(); + await waitFor(() => expect(backend.restarted).toEqual(["clangd"])); + expect(notes.at(-1)).toMatchObject({ title: "clangd installed", key: notes[0]!.key }); + } finally { + off(); + } + }); + + it("keeps the Settings action when the tool cannot be installed", async () => { + const off = installServerWarningPrompts(); + try { + emitLocalIdeEvent(event); + await waitFor(() => expect(notes).toHaveLength(1)); + expect(notes[0]!.actions!.map((a) => a.label)).toEqual(["Language Servers…"]); + } finally { + off(); + } + }); +}); diff --git a/src/features/tools/index.ts b/src/features/tools/index.ts index 76736a60..f9c79d9a 100644 --- a/src/features/tools/index.ts +++ b/src/features/tools/index.ts @@ -9,7 +9,7 @@ import { lazyComponent } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { registerSettingsPage } from "@/workbench/contributions"; import { installJdkPrompts, registerJdkActions } from "./jdk"; -import { installMissingServerPrompts, promptInstall } from "./missing"; +import { installMissingServerPrompts, installServerWarningPrompts, promptInstall } from "./missing"; import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store"; /** The Language Servers page (also used by the Settings window). */ @@ -79,6 +79,7 @@ export function activate(): () => void { }, ]), installMissingServerPrompts(), + installServerWarningPrompts(), registerJdkActions(), installJdkPrompts(), ]; diff --git a/src/features/tools/missing.ts b/src/features/tools/missing.ts index c1c48d1a..6bd53191 100644 --- a/src/features/tools/missing.ts +++ b/src/features/tools/missing.ts @@ -56,3 +56,31 @@ export function installMissingServerPrompts(): () => void { void promptInstall(e.name).catch(() => {}); }); } + +/** A system server that lacks a capability can be replaced by the managed tool. */ +export function installServerWarningPrompts(): () => void { + const prompted = new Set(); + return onIdeEvent("languageServerWarning", (e) => { + const key = `lsp-warning-${e.name}-${e.installTool}`; + if (prompted.has(key)) return; + prompted.add(key); + void (async () => { + if (!useTools.getState().loaded) await refreshTools(); + const tool = toolByName(e.installTool); + notifications().notify({ + group: "lsp", + level: "warning", + title: e.name, + body: tx(e.message), + key, + sticky: true, + actions: [ + ...(tool?.canInstall + ? [{ label: "Install", run: () => void installTool(e.installTool, { notificationKey: key }) }] + : []), + { label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) }, + ], + }); + })(); + }); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index daeb595f..fec979fe 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。", "Newer dependency version available": "有更新的依赖版本可用", "Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。", "Update to {latest}": "更新至 {latest}",