Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/architecture/windows-development-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
233 changes: 233 additions & 0 deletions windows/tauri/src-tauri/src/lsp.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub runtime_executable_path: Option<String>,
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<String>,
}

#[derive(Debug, Clone)]
struct JavaLspResolution {
executable: PathBuf,
java_home: Option<PathBuf>,
}

/// 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<String>,
) -> Result<JavaLspLaunch, String> {
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<JavaLspResolution, String> {
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<PathBuf> {
jdtls_candidates(path_env, extra_roots)
.into_iter()
.find(|candidate| candidate.is_file())
}

fn jdtls_candidates(path_env: Option<&OsStr>, extra_roots: &[PathBuf]) -> Vec<PathBuf> {
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<PathBuf>, directory: &Path) {
for name in JDTLS_EXECUTABLE_NAMES {
candidates.push(directory.join(name));
}
}

fn jdtls_search_roots(project_root: Option<&Path>) -> Vec<PathBuf> {
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<PathBuf> {
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();
}
}
2 changes: 2 additions & 0 deletions windows/tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mod core;
mod file_events;
mod host;
mod lsp;
mod platform;
mod run;
mod secure_storage;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions windows/tauri/src-tauri/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ fn pretty_json(value: &Value) -> Result<String, String> {
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) {
Expand Down Expand Up @@ -513,7 +513,7 @@ fn probe_maven(executable: &Path) -> Option<MavenRuntime> {
})
}

fn java_executable(home: &Path) -> Option<PathBuf> {
pub(crate) fn java_executable(home: &Path) -> Option<PathBuf> {
for name in ["java.exe", "java"] {
let candidate = home.join("bin").join(name);
if candidate.is_file() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<NodeJS.Timeout | undefined>(undefined);
Expand All @@ -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;
Expand Down
Loading
Loading