diff --git a/docs/architecture/windows-development-plan.md b/docs/architecture/windows-development-plan.md index 3b15fe30a..cb1d723e7 100644 --- a/docs/architecture/windows-development-plan.md +++ b/docs/architecture/windows-development-plan.md @@ -33,8 +33,10 @@ platform contract and enabled in the UI only when the capability exists. ## Remaining product work 1. Align each Git feature API with the stable `git.*` command DTOs. -2. Route workspace search, Local History, LSP, Java/Maven, and run - configurations through the same dispatcher. +2. Route workspace search, Local History, remaining non-Java LSP, Java/Maven, + and run configurations through the same dispatcher. Built-in Java LSP now + starts through the Windows host (`jdtls` + JDK discovery) and + `lsp.startServer` with `providerId: "java"`. 3. Implement Windows-owned process, debug, update, and secure-storage flows in Rust where the current UI exposes them. 4. Hide or capability-gate future feature surfaces until their shared backend diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs new file mode 100644 index 000000000..41b22adb3 --- /dev/null +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -0,0 +1,233 @@ +//! Windows discovery for the built-in Java language server. +//! +//! Shared JDT LS process ownership stays in `lithe-core`. This adapter only +//! finds `jdtls`, a local JDK, and a cache directory on the current machine. + +use crate::run; +use serde::Serialize; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +const JAVA_PROVIDER_ID: &str = "java"; +const JDTLS_EXECUTABLE_NAMES: &[&str] = &["jdtls.bat", "jdtls.cmd", "jdtls.exe", "jdtls"]; + +/// Launch plan for the built-in Java language server on this machine. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaLspLaunch { + pub provider_id: String, + pub language_id: String, + pub executable_path: String, + pub arguments: Vec, + pub runtime_executable_path: Option, + pub cache_directory: String, + pub environment: JavaLspEnvironment, +} + +/// Environment values the Java language server needs from the host. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JavaLspEnvironment { + #[serde(rename = "JAVA_HOME", skip_serializing_if = "Option::is_none")] + pub java_home: Option, +} + +#[derive(Debug, Clone)] +struct JavaLspResolution { + executable: PathBuf, + java_home: Option, +} + +/// Resolves the built-in Java language-server executable, JDK, and cache directory. +#[tauri::command] +pub fn lsp_resolve_java_launch( + app: AppHandle, + workspace_path: String, + java_home_path: Option, +) -> Result { + let workspace = PathBuf::from(&workspace_path); + let project_root = workspace.is_dir().then_some(workspace.as_path()); + let resolution = resolve_java_lsp_launch( + std::env::var_os("PATH").as_deref(), + &jdtls_search_roots(project_root), + project_root, + java_home_path.as_deref(), + )?; + + Ok(JavaLspLaunch { + provider_id: JAVA_PROVIDER_ID.to_string(), + language_id: JAVA_PROVIDER_ID.to_string(), + executable_path: normalize_path(&resolution.executable), + arguments: Vec::new(), + runtime_executable_path: resolution + .java_home + .as_deref() + .and_then(run::java_executable) + .as_deref() + .map(normalize_path), + cache_directory: normalize_path(&language_server_cache_directory(&app)), + environment: JavaLspEnvironment { + java_home: resolution.java_home.as_deref().map(normalize_path), + }, + }) +} + +fn resolve_java_lsp_launch( + path_env: Option<&OsStr>, + extra_roots: &[PathBuf], + project_root: Option<&Path>, + java_home_override: Option<&str>, +) -> Result { + let executable = find_jdtls_executable(path_env, extra_roots).ok_or_else(|| { + "Could not find jdtls. Install Eclipse JDT Language Server and add it to PATH.".to_string() + })?; + let java_home = resolve_java_home(project_root, java_home_override); + Ok(JavaLspResolution { + executable, + java_home, + }) +} + +fn find_jdtls_executable(path_env: Option<&OsStr>, extra_roots: &[PathBuf]) -> Option { + jdtls_candidates(path_env, extra_roots) + .into_iter() + .find(|candidate| candidate.is_file()) +} + +fn jdtls_candidates(path_env: Option<&OsStr>, extra_roots: &[PathBuf]) -> Vec { + let mut candidates = Vec::new(); + if let Some(path) = path_env { + for directory in std::env::split_paths(path) { + push_jdtls_names(&mut candidates, &directory); + } + } + for root in extra_roots { + push_jdtls_names(&mut candidates, root); + push_jdtls_names(&mut candidates, &root.join("bin")); + } + candidates +} + +fn push_jdtls_names(candidates: &mut Vec, directory: &Path) { + for name in JDTLS_EXECUTABLE_NAMES { + candidates.push(directory.join(name)); + } +} + +fn jdtls_search_roots(project_root: Option<&Path>) -> Vec { + let mut roots = Vec::new(); + if let Ok(home) = std::env::var("JDTLS_HOME") { + roots.push(PathBuf::from(home)); + } + for key in ["LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"] { + if let Ok(base) = std::env::var(key) { + let base = PathBuf::from(base); + roots.push(base.join("jdtls")); + roots.push(base.join("Eclipse JDT Language Server")); + roots.push(base.join("Programs").join("jdtls")); + } + } + if let Ok(profile) = std::env::var("USERPROFILE") { + let profile = PathBuf::from(profile); + roots.push(profile.join(".jdtls")); + roots.push( + profile + .join("scoop") + .join("apps") + .join("jdtls") + .join("current"), + ); + roots.push(profile.join("scoop").join("shims")); + } + if let Some(root) = project_root { + roots.push(root.join(".lithe").join("toolchains").join("jdtls")); + } + roots +} + +fn resolve_java_home( + project_root: Option<&Path>, + java_home_override: Option<&str>, +) -> Option { + if let Some(configured) = java_home_override + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let path = PathBuf::from(configured); + if run::java_executable(&path).is_some() { + return Some(path); + } + } + run::discover_toolchains(project_root) + .java + .into_iter() + .next() + .map(|runtime| PathBuf::from(runtime.home_path)) +} + +fn language_server_cache_directory(app: &AppHandle) -> PathBuf { + app.path() + .app_cache_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("lithe-lsp")) + .join("language-servers") +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("lithe-java-lsp-{stamp}")); + fs::create_dir_all(&path).expect("temp dir"); + path + } + + #[test] + fn finds_jdtls_bat_in_an_extra_search_root() { + let root = temp_dir(); + let bin = root.join("bin"); + fs::create_dir_all(&bin).expect("bin"); + let executable = bin.join("jdtls.bat"); + fs::write(&executable, "@echo off\n").expect("jdtls"); + + let found = find_jdtls_executable(None, &[root.clone()]).expect("found"); + assert_eq!(found, executable); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn prefers_path_entries_before_extra_roots() { + let path_root = temp_dir(); + let extra_root = temp_dir(); + let path_executable = path_root.join("jdtls.cmd"); + let extra_executable = extra_root.join("jdtls.bat"); + fs::write(&path_executable, "@echo off\n").expect("path jdtls"); + fs::write(&extra_executable, "@echo off\n").expect("extra jdtls"); + + let found = find_jdtls_executable(Some(path_root.as_os_str()), &[extra_root.clone()]) + .expect("found"); + assert_eq!(found, path_executable); + fs::remove_dir_all(path_root).ok(); + fs::remove_dir_all(extra_root).ok(); + } + + #[test] + fn reports_a_stable_error_when_jdtls_is_missing() { + let missing = temp_dir().join("empty-jdtls-root"); + fs::create_dir_all(&missing).expect("missing root"); + let error = resolve_java_lsp_launch(None, &[missing.clone()], None, None).unwrap_err(); + assert!(error.contains("jdtls"), "{error}"); + fs::remove_dir_all(missing).ok(); + } +} diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 893a8fefa..9819f9f54 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -3,6 +3,7 @@ mod core; mod file_events; mod host; +mod lsp; mod platform; mod run; mod secure_storage; @@ -92,6 +93,7 @@ fn main() { host::clipboard_paste, host::clipboard_clear, host::create_app_window, + lsp::lsp_resolve_java_launch, run::run_list_java_sources, run::run_write_generated, run::run_write_document, diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 8a0bfcbd5..d4cc07b0d 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -380,7 +380,7 @@ fn pretty_json(value: &Value) -> Result { serde_json::to_string_pretty(value).map_err(|error| error.to_string()) } -fn discover_toolchains(project_root: Option<&Path>) -> DiscoveredToolchains { +pub(crate) fn discover_toolchains(project_root: Option<&Path>) -> DiscoveredToolchains { let mut java = Vec::new(); let mut seen_homes = std::collections::HashSet::new(); for home in java_home_candidates(project_root) { @@ -513,7 +513,7 @@ fn probe_maven(executable: &Path) -> Option { }) } -fn java_executable(home: &Path) -> Option { +pub(crate) fn java_executable(home: &Path) -> Option { for name in ["java.exe", "java"] { let candidate = home.join("bin").join(name); if candidate.is_file() { diff --git a/windows/tauri/src/features/editor/components/toolbar/file-path-breadcrumb.tsx b/windows/tauri/src/features/editor/components/toolbar/file-path-breadcrumb.tsx index 84eac6cc5..414bd7e94 100644 --- a/windows/tauri/src/features/editor/components/toolbar/file-path-breadcrumb.tsx +++ b/windows/tauri/src/features/editor/components/toolbar/file-path-breadcrumb.tsx @@ -3,7 +3,7 @@ import { CaretLeftIcon as ChevronLeft } from "@/ui/icons"; import { useRef, useState } from "react"; import { EDITOR_CONSTANTS } from "@/features/editor/config/constants"; import { logger } from "@/features/editor/utils/logger"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { ThemedFileIcon } from "@/extensions/icon-themes/components/themed-file-icon"; import { readDirectory } from "@/features/file-system/controllers/platform"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; @@ -133,7 +133,7 @@ export function FilePathBreadcrumb({ event.stopPropagation(); if (segmentIndex === segments.length - 1) { - if (!filePath.includes("://") && extensionRegistry.isLspSupported(filePath)) { + if (!filePath.includes("://") && isEditorLspSupported(filePath)) { openCommandPaletteView("outline"); return; } diff --git a/windows/tauri/src/features/editor/components/toolbar/symbol-breadcrumb.tsx b/windows/tauri/src/features/editor/components/toolbar/symbol-breadcrumb.tsx index 9d292f643..2dfdb8a23 100644 --- a/windows/tauri/src/features/editor/components/toolbar/symbol-breadcrumb.tsx +++ b/windows/tauri/src/features/editor/components/toolbar/symbol-breadcrumb.tsx @@ -1,5 +1,5 @@ import { Fragment, useMemo } from "react"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { useExtensionStore } from "@/extensions/registry/extension-store"; import { useEditorStateStore } from "@/features/editor/stores/state.store"; import { resolveEditorViewCursorPosition } from "@/features/editor/utils/editor-view-cursor-position"; @@ -38,7 +38,7 @@ export function SymbolBreadcrumb({ const availableExtensions = useExtensionStore.use.availableExtensions(); const isExtensionStoreReady = availableExtensions.size > 0; - const isLspSupported = !filePath.includes("://") && extensionRegistry.isLspSupported(filePath); + const isLspSupported = !filePath.includes("://") && isEditorLspSupported(filePath); const { symbols, isSupported } = useDocumentOutline({ isActive: breadcrumbShowSymbols && isLspSupported, bufferId, diff --git a/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts b/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts index c99476799..8cd8e6fb0 100644 --- a/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts +++ b/windows/tauri/src/features/editor/engines/monaco/code-lens-provider.ts @@ -7,7 +7,7 @@ import { } from "monaco-editor"; import type * as Monaco from "monaco-editor"; import { toast } from "sonner"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; import { filePathFromUri } from "@/features/editor/lsp/workspace-edit"; @@ -154,7 +154,7 @@ export function registerMonacoCodeLensProvider(): void { const filePath = filePathFromModel(model); if ( !filePath || - !extensionRegistry.isLspSupported(filePath) || + !isEditorLspSupported(filePath) || !lspClient.getActiveServerEntryForFile(filePath) || !lspClient.isDocumentOpen(filePath) ) { diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index 329785d49..7dbd54448 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -10,7 +10,7 @@ import { isWorkspaceEdit, type LspTextEdit, } from "@/features/editor/lsp/workspace-edit"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { MONACO_HIGHLIGHT_LANGUAGE_IDS } from "./language"; import { filePathFromLitheModelUri } from "./model-uri"; import { createMonacoSemanticTokenProvider } from "./semantic-token-provider"; @@ -181,7 +181,7 @@ function toWorkspaceEdit(edit: unknown): Monaco.languages.WorkspaceEdit | undefi function isLspModel(model: Monaco.editor.ITextModel): boolean { const filePath = filePathFromModel(model); - return Boolean(filePath && extensionRegistry.isLspSupported(filePath)); + return isEditorLspSupported(filePath); } export function registerMonacoLspProviders() { diff --git a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts index 25fa4fd26..1230072e4 100644 --- a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts +++ b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts @@ -1,12 +1,13 @@ import { useEffect, useMemo, useRef } from "react"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; import { useExtensionStore } from "@/extensions/registry/extension-store"; import { deferUntilAfterNextPaint } from "@/features/editor/lsp/deferred-lsp-work"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getSourceEditorBufferByPath } from "@/features/editor/utils/buffer-index"; import { logger } from "@/features/editor/utils/logger"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { getDirName } from "@/utils/path-helpers"; interface UseLspIntegrationOptions { enabled?: boolean; @@ -26,7 +27,7 @@ export const useLspIntegration = ({ const installedExtensions = useExtensionStore.use.installedExtensions(); const activeFilePath = enabled ? filePath : undefined; const isLspSupported = useMemo( - () => Boolean(activeFilePath && extensionRegistry.isLspSupported(activeFilePath)), + () => isEditorLspSupported(activeFilePath), [activeFilePath, installedExtensions], ); const documentChangeTimerRef = useRef(undefined); @@ -41,7 +42,7 @@ export const useLspIntegration = ({ useEffect(() => { if (!enabled || !filePath || !isLspSupported) return; - const workspacePath = rootFolderPath || filePath.substring(0, filePath.lastIndexOf("/")); + const workspacePath = rootFolderPath || getDirName(filePath); if (!workspacePath) { console.warn("LSP: Could not determine workspace path for", filePath); return; diff --git a/windows/tauri/src/features/editor/lsp/built-in-language-support.test.ts b/windows/tauri/src/features/editor/lsp/built-in-language-support.test.ts new file mode 100644 index 000000000..295f71685 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/built-in-language-support.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { + isBuiltInLspPath, + isEditorLspSupported, + isJavaSourcePath, + JAVA_LANGUAGE_ID, + languageIdForEditorFile, +} from "./built-in-language-support"; + +describe("built-in Java language support", () => { + test("recognizes Windows and POSIX Java sources", () => { + expect(isJavaSourcePath("C:\\work\\src\\main\\java\\App.java")).toBe(true); + expect(isJavaSourcePath("/Users/dev/src/App.java")).toBe(true); + expect(isJavaSourcePath("C:\\work\\README.md")).toBe(false); + expect(isJavaSourcePath("remote://host/App.java")).toBe(false); + }); + + test("treats Java as a built-in LSP language without an extension pack", () => { + expect(isBuiltInLspPath("D:/demo/src/Service.JAVA")).toBe(true); + expect(isEditorLspSupported("D:/demo/src/Service.java")).toBe(true); + expect(languageIdForEditorFile("D:/demo/src/Service.java")).toBe(JAVA_LANGUAGE_ID); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/built-in-language-support.ts b/windows/tauri/src/features/editor/lsp/built-in-language-support.ts new file mode 100644 index 000000000..9e88ec884 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/built-in-language-support.ts @@ -0,0 +1,32 @@ +import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { getBaseName, normalizePath } from "@/utils/path-helpers"; + +export const JAVA_LANGUAGE_ID = "java"; +export const JAVA_PROVIDER_ID = "java"; + +const VIRTUAL_PATH_PREFIXES = ["remote://", "wsl://", "diff://"]; + +export function isJavaSourcePath(filePath: string | undefined): boolean { + if (!filePath || isVirtualEditorPath(filePath)) return false; + return getBaseName(filePath).toLowerCase().endsWith(".java"); +} + +export function isBuiltInLspPath(filePath: string | undefined): boolean { + return isJavaSourcePath(filePath); +} + +export function isEditorLspSupported(filePath: string | undefined): boolean { + if (!filePath || isVirtualEditorPath(filePath)) return false; + return isBuiltInLspPath(filePath) || extensionRegistry.isLspSupported(filePath); +} + +export function languageIdForEditorFile(filePath: string | undefined): string | undefined { + if (!filePath || isVirtualEditorPath(filePath)) return undefined; + if (isJavaSourcePath(filePath)) return JAVA_LANGUAGE_ID; + return extensionRegistry.getLanguageId(filePath) || undefined; +} + +function isVirtualEditorPath(filePath: string): boolean { + const normalized = normalizePath(filePath); + return VIRTUAL_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} diff --git a/windows/tauri/src/features/editor/lsp/java-lsp-host-api.ts b/windows/tauri/src/features/editor/lsp/java-lsp-host-api.ts new file mode 100644 index 000000000..112f78513 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/java-lsp-host-api.ts @@ -0,0 +1,20 @@ +import { invoke } from "@/platform/tauri-core"; + +export interface JavaLspLaunch { + providerId: string; + languageId: string; + executablePath: string; + arguments: string[]; + runtimeExecutablePath?: string | null; + cacheDirectory: string; + environment: { + JAVA_HOME?: string; + }; +} + +export function resolveJavaLspLaunch(workspacePath: string, javaHomePath?: string) { + return invoke("lsp_resolve_java_launch", { + workspacePath, + javaHomePath: javaHomePath ?? null, + }); +} diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 5c47787c9..a87f3b1dd 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -14,11 +14,12 @@ import type { Diagnostic, DiagnosticCodeAction, } from "@/features/diagnostics/types/diagnostics.types"; -import type { BackendLanguageToolConfigSet } from "@/extensions/registry/extension-store-runtime"; import { hasTextContent } from "@/features/panes/types/pane-content.types"; import { useBufferStore } from "../stores/buffer.store"; import { getSourceEditorBufferByPath } from "../utils/buffer-index"; import { logger } from "../utils/logger"; +import { isBuiltInLspPath, languageIdForEditorFile } from "./built-in-language-support"; +import { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; import type { LspSemanticTokensResponse } from "./semantic-token-types"; import { useLspStore } from "./stores/lsp.store"; import { @@ -456,73 +457,42 @@ export class LspClient { return; } - // Get LSP server info from extension registry if file path is provided - let serverPath: string | undefined; - let serverArgs: string[] | undefined; - let languageId: string | undefined; - let initOptions: Record | undefined; - let tools: BackendLanguageToolConfigSet | undefined; - - if (filePath) { - const [{ extensionRegistry }, { getLanguageToolConfigSet }] = await Promise.all([ - import("@/extensions/registry/extension-registry"), - import("@/extensions/registry/extension-store-runtime"), - ]); - const extension = extensionRegistry.getExtensionForFilePath(filePath); - - serverPath = extensionRegistry.getLspServerPath(filePath) || undefined; - serverArgs = extensionRegistry.getLspServerArgs(filePath); - languageId = extensionRegistry.getLanguageId(filePath) || undefined; - initOptions = extensionRegistry.getLspInitializationOptions(filePath); - tools = getLanguageToolConfigSet(extension?.manifest); - - logger.debug("LSPClient", `Using LSP server: ${serverPath} for language: ${languageId}`); - - // Check if this language server is already running for this workspace - if (serverPath && languageId) { - const serverKey = `${workspacePath}:${languageId}`; - if (this.activeLanguageServers.has(serverKey)) { - logger.debug("LSPClient", `LSP for ${languageId} already running in workspace`); - return; - } - } + const launch = filePath ? await resolveEditorLspLaunch(filePath, workspacePath) : null; + if (!launch) { + logger.debug("LSPClient", `No LSP server configured for workspace ${workspacePath}`); + return; } - // If no LSP server is configured, return early - if (!serverPath) { - if (languageId) { - logger.warn( - "LSPClient", - `LSP configured for language '${languageId}' but server binary is missing (file: ${filePath})`, - ); - } else { - logger.debug("LSPClient", `No LSP server configured for workspace ${workspacePath}`); - } + logger.debug("LSPClient", `Using LSP server: ${launch.serverPath} for language: ${launch.languageId}`); + const serverKey = `${workspacePath}:${launch.languageId}`; + if (this.activeLanguageServers.has(serverKey)) { + logger.debug("LSPClient", `LSP for ${launch.languageId} already running in workspace`); return; } logger.debug("LSPClient", `Invoking lsp_start with:`, { workspacePath, - serverPath, - serverArgs, + serverPath: launch.serverPath, + serverArgs: launch.serverArgs, }); await invoke("lsp_start", { workspacePath, - serverPath, - serverArgs, - languageId: languageId || null, - tools: tools || null, - initializationOptions: initOptions || null, + filePath: filePath || null, + serverPath: launch.serverPath, + serverArgs: launch.serverArgs, + languageId: launch.languageId, + providerId: launch.providerId, + tools: launch.tools || null, + initializationOptions: launch.initializationOptions || null, + runtimeExecutablePath: launch.runtimeExecutablePath || null, + cacheDirectory: launch.cacheDirectory || null, + environment: launch.environment || null, }); - // Track this language server - if (languageId) { - const serverKey = `${workspacePath}:${languageId}`; - this.activeLanguageServers.add(serverKey); - if (filePath) { - this.addTrackedFile(serverKey, filePath); - } + this.activeLanguageServers.add(serverKey); + if (filePath) { + this.addTrackedFile(serverKey, filePath); } logger.debug("LSPClient", "LSP started successfully for workspace:", workspacePath); @@ -574,22 +544,28 @@ export class LspClient { return false; } - // Get LSP server info from extension registry - const [{ extensionRegistry }, { getLanguageToolConfigSet }] = await Promise.all([ - import("@/extensions/registry/extension-registry"), - import("@/extensions/registry/extension-store-runtime"), - ]); - const extension = extensionRegistry.getExtensionForFilePath(filePath); - - const serverPath = extensionRegistry.getLspServerPath(filePath) || undefined; - const serverArgs = extensionRegistry.getLspServerArgs(filePath); - const languageId = extensionRegistry.getLanguageId(filePath) || undefined; - const initializationOptions = extensionRegistry.getLspInitializationOptions(filePath); - const tools = getLanguageToolConfigSet(extension?.manifest); + let launch: Awaited> = null; + try { + launch = await resolveEditorLspLaunch(filePath, workspacePath); + } catch (error) { + if (!options.repairAttempted && !isBuiltInLspPath(filePath)) { + const languageId = languageIdForEditorFile(filePath); + if (languageId) { + const repaired = await this.repairLanguageServerForFile(filePath, languageId); + if (repaired) { + return this.startForFile(filePath, workspacePath, { + forceRetry: true, + repairAttempted: true, + }); + } + } + } + throw error; + } - // If no LSP server is configured for this file type, return early - if (!serverPath) { - if (languageId && !options.repairAttempted) { + if (!launch) { + const languageId = languageIdForEditorFile(filePath); + if (languageId && !options.repairAttempted && !isBuiltInLspPath(filePath)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired) { return this.startForFile(filePath, workspacePath, { @@ -613,22 +589,21 @@ export class LspClient { throw new Error(message); } - logger.debug("LSPClient", `Using LSP server: ${serverPath} for language: ${languageId}`); + const languageId = launch.languageId; + logger.debug("LSPClient", `Using LSP server: ${launch.serverPath} for language: ${languageId}`); - if (languageId) { - const serverKey = `${workspacePath}:${languageId}`; - if (options.forceRetry) { - this.failedLanguageServers.delete(serverKey); - } - if (this.failedLanguageServers.has(serverKey)) { - logger.debug( - "LSPClient", - `Skipping LSP restart for ${languageId} in ${workspacePath} after a previous startup failure`, - ); - throw new Error( - `${this.getLanguageDisplayName(languageId)} language server previously failed to start.`, - ); - } + const serverKey = `${workspacePath}:${languageId}`; + if (options.forceRetry) { + this.failedLanguageServers.delete(serverKey); + } + if (this.failedLanguageServers.has(serverKey)) { + logger.debug( + "LSPClient", + `Skipping LSP restart for ${languageId} in ${workspacePath} after a previous startup failure`, + ); + throw new Error( + `${this.getLanguageDisplayName(languageId)} language server previously failed to start.`, + ); } useLspStore.getState().actions.updateLspStatus("connecting"); @@ -636,35 +611,32 @@ export class LspClient { logger.debug("LSPClient", `Invoking lsp_start_for_file with:`, { filePath, workspacePath, - serverPath, - serverArgs, + serverPath: launch.serverPath, + serverArgs: launch.serverArgs, }); try { await invoke("lsp_start_for_file", { filePath, workspacePath, - serverPath, - serverArgs, - languageId: languageId || null, - tools: tools || null, - initializationOptions: initializationOptions || null, + serverPath: launch.serverPath, + serverArgs: launch.serverArgs, + languageId, + providerId: launch.providerId, + tools: launch.tools || null, + initializationOptions: launch.initializationOptions || null, + runtimeExecutablePath: launch.runtimeExecutablePath || null, + cacheDirectory: launch.cacheDirectory || null, + environment: launch.environment || null, }); - if (languageId) { - const serverKey = `${workspacePath}:${languageId}`; - this.failedLanguageServers.delete(serverKey); - this.activeLanguageServers.add(serverKey); - this.addTrackedFile(serverKey, filePath); - const displayName = this.getLanguageDisplayName(languageId); - this.activeLanguages.add(displayName); - this.updateLspStatus(); - } + this.failedLanguageServers.delete(serverKey); + this.activeLanguageServers.add(serverKey); + this.addTrackedFile(serverKey, filePath); + this.activeLanguages.add(this.getLanguageDisplayName(languageId)); + this.updateLspStatus(); } catch (error) { - if (languageId) { - const serverKey = `${workspacePath}:${languageId}`; - this.failedLanguageServers.add(serverKey); - } - if (languageId && !options.repairAttempted && this.isRepairableStartupError(error)) { + this.failedLanguageServers.add(serverKey); + if (!options.repairAttempted && this.isRepairableStartupError(error)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired) { return this.startForFile(filePath, workspacePath, { @@ -734,8 +706,7 @@ export class LspClient { async stopForFile(filePath: string): Promise { try { logger.debug("LSPClient", "Stopping LSP for file:", filePath); - const { extensionRegistry } = await import("@/extensions/registry/extension-registry"); - const languageId = extensionRegistry.getLanguageId(filePath) || undefined; + const languageId = languageIdForEditorFile(filePath); await invoke("lsp_stop_for_file", { filePath }); if (languageId) { @@ -1273,8 +1244,7 @@ export class LspClient { async notifyDocumentOpen(filePath: string, content: string): Promise { try { logger.debug("LSPClient", `Opening document: ${filePath}`); - const { extensionRegistry } = await import("@/extensions/registry/extension-registry"); - const languageId = extensionRegistry.getLanguageId(filePath) || undefined; + const languageId = languageIdForEditorFile(filePath); await invoke("lsp_document_open", { filePath, content, languageId }); this.openDocuments.add(filePath); this.documentVersions.set(filePath, 1); diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts new file mode 100644 index 000000000..406ec5fe4 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts @@ -0,0 +1,55 @@ +import type { BackendLanguageToolConfigSet } from "@/extensions/registry/extension-store-runtime"; +import { isJavaSourcePath, JAVA_LANGUAGE_ID, JAVA_PROVIDER_ID } from "./built-in-language-support"; +import { resolveJavaLspLaunch } from "./java-lsp-host-api"; + +export interface EditorLspLaunch { + providerId: string; + languageId: string; + serverPath: string; + serverArgs: string[]; + initializationOptions?: Record; + tools?: BackendLanguageToolConfigSet; + runtimeExecutablePath?: string | null; + cacheDirectory?: string; + environment?: Record; +} + +export async function resolveEditorLspLaunch( + filePath: string, + workspacePath: string, +): Promise { + if (isJavaSourcePath(filePath)) { + const launch = await resolveJavaLspLaunch(workspacePath); + const environment: Record = {}; + if (launch.environment.JAVA_HOME) { + environment.JAVA_HOME = launch.environment.JAVA_HOME; + } + return { + providerId: launch.providerId || JAVA_PROVIDER_ID, + languageId: launch.languageId || JAVA_LANGUAGE_ID, + serverPath: launch.executablePath, + serverArgs: launch.arguments ?? [], + runtimeExecutablePath: launch.runtimeExecutablePath, + cacheDirectory: launch.cacheDirectory, + environment, + }; + } + + const [{ extensionRegistry }, { getLanguageToolConfigSet }] = await Promise.all([ + import("@/extensions/registry/extension-registry"), + import("@/extensions/registry/extension-store-runtime"), + ]); + const extension = extensionRegistry.getExtensionForFilePath(filePath); + const serverPath = extensionRegistry.getLspServerPath(filePath); + const languageId = extensionRegistry.getLanguageId(filePath); + if (!serverPath || !languageId) return null; + + return { + providerId: languageId, + languageId, + serverPath, + serverArgs: extensionRegistry.getLspServerArgs(filePath), + initializationOptions: extensionRegistry.getLspInitializationOptions(filePath), + tools: getLanguageToolConfigSet(extension?.manifest), + }; +} diff --git a/windows/tauri/src/features/editor/lsp/signature-help-tooltip.tsx b/windows/tauri/src/features/editor/lsp/signature-help-tooltip.tsx index 8a036c255..66005ed7d 100644 --- a/windows/tauri/src/features/editor/lsp/signature-help-tooltip.tsx +++ b/windows/tauri/src/features/editor/lsp/signature-help-tooltip.tsx @@ -2,7 +2,7 @@ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } fro import { EDITOR_CONSTANTS } from "@/features/editor/config/constants"; import { useEditorLayout } from "@/features/editor/hooks/use-layout"; import { useEditorStateStore } from "@/features/editor/stores/state.store"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { isEditorLspSupported } from "./built-in-language-support"; import type { EditorModelPositionResolver } from "../view-model/view-layout"; import { LspClient } from "./lsp-client"; @@ -63,7 +63,7 @@ export const SignatureHelpTooltip = ({ }, [editorRef, filePath]); const fetchSignatureHelp = useCallback(async () => { - if (!filePath || !extensionRegistry.isLspSupported(filePath)) { + if (!filePath || !isEditorLspSupported(filePath)) { setSignatureHelp(null); return; } diff --git a/windows/tauri/src/features/editor/lsp/use-code-lens.ts b/windows/tauri/src/features/editor/lsp/use-code-lens.ts index 2baf6462a..42aa8b05f 100644 --- a/windows/tauri/src/features/editor/lsp/use-code-lens.ts +++ b/windows/tauri/src/features/editor/lsp/use-code-lens.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { isEditorLspSupported } from "./built-in-language-support"; import { LspClient } from "./lsp-client"; import { useLspStore } from "./stores/lsp.store"; @@ -19,7 +19,7 @@ export const useCodeLens = (filePath: string | undefined, enabled: boolean) => { }); const fetchLenses = useCallback(async () => { - if (!filePath || !enabled || !extensionRegistry.isLspSupported(filePath)) { + if (!filePath || !enabled || !isEditorLspSupported(filePath)) { setLenses([]); return; } diff --git a/windows/tauri/src/features/outline/hooks/use-document-outline.ts b/windows/tauri/src/features/outline/hooks/use-document-outline.ts index e5866da5a..1f668e9e0 100644 --- a/windows/tauri/src/features/outline/hooks/use-document-outline.ts +++ b/windows/tauri/src/features/outline/hooks/use-document-outline.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { hasTextContent } from "@/features/panes/types/pane-content.types"; @@ -30,7 +30,7 @@ export function useDocumentOutline({ Boolean(filePath) && activeBuffer?.type === "editor" && !activeBuffer.isVirtual && - extensionRegistry.isLspSupported(filePath); + isEditorLspSupported(filePath); const [rawSymbols, setRawSymbols] = useState< Awaited> >([]); diff --git a/windows/tauri/src/features/quick-open/hooks/use-symbol-search.ts b/windows/tauri/src/features/quick-open/hooks/use-symbol-search.ts index 181505eaa..878cc3f27 100644 --- a/windows/tauri/src/features/quick-open/hooks/use-symbol-search.ts +++ b/windows/tauri/src/features/quick-open/hooks/use-symbol-search.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getBufferById } from "@/features/editor/utils/buffer-index"; -import { extensionRegistry } from "@/extensions/registry/extension-registry"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; import { fuzzyScore } from "../utils/fuzzy-search"; export interface SymbolItem { @@ -37,7 +37,7 @@ export const useSymbolSearch = (query: string, isActive: boolean) => { const bufferStore = useBufferStore.getState(); const activeBuffer = getBufferById(bufferStore.buffers, bufferStore.activeBufferId); - if (!activeBuffer?.path || !extensionRegistry.isLspSupported(activeBuffer.path)) { + if (!activeBuffer?.path || !isEditorLspSupported(activeBuffer.path)) { setSymbols([]); return; } diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 588e825c3..a90a0bfdf 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -145,17 +145,24 @@ function sessionForFile(filePath: string): Session { async function start(args: JsonRecord): Promise { const workspacePath = String(args.workspacePath ?? ""); const languageId = String(args.languageId ?? "plaintext"); + const providerId = String(args.providerId ?? languageId); const key = `${workspacePath}:${languageId}`; let session = sessions.get(key); if (!session) { + const environment = { + ...(args.tools?.lsp?.env ?? {}), + ...(args.environment ?? {}), + }; const started = await core<{ sessionId: string }>("lsp.startServer", { - providerId: languageId, + providerId, executablePath: args.serverPath, arguments: args.serverArgs ?? [], - environment: args.tools?.lsp?.env ?? {}, + environment, rootUri: fileUri(workspacePath), workingDirectory: workspacePath, initializationOptions: args.initializationOptions ?? null, + runtimeExecutablePath: args.runtimeExecutablePath ?? null, + cacheDirectory: args.cacheDirectory ?? null, }); session = { id: started.sessionId, diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 52c7c593e..c4d2437a6 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -33,6 +33,7 @@ const nativeCommands = new Set([ "get_system_fonts", "get_system_theme", "list_shells", + "lsp_resolve_java_launch", "move_file", "open_file_external", "read_file_custom",