diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 6c7da10c0..8c5cbe520 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -125,7 +125,7 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 -正式 macOS 与 Windows 安装包包含 JDTLS。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验归档与 EPL-2.0 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。平台 adapter 优先使用这个包内启动器;开发环境仍保留项目工具目录、显式覆盖和 `PATH` 等外部候选作为回退。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS;Java 语义功能仍要求系统提供 JDK 17 或更高版本。 +正式 macOS 与 Windows 安装包包含 JDTLS。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验 JDTLS 归档、EPL-2.0 许可证、Lombok agent 与 MIT 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。包内启动器通过相对路径加载 Lombok `-javaagent`,确保 JDTLS 能解析注解生成的成员;agent 缺失时启动器会明确失败,而不会产生静默的错误诊断。平台 adapter 优先使用这个包内启动器;开发环境仍保留项目工具目录、显式覆盖和 `PATH` 等外部候选作为回退。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS 或 Lombok;Java 语义功能仍要求系统提供 JDK 17 或更高版本。 JDTLS 使用独立于项目运行/调试配置的全局 JDK 偏好。空路径表示自动模式:平台探测本机 JDK,并只选择主版本 17 或更高的候选。用户也可以在语言服务器设置中选择单独的 JDK Home;平台会在保存时探测 `java -version`,并在每次启动 JDTLS 前再次校验路径和版本。这个偏好不会读取、覆盖或写回项目的 Java/Maven JDK 设置。 diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 80ef4b632..4ac77f09d 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -212,6 +212,20 @@ pub fn client_shutdown(request: ClientShutdownRequest) -> Result Result { + client_feature_request_with_document_ownership(request, true) +} + +/// Allocates a feature request for a provider-owned virtual document. +pub(crate) fn client_provider_document_feature_request_canonical( + request: ClientFeatureRequest, +) -> Result { + client_feature_request_with_document_ownership(request, false) +} + +fn client_feature_request_with_document_ownership( + request: ClientFeatureRequest, + require_open_document: bool, ) -> Result { validate_uri(&request.uri)?; validate_lsp_method(&request.method)?; @@ -219,7 +233,7 @@ pub(crate) fn client_feature_request_canonical( let uri = request.uri.clone(); let method = request.method.clone(); let mut state = request.state; - if !state.open_documents.contains_key(&uri) { + if require_open_document && !state.open_documents.contains_key(&uri) { return Err(CoreError::new( ErrorCode::InvalidRequest, "Cannot request LSP features for a document that is not open.", diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index aa3aa96be..3af794d17 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -3,16 +3,17 @@ use super::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, SystemProcessLauncher}; use super::{ client_apply_server_message, client_change_document, client_close_document, - client_feature_request_canonical, client_initialize, client_open_document, client_shutdown, - frame_message, parse_server_messages, ClientApplyServerMessageRequest, - ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, - ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, - LspClientDiagnostic, LspClientDocument, LspClientState, LspPosition, LspRange, - ParseServerMessagesRequest, + client_feature_request_canonical, client_initialize, client_open_document, + client_provider_document_feature_request_canonical, client_shutdown, frame_message, + parse_server_messages, ClientApplyServerMessageRequest, ClientChangeDocumentRequest, + ClientCloseDocumentRequest, ClientFeatureRequest, ClientInitializeRequest, + ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, LspClientDiagnostic, + LspClientDocument, LspClientState, LspPosition, LspRange, ParseServerMessagesRequest, }; use crate::lsp::languages::jdt::{ - adapt_start, initialized_notification, virtual_source_content, virtual_source_resolve_params, - workspace_configuration, JdtStartContext, WorkspaceConfigurationItem, + adapt_initialization_options, adapt_start, initialized_notification, is_virtual_source_uri, + normalize_location, virtual_source_content, virtual_source_resolve_params, + workspace_configuration, JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, }; use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; @@ -483,7 +484,10 @@ impl LspEngine { state: LspClientState::default(), root_uri: request.root_uri.clone(), process_id: Some(std::process::id() as i64), - initialization_options: request.initialization_options, + initialization_options: adapt_initialization_options( + &request.provider_id, + request.initialization_options, + ), })?; let request_id = (initialize.state.next_request_id - 1).to_string(); let now = Instant::now(); @@ -740,18 +744,19 @@ impl RuntimeSession { "This language-server operation requires a document URI.", ) })?; - if !state + let document_is_open = state .client .open_documents .get(&uri) - .is_some_and(|document| document.version > 0) - { + .is_some_and(|document| document.version > 0); + let provider_owns_document = is_virtual_source_uri(&self.provider_id, &uri); + if !document_is_open && !provider_owns_document { return Err(CoreError::new( ErrorCode::InvalidRequest, "The document is not open in the language server.", )); } - client_feature_request_canonical(ClientFeatureRequest { + let feature_request = ClientFeatureRequest { state: state.client.clone(), uri, method: method.to_string(), @@ -762,7 +767,12 @@ impl RuntimeSession { completion_item: request.completion_item, code_action: request.code_action, command: request.command, - })? + }; + if provider_owns_document { + client_provider_document_feature_request_canonical(feature_request)? + } else { + client_feature_request_canonical(feature_request)? + } } }; let request_id = (response.state.next_request_id - 1).to_string(); @@ -1136,12 +1146,16 @@ impl RuntimeSession { None, ) }); + let result = normalize_provider_navigation_result( + &self.provider_id, + event.and_then(|event| event.result.clone()), + ); push_request_event( self, &mut state, operation_id, &pending.method, - event.and_then(|event| event.result.clone()), + result, error, ); } @@ -1807,6 +1821,47 @@ fn push_request_event( }); } +fn normalize_provider_navigation_result( + provider_id: &str, + mut result: Option, +) -> Option { + let locations = result + .as_mut() + .and_then(|result| result.get_mut("locations")) + .and_then(Value::as_array_mut); + let Some(locations) = locations else { + return result; + }; + + for location in locations { + let Some(uri) = location + .get("uri") + .and_then(Value::as_str) + .map(ToString::to_string) + else { + continue; + }; + let normalized = normalize_location( + provider_id, + ProviderLocation { + uri, + is_read_only: location + .get("isReadOnly") + .and_then(Value::as_bool) + .unwrap_or(false), + display_path: location + .get("displayPath") + .and_then(Value::as_str) + .map(ToString::to_string), + }, + ); + location["isReadOnly"] = Value::Bool(normalized.is_read_only); + location["displayPath"] = normalized.display_path.map_or(Value::Null, Value::String); + } + + result +} + fn push_diagnostics_event( session: &RuntimeSession, state: &mut SessionState, @@ -2187,6 +2242,145 @@ mod tests { } } + /// Owns an opt-in real-process smoke session and removes every temporary + /// resource even when the smoke test unwinds after a failed assertion. + struct RealSmokeCleanup<'a> { + engine: &'a LspEngine, + session_id: String, + root: PathBuf, + } + + impl Drop for RealSmokeCleanup<'_> { + fn drop(&mut self) { + if let Ok(session) = self.engine.session(&self.session_id) { + let _ = session.stop(); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let terminal = session.snapshot().is_ok_and(|snapshot| { + matches!( + snapshot.state, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) + }); + if terminal { + break; + } + let _ = session.poll_events(); + thread::sleep(Duration::from_millis(20)); + } + if session.snapshot().is_ok_and(|snapshot| { + !matches!( + snapshot.state, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) + }) { + session.kill_process(); + } + } + let _ = self.engine.destroy(&self.session_id); + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn await_real_smoke_ready( + session: &Arc, + ) -> Result, String> { + let deadline = Instant::now() + Duration::from_secs(90); + let mut events = Vec::new(); + while Instant::now() < deadline { + events.extend(session.poll_events().map_err(|error| error.message)?); + let snapshot = session.snapshot().map_err(|error| error.message)?; + match snapshot.state { + LspLifecycleState::Ready => { + events.extend(session.poll_events().map_err(|error| error.message)?); + return Ok(events); + } + LspLifecycleState::Failed => { + return Err(format!("JDTLS failed during initialization: {events:?}")); + } + _ => thread::sleep(Duration::from_millis(20)), + } + } + Err(format!( + "JDTLS did not become ready before the smoke timeout: {events:?}" + )) + } + + fn real_smoke_request( + engine: &LspEngine, + session: &Arc, + operation: LspSemanticOperation, + uri: Option<&str>, + virtual_uri: Option<&str>, + position: Option, + ) -> Result { + let operation_id = engine.next_operation_id(); + session + .request( + SemanticRequest { + session_id: session.id.clone(), + operation_id: Some(operation_id.clone()), + operation, + uri: uri.map(str::to_string), + virtual_uri: virtual_uri.map(str::to_string), + position, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }, + operation_id.clone(), + ) + .map_err(|error| error.message)?; + + let deadline = Instant::now() + Duration::from_secs(35); + while Instant::now() < deadline { + for event in session.poll_events().map_err(|error| error.message)? { + if event.operation_id.as_deref() != Some(operation_id.as_str()) { + continue; + } + if let Some(error) = event.error { + return Err(format!( + "JDTLS smoke request {} failed: {error:?}", + semantic_method(operation) + )); + } + return event.result.ok_or_else(|| { + format!( + "JDTLS smoke request {} returned no result", + semantic_method(operation) + ) + }); + } + thread::sleep(Duration::from_millis(20)); + } + Err(format!( + "JDTLS smoke request {} timed out", + semantic_method(operation) + )) + } + + fn real_smoke_locations(result: &Value) -> &[Value] { + result + .get("locations") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + } + + fn real_smoke_token_position(text: &str, marker: &str, token_offset: usize) -> LspPosition { + let marker_index = text.find(marker).expect("smoke marker should exist") + token_offset; + let prefix = &text[..marker_index]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() as i64; + let line_prefix = prefix.rsplit_once('\n').map_or(prefix, |(_, tail)| tail); + LspPosition { + line, + utf16_column: line_prefix.encode_utf16().count() as i64, + } + } + /// Criterion 1: a spawned process that never initializes cannot become ready. #[test] fn a_server_that_never_answers_initialize_fails_instead_of_becoming_ready() { @@ -3046,6 +3240,67 @@ mod tests { assert!(request["params"].get("textDocument").is_none()); } + #[test] + fn java_start_enables_and_normalizes_class_file_navigation() { + let cache = std::env::temp_dir().join("lithe-core-java-navigation-tests"); + let mut harness = Harness::start(|request| { + request.provider_id = "java".to_string(); + request.cache_directory = Some(cache.to_string_lossy().into_owned()); + request.initialization_options = Some(json!({ + "extendedClientCapabilities": { "customCapability": true } + })); + }); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("the initialize request should reach JDT LS"); + assert_eq!( + initialize["params"]["initializationOptions"]["extendedClientCapabilities"] + ["classFileContentsSupport"], + true + ); + assert_eq!( + initialize["params"]["initializationOptions"]["extendedClientCapabilities"] + ["customCapability"], + true + ); + + harness + .server + .complete_initialize(json!({ "definitionProvider": true })); + harness.await_state(LspLifecycleState::Ready); + let uri = "file:///workspace/Main.java"; + harness.sync(uri, "class Main { String value; }"); + let operation_id = harness.request(LspSemanticOperation::Definition, uri); + let request_id = harness + .server + .await_request("textDocument/definition") + .expect("the definition request should reach JDT LS"); + let virtual_uri = "jdt://contents/java.base/java/lang/String.class?=demo"; + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": [{ + "uri": virtual_uri, + "range": { + "start": { "line": 10, "character": 4 }, + "end": { "line": 10, "character": 10 } + } + }] + })); + let event = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation_id.as_str())) + .clone(); + let location = &event.result.as_ref().unwrap()["locations"][0]; + assert_eq!(location["uri"], virtual_uri); + assert_eq!(location["isReadOnly"], true); + assert_eq!(location["displayPath"], "java.base/java/lang/String.java"); + + let _ = std::fs::remove_dir_all(cache); + } + #[test] fn java_virtual_document_returns_decompiled_text_without_an_open_document() { let mut harness = Harness::start(|request| { @@ -3110,6 +3365,320 @@ mod tests { assert!(event.error.is_none()); } + #[test] + fn java_virtual_semantics_bypass_did_open_without_weakening_physical_ownership() { + let mut harness = Harness::start(|request| { + request.provider_id = "java".to_string(); + request.cache_directory = Some("/tmp/lithe-lsp-engine-tests".to_string()); + }); + harness.server.complete_initialize(json!({ + "referencesProvider": true + })); + harness.await_state(LspLifecycleState::Ready); + let virtual_uri = "jdt://contents/java.base/java/lang/String.class?=smoke"; + let operation_id = harness.request(LspSemanticOperation::References, virtual_uri); + let request_id = harness + .server + .await_request("textDocument/references") + .expect("virtual references should reach JDT LS without didOpen"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": [] + })); + let event = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation_id.as_str())); + assert!(event.error.is_none()); + + let physical_operation_id = harness.engine.next_operation_id(); + let error = harness + .session() + .request( + SemanticRequest { + session_id: harness.session_id.clone(), + operation_id: Some(physical_operation_id.clone()), + operation: LspSemanticOperation::References, + uri: Some("file:///workspace/Unopened.java".to_string()), + virtual_uri: None, + position: Some(LspPosition { + line: 0, + utf16_column: 0, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }, + physical_operation_id, + ) + .expect_err("an unopened physical document must remain rejected"); + assert_eq!( + error.message, + "The document is not open in the language server." + ); + } + + #[test] + fn real_jdtls_routes_physical_and_virtual_references() { + let Ok(executable_path) = std::env::var("LITHE_JDTLS_SMOKE_EXECUTABLE") else { + return; + }; + let java_path = std::env::var("LITHE_JDTLS_SMOKE_JAVA") + .expect("LITHE_JDTLS_SMOKE_JAVA must accompany the JDTLS smoke executable"); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should follow the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-real-jdtls-smoke-{}-{stamp}", + std::process::id() + )); + let workspace = root.join("workspace"); + let source_directory = workspace + .join("src") + .join("main") + .join("java") + .join("smoke"); + let dependency_source_directory = root.join("dependency-source").join("dependency"); + let dependency_classes = root.join("dependency-classes"); + let dependency_jar = workspace.join("lib").join("smoke-dependency.jar"); + std::fs::create_dir_all(&source_directory).expect("smoke source directory should exist"); + std::fs::create_dir_all(&dependency_source_directory) + .expect("smoke dependency source directory should exist"); + std::fs::create_dir_all(&dependency_classes) + .expect("smoke dependency classes directory should exist"); + std::fs::create_dir_all( + dependency_jar + .parent() + .expect("dependency JAR should have a parent"), + ) + .expect("smoke dependency library directory should exist"); + let java_home = PathBuf::from(&java_path) + .parent() + .and_then(Path::parent) + .expect("smoke Java executable should be inside a JDK bin directory") + .to_path_buf(); + let executable_suffix = if cfg!(windows) { ".exe" } else { "" }; + let dependency_source = dependency_source_directory.join("Widget.java"); + std::fs::write( + &dependency_source, + "package dependency; public class Widget { public String value() { return \"widget\"; } }\n", + ) + .expect("smoke dependency source should be written"); + let javac_status = std::process::Command::new( + java_home + .join("bin") + .join(format!("javac{executable_suffix}")), + ) + .arg("-d") + .arg(&dependency_classes) + .arg(&dependency_source) + .status() + .expect("smoke javac should start"); + assert!(javac_status.success(), "smoke dependency should compile"); + let jar_status = std::process::Command::new( + java_home + .join("bin") + .join(format!("jar{executable_suffix}")), + ) + .arg("--create") + .arg("--file") + .arg(&dependency_jar) + .arg("-C") + .arg(&dependency_classes) + .arg(".") + .status() + .expect("smoke jar should start"); + assert!( + jar_status.success(), + "smoke dependency JAR should be created" + ); + std::fs::write( + workspace.join("pom.xml"), + r#" + 4.0.0 + smoke + lithe-jdtls-smoke + 1.0.0 + 17 + + + smoke + dependency + 1.0.0 + system + ${project.basedir}/lib/smoke-dependency.jar + + + +"#, + ) + .expect("smoke pom should be written"); + let source = r#"package smoke; + +import dependency.Widget; + +public class Main { + private Widget widget; + public Widget read() { return widget; } + public void write(Widget replacement) { widget = replacement; } +} +"#; + let source_path = source_directory.join("Main.java"); + std::fs::write(&source_path, source).expect("smoke source should be written"); + + let root_uri = url::Url::from_directory_path(&workspace) + .expect("workspace should convert to a file URI") + .to_string(); + let source_uri = url::Url::from_file_path(&source_path) + .expect("source should convert to a file URI") + .to_string(); + let engine = LspEngine::new(); + let started = engine + .start_server(StartServerRequest { + provider_id: "java".to_string(), + executable_path, + arguments: Vec::new(), + environment: BTreeMap::from([( + "JAVA_HOME".to_string(), + java_home.to_string_lossy().into_owned(), + )]), + root_uri, + working_directory: workspace.to_string_lossy().into_owned(), + initialization_options: None, + runtime_executable_path: Some(java_path), + cache_directory: Some(root.join("cache").to_string_lossy().into_owned()), + initialize_timeout_milliseconds: 90_000, + request_timeout_milliseconds: 30_000, + shutdown_timeout_milliseconds: 10_000, + }) + .expect("real JDTLS should start"); + let _cleanup = RealSmokeCleanup { + engine: &engine, + session_id: started.session_id.clone(), + root, + }; + let session = engine + .session(&started.session_id) + .expect("real JDTLS session should be registered"); + let mut ready_events = + await_real_smoke_ready(&session).unwrap_or_else(|error| panic!("{error}")); + let capability_deadline = Instant::now() + Duration::from_secs(30); + let capabilities = loop { + if let Some(capabilities) = ready_events.iter().find_map(|event| { + event + .capabilities + .as_ref() + .filter(|capabilities| { + ["definition", "references", "executeCommand"] + .iter() + .all(|required| capabilities.iter().any(|feature| feature == required)) + }) + .cloned() + }) { + break capabilities; + } + assert!( + Instant::now() < capability_deadline, + "real JDTLS should dynamically publish capabilities: {ready_events:?}" + ); + ready_events.extend( + session + .poll_events() + .expect("real JDTLS capability events should poll"), + ); + thread::sleep(Duration::from_millis(20)); + }; + for required in ["definition", "references", "executeCommand"] { + assert!( + capabilities.iter().any(|feature| feature == required), + "real JDTLS did not negotiate {required}: {capabilities:?}" + ); + } + + session + .sync_document(SyncDocumentRequest { + session_id: started.session_id.clone(), + uri: source_uri.clone(), + language_id: "java".to_string(), + text: source.to_string(), + }) + .expect("smoke source should synchronize"); + + let field_position = real_smoke_token_position(source, "Widget widget", "Widget ".len()); + let physical_deadline = Instant::now() + Duration::from_secs(30); + let physical_references = loop { + let result = real_smoke_request( + &engine, + &session, + LspSemanticOperation::References, + Some(&source_uri), + None, + Some(field_position), + ) + .unwrap_or_else(|error| panic!("{error}")); + if real_smoke_locations(&result).len() >= 3 { + break result; + } + assert!( + Instant::now() < physical_deadline, + "real JDTLS did not index physical references: {result}" + ); + thread::sleep(Duration::from_millis(250)); + }; + assert!(real_smoke_locations(&physical_references).len() >= 3); + + let widget_position = real_smoke_token_position(source, "Widget widget", 1); + let definition = real_smoke_request( + &engine, + &session, + LspSemanticOperation::Definition, + Some(&source_uri), + None, + Some(widget_position), + ) + .unwrap_or_else(|error| panic!("{error}")); + let virtual_uri = real_smoke_locations(&definition) + .iter() + .find_map(|location| location.get("uri").and_then(Value::as_str)) + .filter(|uri| uri.starts_with("jdt://")) + .expect("Widget definition should resolve to a JDT virtual URI") + .to_string(); + let virtual_document = real_smoke_request( + &engine, + &session, + LspSemanticOperation::VirtualDocument, + None, + Some(&virtual_uri), + None, + ) + .unwrap_or_else(|error| panic!("{error}")); + let virtual_source = virtual_document + .get("text") + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .expect("JDTLS should return decompiled Widget source"); + let virtual_position = + real_smoke_token_position(virtual_source, "class Widget", "class ".len()); + let virtual_references = real_smoke_request( + &engine, + &session, + LspSemanticOperation::References, + Some(&virtual_uri), + None, + Some(virtual_position), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert!( + real_smoke_locations(&virtual_references).iter().any(|location| { + location.get("uri").and_then(Value::as_str) == Some(source_uri.as_str()) + }), + "virtual Widget references should include the synchronized project source: {virtual_references}" + ); + } + #[test] fn java_runtime_is_derived_from_the_start_environment() { let environment = BTreeMap::from([("JAVA_HOME".to_string(), "/jdk".to_string())]); diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index 3642592df..7ec35d491 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -8,7 +8,7 @@ #![allow(dead_code)] // This module is an engine adapter seam; integration is intentionally separate. use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; @@ -107,6 +107,36 @@ pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { } } +/// Adds the JDT LS client extensions required for class-file navigation. +/// +/// Catalog-provided options are preserved, while the provider-owned capability +/// is authoritative because virtual class files cannot be opened without it. +pub(crate) fn adapt_initialization_options( + provider_id: &str, + initialization_options: Option, +) -> Option { + if !is_java_provider(provider_id) { + return initialization_options; + } + + let mut options = match initialization_options { + Some(Value::Object(options)) => options, + _ => Map::new(), + }; + let extended = options + .entry("extendedClientCapabilities") + .or_insert_with(|| json!({})); + if !extended.is_object() { + *extended = json!({}); + } + extended + .as_object_mut() + .expect("the extended capabilities were normalized to an object") + .insert("classFileContentsSupport".to_string(), Value::Bool(true)); + + Some(Value::Object(options)) +} + /// Returns JDT LS configuration values in the same order as the requested /// `workspace/configuration` items. `None` delegates non-Java providers to the /// generic engine behavior. @@ -154,7 +184,7 @@ pub(crate) fn virtual_source_resolve_params( provider_id: &str, uri: &str, ) -> Option { - if !is_java_provider(provider_id) || !has_uri_scheme(uri, JDT_URI_SCHEME) { + if !is_virtual_source_uri(provider_id, uri) { return None; } Some(ExecuteCommandParams { @@ -163,6 +193,11 @@ pub(crate) fn virtual_source_resolve_params( }) } +/// Returns whether a URI names a virtual source document owned by JDT LS. +pub(crate) fn is_virtual_source_uri(provider_id: &str, uri: &str) -> bool { + is_java_provider(provider_id) && has_uri_scheme(uri, JDT_URI_SCHEME) +} + /// Extracts the text shape returned by JDT LS for `java.decompile`. /// Different JDT LS versions return either the string directly or wrap it in /// a `content` member. @@ -471,6 +506,10 @@ mod tests { assert!(workspace_configuration("rust", &[]).is_none()); assert!(initialized_notification("rust").is_none()); assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); + assert_eq!( + adapt_initialization_options("rust", Some(json!({ "custom": true }))), + Some(json!({ "custom": true })) + ); let location = ProviderLocation { uri: "jdt://contents/A.class".to_string(), is_read_only: false, @@ -479,6 +518,31 @@ mod tests { assert_eq!(normalize_location("rust", location.clone()), location); } + #[test] + fn java_initialization_enables_class_file_content_without_losing_catalog_options() { + let options = adapt_initialization_options( + "JAVA", + Some(json!({ + "workspace": { "custom": true }, + "extendedClientCapabilities": { + "customCapability": true, + "classFileContentsSupport": false + } + })), + ) + .unwrap(); + + assert_eq!(options["workspace"]["custom"], true); + assert_eq!( + options["extendedClientCapabilities"]["customCapability"], + true + ); + assert_eq!( + options["extendedClientCapabilities"]["classFileContentsSupport"], + true + ); + } + #[test] fn java_workspace_configuration_matches_each_section_shape() { let items = [ diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 index 820382e69..00ed988ca 100644 --- a/scripts/prepare-jdtls.ps1 +++ b/scripts/prepare-jdtls.ps1 @@ -25,13 +25,18 @@ $cache = Join-Path $root ".artifacts/jdtls-downloads" $archiveUsesOverride = -not [string]::IsNullOrWhiteSpace($env:LITHE_JDTLS_ARCHIVE) $archiveHash = $manifest.archiveSHA256.ToLowerInvariant() $licenseHash = $manifest.licenseSHA256.ToLowerInvariant() +$lombokHash = $manifest.lombokSHA256.ToLowerInvariant() +$lombokLicenseHash = $manifest.lombokLicenseSHA256.ToLowerInvariant() $safeVersion = ([string]$manifest.version) -replace '[^A-Za-z0-9._-]', '_' +$safeLombokVersion = ([string]$manifest.lombokVersion) -replace '[^A-Za-z0-9._-]', '_' $archive = if ($archiveUsesOverride) { $env:LITHE_JDTLS_ARCHIVE } else { Join-Path $cache "jdtls-$safeVersion-$archiveHash.tar.gz" } $license = Join-Path $cache "EPL-2.0-$licenseHash.txt" +$lombok = Join-Path $cache "lombok-$safeLombokVersion-$lombokHash.jar" +$lombokLicense = Join-Path $cache "lombok-MIT-$safeLombokVersion-$lombokLicenseHash.txt" function Get-FileSHA256 { param([Parameter(Mandatory)][string]$Path) @@ -73,6 +78,10 @@ function Assert-JdtlsOutput { if (-not (Test-Path -LiteralPath (Join-Path $output "config_win") -PathType Container)) { throw "JDTLS Windows configuration is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.ps1") -PathType Leaf)) { throw "JDTLS PowerShell launcher is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.bat") -PathType Leaf)) { throw "JDTLS batch launcher is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/lombok.jar") -PathType Leaf)) { throw "JDTLS Lombok agent is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/LICENSE-MIT.txt") -PathType Leaf)) { throw "JDTLS Lombok license is missing: $output" } + $launcher = Get-Content -Raw -LiteralPath (Join-Path $output "bin/jdtls.ps1") + if (-not $launcher.Contains("-javaagent:")) { throw "JDTLS launcher does not load the Lombok agent: $output" } } if ($usesExistingRoot) { @@ -90,16 +99,25 @@ if ($archiveUsesOverride) { Get-VerifiedDownload -Uri $manifest.archiveURL -ExpectedSHA256 $archiveHash -Destination $archive -Description "JDTLS archive" } Get-VerifiedDownload -Uri $manifest.licenseURL -ExpectedSHA256 $licenseHash -Destination $license -Description "EPL-2.0 license" +Get-VerifiedDownload -Uri $manifest.lombokURL -ExpectedSHA256 $lombokHash -Destination $lombok -Description "Lombok agent" +Get-VerifiedDownload -Uri $manifest.lombokLicenseURL -ExpectedSHA256 $lombokLicenseHash -Destination $lombokLicense -Description "Lombok MIT license" if (Test-Path -LiteralPath $output) { Remove-Item -Recurse -Force -LiteralPath $output } New-Item -ItemType Directory -Force -Path $output | Out-Null tar.exe -xzf $archive -C $output Copy-Item -LiteralPath $license -Destination (Join-Path $output "LICENSE-EPL-2.0.txt") -Force +$lombokOutput = Join-Path $output "lombok" +New-Item -ItemType Directory -Force -Path $lombokOutput | Out-Null +Copy-Item -LiteralPath $lombok -Destination (Join-Path $lombokOutput "lombok.jar") -Force +Copy-Item -LiteralPath $lombokLicense -Destination (Join-Path $lombokOutput "LICENSE-MIT.txt") -Force $windowsLauncher = @' $ErrorActionPreference = "Stop" $javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" } +$lombokAgent = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\lombok\lombok.jar")) +if (-not (Test-Path -LiteralPath $lombokAgent -PathType Leaf)) { throw "JDTLS Lombok agent was not found: $lombokAgent" } $jvmArguments = [System.Collections.Generic.List[string]]::new() +$jvmArguments.Add("-javaagent:$lombokAgent") $jvmArguments.Add("--add-modules=ALL-SYSTEM") $jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED") $jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED") diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index b82133c60..2ae7bb211 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -15,9 +15,16 @@ archive_url="$(manifest_value archiveURL)" archive_sha256="$(manifest_value archiveSHA256)" license_url="$(manifest_value licenseURL)" license_sha256="$(manifest_value licenseSHA256)" +lombok_url="$(manifest_value lombokURL)" +lombok_sha256="$(manifest_value lombokSHA256)" +lombok_license_url="$(manifest_value lombokLicenseURL)" +lombok_license_sha256="$(manifest_value lombokLicenseSHA256)" jdtls_version="$(manifest_value version)" +lombok_version="$(manifest_value lombokVersion)" archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls-$jdtls_version-$archive_sha256.tar.gz}" license_path="$CACHE_DIR/EPL-2.0-$license_sha256.txt" +lombok_path="$CACHE_DIR/lombok-$lombok_version-$lombok_sha256.jar" +lombok_license_path="$CACHE_DIR/lombok-MIT-$lombok_version-$lombok_license_sha256.txt" file_sha256() { shasum -a 256 "$1" | awk '{print tolower($1)}' @@ -60,6 +67,10 @@ validate_output() { [[ -d "$OUTPUT_DIR/config_win" ]] || { print -u2 -- "JDTLS Windows configuration is missing: $OUTPUT_DIR"; exit 1; } [[ -x "$OUTPUT_DIR/bin/jdtls" ]] || { print -u2 -- "JDTLS launcher is missing: $OUTPUT_DIR/bin/jdtls"; exit 1; } [[ -f "$OUTPUT_DIR/bin/jdtls.ps1" ]] || { print -u2 -- "JDTLS Windows launcher is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/lombok/lombok.jar" ]] || { print -u2 -- "JDTLS Lombok agent is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" ]] || { print -u2 -- "JDTLS Lombok license is missing: $OUTPUT_DIR"; exit 1; } + grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls" || { print -u2 -- "JDTLS launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } + grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls.ps1" || { print -u2 -- "JDTLS Windows launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } } if [[ -n "${LITHE_JDTLS_ROOT:-}" ]]; then @@ -80,11 +91,16 @@ else download_verified_file "$archive_url" "$archive_sha256" "$archive_path" "JDTLS archive" fi download_verified_file "$license_url" "$license_sha256" "$license_path" "EPL-2.0 license" +download_verified_file "$lombok_url" "$lombok_sha256" "$lombok_path" "Lombok agent" +download_verified_file "$lombok_license_url" "$lombok_license_sha256" "$lombok_license_path" "Lombok MIT license" rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" tar -xzf "$archive_path" -C "$OUTPUT_DIR" cp "$license_path" "$OUTPUT_DIR/LICENSE-EPL-2.0.txt" +mkdir -p "$OUTPUT_DIR/lombok" +cp "$lombok_path" "$OUTPUT_DIR/lombok/lombok.jar" +cp "$lombok_license_path" "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" cat > "$OUTPUT_DIR/bin/jdtls" <<'EOF' #!/bin/zsh @@ -97,7 +113,10 @@ if [[ ! -x "$JAVA_EXECUTABLE" ]]; then JAVA_EXECUTABLE="${JAVA:-java}" fi +LOMBOK_AGENT="$SCRIPT_DIR/../lombok/lombok.jar" +[[ -f "$LOMBOK_AGENT" ]] || { print -u2 -- "JDTLS Lombok agent was not found: $LOMBOK_AGENT"; exit 1; } JVM_ARGUMENTS=( + "-javaagent:$LOMBOK_AGENT" "--add-modules=ALL-SYSTEM" "--add-opens=java.base/java.util=ALL-UNNAMED" "--add-opens=java.base/java.lang=ALL-UNNAMED" @@ -150,7 +169,10 @@ cat > "$OUTPUT_DIR/bin/jdtls.ps1" <<'EOF' $ErrorActionPreference = "Stop" $javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" } +$lombokAgent = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\lombok\lombok.jar")) +if (-not (Test-Path -LiteralPath $lombokAgent -PathType Leaf)) { throw "JDTLS Lombok agent was not found: $lombokAgent" } $jvmArguments = [System.Collections.Generic.List[string]]::new() +$jvmArguments.Add("-javaagent:$lombokAgent") $jvmArguments.Add("--add-modules=ALL-SYSTEM") $jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED") $jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED") diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 1cc6fd1ab..39ca2240a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -338,6 +338,10 @@ command fields, and returns `{ operationId }`. Supported operations include completion, hover, definition/declaration/type-definition, references, implementation, rename, formatting, code actions and resolve, execute command, inlay hints, folding ranges, code lens, and provider virtual documents. +The `virtualDocument` operation accepts `{ sessionId, operation, +virtualUri }` without a document `uri`. Its terminal `requestCompleted` event +returns `{ text }`, where `text` is the provider-resolved UTF-8 source for the +opaque virtual URI. `lsp.pollEvents` drains events ordered by per-session `sequence`. Event types include `stateChanged`, `featuresChanged`, `diagnostics`, diff --git a/third_party/jdtls/manifest.json b/third_party/jdtls/manifest.json index 2c526d0ed..ddb001889 100644 --- a/third_party/jdtls/manifest.json +++ b/third_party/jdtls/manifest.json @@ -4,5 +4,10 @@ "archiveSHA256": "ba697788a19f2ba57b16302aba6b343c649928c95f76b0d170494ac12d17ac78", "licenseURL": "https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt", "licenseSHA256": "0becf16567beb77fa252b7664631dd177c8f9a1889e48995b45379c7130e5303", + "lombokVersion": "1.18.46", + "lombokURL": "https://repo.maven.apache.org/maven2/org/projectlombok/lombok/1.18.46/lombok-1.18.46.jar", + "lombokSHA256": "01f7b1a015e33e2b62d5f5f37053306357ab1415fd181fcba7794f5d198c1126", + "lombokLicenseURL": "https://raw.githubusercontent.com/projectlombok/lombok/v1.18.46/LICENSE", + "lombokLicenseSHA256": "76479448741d7a7a3a97b6afd9ab9699d95621faafc65a1b8e9149342ea00feb", "minimumJavaVersion": 17 } diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index 3ed6a9840..9c32da240 100644 --- a/windows/tauri/src-tauri/src/lsp.rs +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -264,7 +264,16 @@ fn language_server_cache_directory(app: &AppHandle) -> PathBuf { } fn normalize_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") + let path = path.to_string_lossy(); + if let Some(network_path) = path.strip_prefix(r"\\?\UNC\") { + return format!("//{}", network_path.replace('\\', "/")); + } + + // Tauri can return verbatim resource paths, but cmd.exe cannot execute + // their `//?/` form after slash normalization. + path.strip_prefix(r"\\?\") + .unwrap_or(path.as_ref()) + .replace('\\', "/") } #[cfg(test)] @@ -375,6 +384,24 @@ mod tests { assert!(error.contains("11.0.26"), "{error}"); } + #[test] + fn strips_verbatim_prefix_from_windows_launch_paths() { + assert_eq!( + normalize_path(Path::new( + r"\\?\D:\Lithe\LanguageServers\jdtls\bin\jdtls.bat" + )), + "D:/Lithe/LanguageServers/jdtls/bin/jdtls.bat" + ); + } + + #[test] + fn preserves_unc_root_when_stripping_verbatim_prefix() { + assert_eq!( + normalize_path(Path::new(r"\\?\UNC\server\share\jdtls\bin\jdtls.bat")), + "//server/share/jdtls/bin/jdtls.bat" + ); + } + fn java_runtime(home_path: &str, version: &str) -> run::JavaRuntime { run::JavaRuntime { home_path: home_path.to_string(), diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index bee2eca18..ad43e5aac 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -53,6 +53,7 @@ import { toggleCaseText } from "../utils/text-operations"; import { editorAPI } from "../extensions/api"; import type { EditorModelPositionResolver } from "../view-model/view-layout"; import { syncContainedEditorFontOptions } from "../engines/monaco/contained-editors"; +import { registerMonacoDefinitionLinkGesture } from "../engines/monaco/definition-link"; import { consumeLocalContentSnapshot, rememberLocalContentSnapshot, @@ -166,7 +167,8 @@ export function MonacoEditor({ const buffer = activeBuffer && activeBuffer.type === "editor" ? activeBuffer : null; const content = buffer?.content ?? ""; const filePath = buffer?.path ?? ""; - const languageId = buffer?.languageOverride ?? getLanguageIdFromPath(filePath); + const languageId = + buffer?.languageOverride ?? buffer?.language ?? getLanguageIdFromPath(filePath); const monacoLanguageId = toMonacoLanguageId(languageId); const { fontFamily, @@ -699,6 +701,12 @@ export function MonacoEditor({ requestAnimationFrame(syncNestedEditorFonts); editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyA, selectEntireModel); + const definitionLinkDisposable = registerMonacoDefinitionLinkGesture({ + editor, + model, + filePath, + workspaceRoot: rootFolderPath, + }); const handleWindowSelectAllShortcut = (event: KeyboardEvent) => { const isSelectAllShortcut = @@ -834,6 +842,7 @@ export function MonacoEditor({ syncBottomScrollPadding(info.height); scheduleMonacoHoverClamp(); }), + definitionLinkDisposable, ]; const handleWindowMouseUp = () => { @@ -945,6 +954,7 @@ export function MonacoEditor({ readOnly, renderIndentGuides, renderWhitespace, + rootFolderPath, scrollable, scheduleInlineGitBlameRender, selectEntireModel, 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 8cd8e6fb0..af70765e7 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 @@ -6,10 +6,15 @@ import { languages, } from "monaco-editor"; import type * as Monaco from "monaco-editor"; +import { listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; -import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; +import { + lspDocumentTargetForEditorPath, + type LspDocumentTarget, +} from "@/features/editor/lsp/lsp-document-target"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { filePathFromUri } from "@/features/editor/lsp/workspace-edit"; import { MONACO_HIGHLIGHT_LANGUAGE_IDS } from "./language"; import { filePathFromLitheModelUri } from "./model-uri"; @@ -25,7 +30,7 @@ interface LspCodeLens { } interface ExecuteLspCodeLensPayload { - filePath: string; + target: LspDocumentTarget; lens: LspCodeLens; } @@ -114,6 +119,7 @@ function toShowReferencesArguments(argumentsValue: unknown[] | undefined): unkno export function toMonacoCodeLens( filePath: string, lens: LspCodeLens, + target: LspDocumentTarget = { filePath }, ): Monaco.languages.CodeLens | null { if (!lens.command) return null; @@ -137,7 +143,7 @@ export function toMonacoCodeLens( command: { id: EXECUTE_LSP_CODE_LENS_COMMAND, title: lens.title, - arguments: [{ filePath, lens } satisfies ExecuteLspCodeLensPayload], + arguments: [{ target, lens } satisfies ExecuteLspCodeLensPayload], }, }; } @@ -152,21 +158,21 @@ export function registerMonacoCodeLensProvider(): void { onDidChange: codeLensesChanged.event, async provideCodeLenses(model, token) { const filePath = filePathFromModel(model); + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); if ( - !filePath || - !isEditorLspSupported(filePath) || - !lspClient.getActiveServerEntryForFile(filePath) || - !lspClient.isDocumentOpen(filePath) + !target || + !lspClient.getDocumentAvailability(target, "codeLens").available || + (!target.documentUri && !lspClient.isDocumentOpen(target.filePath)) ) { return { lenses: [] }; } - const lenses = await lspClient.getCodeLens(filePath); + const lenses = await lspClient.getCodeLens(target); if (token.isCancellationRequested) return { lenses: [] }; return { lenses: lenses - .map((lens) => toMonacoCodeLens(filePath, lens)) + .map((lens) => toMonacoCodeLens(filePath, lens, target)) .filter((lens): lens is Monaco.languages.CodeLens => lens !== null), }; }, @@ -181,14 +187,15 @@ export function registerMonacoCodeLensProvider(): void { codeLensesChanged.fire(provider); } }); + void listen("lsp://features-changed", () => codeLensesChanged.fire(provider)); monacoEditor.addCommand({ id: EXECUTE_LSP_CODE_LENS_COMMAND, run: (_accessor, payload: ExecuteLspCodeLensPayload | undefined) => { - if (!payload?.filePath || !payload.lens.command) return; + if (!payload?.target.filePath || !payload.lens.command) return; void lspClient - .applyCodeAction(payload.filePath, { + .applyCodeAction(payload.target, { title: payload.lens.title, command: payload.lens.command, arguments: payload.lens.arguments ?? [], diff --git a/windows/tauri/src/features/editor/engines/monaco/definition-link.ts b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts new file mode 100644 index 000000000..d2769a042 --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts @@ -0,0 +1,130 @@ +import { editor as monacoEditor, Range as MonacoRange } from "monaco-editor"; +import type * as Monaco from "monaco-editor"; +import { isEditorLspSupported } from "@/features/editor/lsp/built-in-language-support"; +import { LspClient } from "@/features/editor/lsp/lsp-client"; +import { resolveLombokAccessorDefinition } from "@/features/editor/lsp/lombok-accessor-navigation"; +import { logger } from "@/features/editor/utils/logger"; +import { isEditorGoToDefinitionModifierActive } from "@/features/editor/utils/go-to-definition-gesture"; + +interface MonacoDefinitionLinkOptions { + editor: Monaco.editor.IStandaloneCodeEditor; + model: Monaco.editor.ITextModel; + filePath: string; + workspaceRoot?: string; +} + +export function registerMonacoDefinitionLinkGesture({ + editor, + model, + filePath, + workspaceRoot, +}: MonacoDefinitionLinkOptions): Monaco.IDisposable { + const decorations = editor.createDecorationsCollection(); + let requestVersion = 0; + let hoveredPosition: Monaco.Position | null = null; + let resolvedWordKey = ""; + + const clearLink = () => { + if (!resolvedWordKey && decorations.length === 0) return; + requestVersion += 1; + resolvedWordKey = ""; + decorations.clear(); + }; + + const showLink = async (position: Monaco.Position) => { + if (!filePath || !isEditorLspSupported(filePath)) { + clearLink(); + return; + } + + const word = model.getWordAtPosition(position); + if (!word) { + clearLink(); + return; + } + + const wordKey = `${position.lineNumber}:${word.startColumn}:${word.endColumn}`; + if (resolvedWordKey === wordKey) return; + resolvedWordKey = wordKey; + decorations.clear(); + const request = ++requestVersion; + + try { + const line = position.lineNumber - 1; + const character = position.column - 1; + const locations = await LspClient.getInstance().getDefinition(filePath, line, character); + const lombokDefinition = + (!locations || locations.length === 0) && model.getLanguageId() === "java" && workspaceRoot + ? await resolveLombokAccessorDefinition({ + source: model.getValue(), + sourceFilePath: filePath, + workspaceRoot, + line, + character, + }) + : null; + if (request !== requestVersion || model.isDisposed()) return; + if ((!locations || locations.length === 0) && !lombokDefinition) return; + + decorations.set([ + { + range: new MonacoRange( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn, + ), + options: { inlineClassName: "goto-definition-link" }, + }, + ]); + } catch (error) { + if (request !== requestVersion) return; + logger.error("DefinitionLink", "Could not resolve definition link:", error); + } + }; + + const syncLinkForModifier = (event: { + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + }) => { + if (hoveredPosition && isEditorGoToDefinitionModifierActive(event)) { + void showLink(hoveredPosition); + } else { + clearLink(); + } + }; + + const disposables = [ + editor.onMouseMove((event) => { + if ( + event.target.type !== monacoEditor.MouseTargetType.CONTENT_TEXT || + !event.target.position + ) { + hoveredPosition = null; + clearLink(); + return; + } + + hoveredPosition = event.target.position; + syncLinkForModifier(event.event); + }), + editor.onMouseLeave(() => { + hoveredPosition = null; + clearLink(); + }), + editor.onKeyDown((event) => syncLinkForModifier(event.browserEvent)), + editor.onKeyUp((event) => syncLinkForModifier(event.browserEvent)), + editor.onDidChangeModelContent(clearLink), + editor.onDidBlurEditorWidget(clearLink), + ]; + + return { + dispose() { + requestVersion += 1; + decorations.clear(); + for (const disposable of disposables) disposable.dispose(); + }, + }; +} 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 7dbd54448..9301454a8 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -1,19 +1,18 @@ -import { Emitter, languages, Range as MonacoRange, Uri } from "monaco-editor"; +import { languages, Range as MonacoRange, Uri } from "monaco-editor"; import type * as Monaco from "monaco-editor"; import type { CompletionItem, Hover } from "vscode-languageserver-protocol"; import { LspClient } from "@/features/editor/lsp/lsp-client"; import { formatHoverContents } from "@/features/editor/lsp/hover-content"; -import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; +import { lspDocumentTargetForEditorPath } from "@/features/editor/lsp/lsp-document-target"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { collectWorkspaceTextEdits, filePathFromUri, isWorkspaceEdit, type LspTextEdit, } from "@/features/editor/lsp/workspace-edit"; -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"; let providersRegistered = false; @@ -41,6 +40,12 @@ function toMonacoRange(range: { ); } +function toMonacoLocationUri(uri: string): Monaco.Uri { + return uri.startsWith("file://") || !uri.includes("://") + ? Uri.file(filePathFromUri(uri)) + : Uri.parse(uri); +} + function toMonacoTextEdit(edit: LspTextEdit): Monaco.languages.TextEdit { return { range: toMonacoRange(edit.range), @@ -179,47 +184,28 @@ function toWorkspaceEdit(edit: unknown): Monaco.languages.WorkspaceEdit | undefi return edits.length > 0 ? { edits } : undefined; } -function isLspModel(model: Monaco.editor.ITextModel): boolean { - const filePath = filePathFromModel(model); - return isEditorLspSupported(filePath); -} - export function registerMonacoLspProviders() { if (providersRegistered) return; providersRegistered = true; const selector = Array.from(MONACO_HIGHLIGHT_LANGUAGE_IDS); const lspClient = LspClient.getInstance(); - const semanticTokensChanged = new Emitter(); - useLspStore.subscribe((state, previousState) => { - const currentStatus = state.lspStatus; - const previousStatus = previousState.lspStatus; - if ( - currentStatus.status !== previousStatus.status || - currentStatus.documentRevision !== previousStatus.documentRevision - ) { - semanticTokensChanged.fire(); - } - }); - - languages.registerDocumentSemanticTokensProvider( - selector, - createMonacoSemanticTokenProvider({ - client: lspClient, - filePathFromModel, - isLspModel, - onDidChange: semanticTokensChanged.event, - }), - ); + const availableTarget = (model: Monaco.editor.ITextModel, feature: string) => { + const target = lspDocumentTargetForEditorPath( + useBufferStore.getState().buffers, + filePathFromModel(model), + ); + return target && lspClient.getDocumentAvailability(target, feature).available ? target : null; + }; languages.registerCompletionItemProvider(selector, { triggerCharacters: [".", ":", "<", '"', "'", "/", "@", "#"], async provideCompletionItems(model, position) { - if (!isLspModel(model)) return { suggestions: [] }; + const target = availableTarget(model, "completion"); + if (!target) return { suggestions: [] }; - const filePath = filePathFromModel(model); const completions = await lspClient.getCompletions( - filePath, + target, position.lineNumber - 1, position.column - 1, ); @@ -239,45 +225,30 @@ export function registerMonacoLspProviders() { languages.registerHoverProvider(selector, { async provideHover(model, position) { - if (!isLspModel(model)) return null; + const target = availableTarget(model, "hover"); + if (!target) return null; - const hover = await lspClient.getHover( - filePathFromModel(model), - position.lineNumber - 1, - position.column - 1, - ); + const hover = await lspClient.getHover(target, position.lineNumber - 1, position.column - 1); const contents = hoverToMarkdown(hover); return contents.length > 0 ? { contents } : null; }, }); - languages.registerDefinitionProvider(selector, { - async provideDefinition(model, position) { - if (!isLspModel(model)) return []; - - const locations = await lspClient.getDefinition( - filePathFromModel(model), - position.lineNumber - 1, - position.column - 1, - ); - return (locations ?? []).map((location) => ({ - uri: Uri.file(filePathFromUri(location.uri)), - range: toMonacoRange(location.range), - })); - }, - }); + // Lithe owns definition navigation so target files and virtual documents are + // opened as buffers. Monaco's standalone navigation cannot load those models. languages.registerImplementationProvider(selector, { async provideImplementation(model, position) { - if (!isLspModel(model)) return []; + const target = availableTarget(model, "implementation"); + if (!target) return []; const locations = await lspClient.getImplementation( - filePathFromModel(model), + target, position.lineNumber - 1, position.column - 1, ); return (locations ?? []).map((location) => ({ - uri: Uri.file(filePathFromUri(location.uri)), + uri: toMonacoLocationUri(location.uri), range: toMonacoRange(location.range), })); }, @@ -285,15 +256,16 @@ export function registerMonacoLspProviders() { languages.registerTypeDefinitionProvider(selector, { async provideTypeDefinition(model, position) { - if (!isLspModel(model)) return []; + const target = availableTarget(model, "typeDefinition"); + if (!target) return []; const locations = await lspClient.getTypeDefinition( - filePathFromModel(model), + target, position.lineNumber - 1, position.column - 1, ); return (locations ?? []).map((location) => ({ - uri: Uri.file(filePathFromUri(location.uri)), + uri: toMonacoLocationUri(location.uri), range: toMonacoRange(location.range), })); }, @@ -301,15 +273,16 @@ export function registerMonacoLspProviders() { languages.registerReferenceProvider(selector, { async provideReferences(model, position) { - if (!isLspModel(model)) return []; + const target = availableTarget(model, "references"); + if (!target) return []; const locations = await lspClient.getReferences( - filePathFromModel(model), + target, position.lineNumber - 1, position.column - 1, ); return (locations ?? []).map((location) => ({ - uri: Uri.file(filePathFromUri(location.uri)), + uri: toMonacoLocationUri(location.uri), range: toMonacoRange(location.range), })); }, @@ -317,7 +290,8 @@ export function registerMonacoLspProviders() { languages.registerRenameProvider(selector, { async resolveRenameLocation(model, position) { - if (!isLspModel(model)) { + const target = availableTarget(model, "rename"); + if (!target) { return { range: new MonacoRange( position.lineNumber, @@ -329,46 +303,30 @@ export function registerMonacoLspProviders() { }; } - const prepared = await lspClient.prepareRename( - filePathFromModel(model), - position.lineNumber - 1, - position.column - 1, - ); - const range = - prepared?.range ?? - (prepared?.start && prepared?.end ? { start: prepared.start, end: prepared.end } : null); - - if (!range) { - const word = model.getWordAtPosition(position); - return { - range: word - ? new MonacoRange( - position.lineNumber, - word.startColumn, - position.lineNumber, - word.endColumn, - ) - : new MonacoRange( - position.lineNumber, - position.column, - position.lineNumber, - position.column, - ), - text: prepared?.placeholder || word?.word || "", - }; - } - - const monacoRange = toMonacoRange(range); + const word = model.getWordAtPosition(position); return { - range: monacoRange, - text: prepared?.placeholder || model.getValueInRange(monacoRange), + range: word + ? new MonacoRange( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn, + ) + : new MonacoRange( + position.lineNumber, + position.column, + position.lineNumber, + position.column, + ), + text: word?.word || "", }; }, async provideRenameEdits(model, position, newName) { - if (!isLspModel(model)) return undefined; + const target = availableTarget(model, "rename"); + if (!target) return undefined; const edit = await lspClient.rename( - filePathFromModel(model), + target, position.lineNumber - 1, position.column - 1, newName, @@ -379,14 +337,14 @@ export function registerMonacoLspProviders() { languages.registerCodeActionProvider(selector, { async provideCodeActions(model, _range, context) { - if (!isLspModel(model)) return { actions: [], dispose: () => {} }; + const target = availableTarget(model, "codeActions"); + if (!target) return { actions: [], dispose: () => {} }; - const filePath = filePathFromModel(model); const actions: Monaco.languages.CodeAction[] = []; for (const marker of context.markers.slice(0, 3)) { const diagnostic = { severity: marker.severity === 8 ? "error" : marker.severity === 4 ? "warning" : "info", - filePath, + filePath: target.filePath, line: marker.startLineNumber - 1, column: marker.startColumn - 1, endLine: marker.endLineNumber - 1, @@ -395,7 +353,7 @@ export function registerMonacoLspProviders() { source: marker.source, code: typeof marker.code === "string" ? marker.code : undefined, } as const; - const lspActions = await lspClient.getCodeActions(filePath, diagnostic); + const lspActions = await lspClient.getCodeActions(target, diagnostic); for (const action of lspActions) { if (action.disabledReason) continue; const edit = toWorkspaceEdit(getPayloadEdit(action.payload)); diff --git a/windows/tauri/src/features/editor/engines/monaco/theme.ts b/windows/tauri/src/features/editor/engines/monaco/theme.ts index 0e5daf283..e23bf3791 100644 --- a/windows/tauri/src/features/editor/engines/monaco/theme.ts +++ b/windows/tauri/src/features/editor/engines/monaco/theme.ts @@ -74,6 +74,7 @@ function createMonacoThemeData( colors: { "editor.background": background, "editor.foreground": foreground, + "editorLink.activeForeground": accent, "editorCursor.foreground": cursor, "editor.selectionBackground": selection, "editor.inactiveSelectionBackground": selected, diff --git a/windows/tauri/src/features/editor/lsp/diagnostics-file-path.test.ts b/windows/tauri/src/features/editor/lsp/diagnostics-file-path.test.ts new file mode 100644 index 000000000..bee06e522 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/diagnostics-file-path.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { resolvePublishedDiagnosticsFilePath } from "./diagnostics-file-path"; + +describe("published diagnostics file paths", () => { + test("returns the concrete Windows buffer path across separator and case differences", () => { + const bufferPath = "C:\\Work\\src\\Main.java"; + + expect( + resolvePublishedDiagnosticsFilePath( + "c:/work/src/main.java", + [bufferPath], + ["c:/work/src/main.java"], + ), + ).toBe(bufferPath); + }); + + test("uses a tracked document path when the source buffer is not mounted", () => { + const trackedPath = "C:\\work\\src\\Main.java"; + + expect(resolvePublishedDiagnosticsFilePath("C:/work/src/Main.java", [], [trackedPath])).toBe( + trackedPath, + ); + }); + + test("compares UNC paths case-insensitively", () => { + const bufferPath = "\\\\SERVER\\Share\\Main.java"; + + expect(resolvePublishedDiagnosticsFilePath("//server/share/main.java", [bufferPath], [])).toBe( + bufferPath, + ); + }); + + test("keeps POSIX paths case-sensitive and rejects closed documents", () => { + expect( + resolvePublishedDiagnosticsFilePath("/workspace/Main.java", ["/workspace/main.java"], []), + ).toBeNull(); + expect(resolvePublishedDiagnosticsFilePath("C:/work/Main.java", [], [])).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/diagnostics-file-path.ts b/windows/tauri/src/features/editor/lsp/diagnostics-file-path.ts new file mode 100644 index 000000000..9492b1d7b --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/diagnostics-file-path.ts @@ -0,0 +1,30 @@ +import { normalizePath } from "@/utils/path-helpers"; + +function diagnosticPathKey(filePath: string): string { + const normalizedPath = normalizePath(filePath); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalizedPath) + ? normalizedPath.toLowerCase() + : normalizedPath; +} + +function findEquivalentPath( + publishedPathKey: string, + candidatePaths: Iterable, +): string | null { + for (const candidatePath of candidatePaths) { + if (diagnosticPathKey(candidatePath) === publishedPathKey) return candidatePath; + } + return null; +} + +export function resolvePublishedDiagnosticsFilePath( + publishedFilePath: string, + sourceBufferPaths: Iterable, + openDocumentPaths: Iterable, +): string | null { + const publishedPathKey = diagnosticPathKey(publishedFilePath); + return ( + findEquivalentPath(publishedPathKey, sourceBufferPaths) ?? + findEquivalentPath(publishedPathKey, openDocumentPaths) + ); +} diff --git a/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts b/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts index 7102023c4..aabe47ad5 100644 --- a/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts +++ b/windows/tauri/src/features/editor/lsp/language-server-navigation.test.ts @@ -8,10 +8,27 @@ describe("language server jump messages", () => { languageId: "java", status: "connected", hasSession: true, + ready: true, + featuresKnown: true, + supportsFeature: true, }), ).toBeNull(); }); + test("reports a negotiated unsupported capability", () => { + expect( + languageServerUnavailableMessage({ + languageId: "java", + status: "connected", + hasSession: true, + ready: true, + featuresKnown: true, + supportsFeature: false, + featureLabel: "references", + }), + ).toBe("Java language server does not support references."); + }); + test("reports startup, failure, and not-ready states", () => { expect( languageServerUnavailableMessage({ diff --git a/windows/tauri/src/features/editor/lsp/language-server-navigation.ts b/windows/tauri/src/features/editor/lsp/language-server-navigation.ts index bebf2a699..112cd8dcf 100644 --- a/windows/tauri/src/features/editor/lsp/language-server-navigation.ts +++ b/windows/tauri/src/features/editor/lsp/language-server-navigation.ts @@ -11,10 +11,17 @@ export function languageServerUnavailableMessage(args: { status: LspStatus; lastError?: string; hasSession: boolean; + ready?: boolean; + featuresKnown?: boolean; + supportsFeature?: boolean; + featureLabel?: string; }): string | null { - if (args.hasSession && args.status === "connected") return null; + if (args.hasSession && args.ready !== false && args.supportsFeature !== false) return null; const name = languageDisplayName(args.languageId); + if (args.hasSession && args.ready && args.featuresKnown && args.supportsFeature === false) { + return `${name} language server does not support ${args.featureLabel || "this feature"}.`; + } if (args.status === "connecting") { return `${name} language server is starting.`; } diff --git a/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.test.ts b/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.test.ts new file mode 100644 index 000000000..3d427117d --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + lombokAccessorTargetAtPosition, + resolveLombokAccessorDefinition, +} from "./lombok-accessor-navigation"; + +const source = `package com.score.controller; +import com.score.entity.SysUser; +class AuthController { + void find() { query.eq(SysUser::getUsername, "admin"); } +}`; + +function dependencies( + targetSource: string, + files = ["src/main/java/com/score/entity/SysUser.java"], +) { + const findSourceDefinition = mock( + async (_source: string, declarationName: string, memberName?: string) => { + const lines = targetSource.split(/\r?\n/); + const expression = memberName + ? new RegExp(`\\b${memberName}\\s*(?:=|;)`) + : new RegExp(`\\b(?:class|interface|enum|record)\\s+${declarationName}\\b`); + const line = lines.findIndex((value) => expression.test(value)); + if (line < 0) return null; + const token = memberName ?? declarationName; + return { line, utf16Column: lines[line].indexOf(token) }; + }, + ); + return { + listWorkspaceFiles: mock(async () => files), + readSource: mock(async () => targetSource), + findSourceDefinition, + }; +} + +describe("Lombok accessor navigation", () => { + test("recognizes JavaBeans method references only at the accessor token", () => { + const line = source.split("\n")[3]; + const character = line.indexOf("getUsername") + 2; + expect(lombokAccessorTargetAtPosition(source, 3, character)).toEqual({ + declarationName: "SysUser", + fieldName: "username", + kind: "getter", + }); + expect(lombokAccessorTargetAtPosition(source, 3, line.indexOf("query"))).toBeNull(); + }); + + test("infers the declared type of a simple instance receiver", () => { + const instanceSource = `class AuthController { + SysUser currentUser; + void authenticate(SysUser user) { + SysUser shadowed = user; + if (user.getStatus() == null || shadowed.getUsername() == null) {} + } +}`; + const lineText = instanceSource.split("\n")[4]; + + expect( + lombokAccessorTargetAtPosition(instanceSource, 4, lineText.indexOf("getStatus") + 2), + ).toEqual({ + declarationName: "SysUser", + fieldName: "status", + kind: "getter", + }); + expect( + lombokAccessorTargetAtPosition(instanceSource, 4, lineText.indexOf("getUsername") + 2), + ).toEqual({ + declarationName: "SysUser", + fieldName: "username", + kind: "getter", + }); + }); + + test("ignores same-name declarations from a closed method scope", () => { + const instanceSource = `class AuthController { + SysUser user; + void unrelated(OtherUser user) { + OtherUser local = user; + } + void authenticate() { + user.getStatus(); + } +}`; + const lineText = instanceSource.split("\n")[6]; + + expect( + lombokAccessorTargetAtPosition(instanceSource, 6, lineText.indexOf("getStatus") + 2), + ).toEqual({ + declarationName: "SysUser", + fieldName: "status", + kind: "getter", + }); + expect( + lombokAccessorTargetAtPosition(instanceSource, 6, lineText.indexOf("user") + 2), + ).toBeNull(); + }); + + test("does not guess instance receiver types or match non-code text", () => { + const instanceSource = `class AuthController { + void authenticate() { + unknown.getStatus(); + this.user.getStatus(); + user.refresh(); + // SysUser fake; fake.getStatus(); + String message = "SysUser fake; fake.getStatus()"; + } +}`; + + expect(lombokAccessorTargetAtPosition(instanceSource, 2, 17)).toBeNull(); + expect(lombokAccessorTargetAtPosition(instanceSource, 3, 19)).toBeNull(); + expect(lombokAccessorTargetAtPosition(instanceSource, 4, 10)).toBeNull(); + expect(lombokAccessorTargetAtPosition(instanceSource, 5, 30)).toBeNull(); + expect(lombokAccessorTargetAtPosition(instanceSource, 6, 42)).toBeNull(); + }); + + test("preserves JavaBeans acronym field names", () => { + expect(lombokAccessorTargetAtPosition("class A { Fn f = User::getURL; }", 0, 29)).toEqual({ + declarationName: "User", + fieldName: "URL", + kind: "getter", + }); + }); + + test("resolves a uniquely imported Lombok field declaration", async () => { + const targetSource = `package com.score.entity; +import lombok.Data; +@Data +public class SysUser { + private Long id; + private String username; +}`; + const deps = dependencies(targetSource, [ + "other/src/SysUser.java", + "score-management-backend/src/main/java/com/score/entity/SysUser.java", + ]); + const lineText = source.split("\n")[3]; + + const result = await resolveLombokAccessorDefinition( + { + source, + sourceFilePath: "F:/workspace/score-management-backend/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("getUsername") + 2, + }, + deps, + ); + + expect(result).toEqual({ + uri: "F:/workspace/score-management-backend/src/main/java/com/score/entity/SysUser.java", + filePath: "F:/workspace/score-management-backend/src/main/java/com/score/entity/SysUser.java", + range: { + start: { line: 5, character: 17 }, + end: { line: 5, character: 17 }, + }, + }); + expect(deps.findSourceDefinition).toHaveBeenCalledWith(targetSource, "SysUser", "username"); + }); + + test("resolves a Lombok field from an instance accessor", async () => { + const instanceSource = `package com.score.controller; +import com.score.entity.SysUser; +class AuthController { + void authenticate() { + SysUser user = findUser(); + if (user.getStatus() != 1) {} + } +}`; + const targetSource = `package com.score.entity; +import lombok.Data; +@Data +public class SysUser { + private Integer status; +}`; + const deps = dependencies(targetSource); + const lineText = instanceSource.split("\n")[5]; + + const result = await resolveLombokAccessorDefinition( + { + source: instanceSource, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 5, + character: lineText.indexOf("getStatus") + 2, + }, + deps, + ); + + expect(result).toEqual({ + uri: "F:/workspace/src/main/java/com/score/entity/SysUser.java", + filePath: "F:/workspace/src/main/java/com/score/entity/SysUser.java", + range: { + start: { line: 4, character: 18 }, + end: { line: 4, character: 18 }, + }, + }); + }); + + test("does not guess for non-Lombok or ambiguous target types", async () => { + const lineText = source.split("\n")[3]; + const options = { + source, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("getUsername") + 2, + }; + const plainJava = dependencies("public class SysUser { private String username; }"); + expect(await resolveLombokAccessorDefinition(options, plainJava)).toBeNull(); + expect(plainJava.findSourceDefinition).not.toHaveBeenCalled(); + + const ambiguous = dependencies("@lombok.Data class SysUser {}", [ + "one/SysUser.java", + "two/SysUser.java", + ]); + expect(await resolveLombokAccessorDefinition(options, ambiguous)).toBeNull(); + expect(ambiguous.readSource).not.toHaveBeenCalled(); + }); + + test("does not apply a field annotation to a different field", async () => { + const targetSource = `package com.score.entity; +import lombok.Getter; +public class SysUser { + @Getter private String displayName; + private String username; +}`; + const deps = dependencies(targetSource); + const lineText = source.split("\n")[3]; + + const result = await resolveLombokAccessorDefinition( + { + source, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("getUsername") + 2, + }, + deps, + ); + + expect(result).toBeNull(); + }); + + test("does not select a lone same-name type from a different package", async () => { + const externalSource = source.replace( + "import com.score.entity.SysUser;", + "import vendor.model.SysUser;", + ); + const targetSource = `package com.score.entity; +import lombok.Data; +@Data +public class SysUser { + private String username; +}`; + const deps = dependencies(targetSource); + const lineText = externalSource.split("\n")[3]; + + const result = await resolveLombokAccessorDefinition( + { + source: externalSource, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("getUsername") + 2, + }, + deps, + ); + + expect(result).toBeNull(); + expect(deps.readSource).not.toHaveBeenCalled(); + }); + + test("requires a primitive boolean field for an is-accessor", async () => { + const booleanReference = source.replace("getUsername", "isUsername"); + const targetSource = `package com.score.entity; +import lombok.Data; +@Data +public class SysUser { + private Boolean username; +}`; + const deps = dependencies(targetSource); + const lineText = booleanReference.split("\n")[3]; + + const result = await resolveLombokAccessorDefinition( + { + source: booleanReference, + sourceFilePath: "F:/workspace/src/AuthController.java", + workspaceRoot: "F:/workspace", + line: 3, + character: lineText.indexOf("isUsername") + 2, + }, + deps, + ); + + expect(result).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.ts b/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.ts new file mode 100644 index 000000000..48ef51094 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lombok-accessor-navigation.ts @@ -0,0 +1,311 @@ +import { executeCore } from "@/core/lithe-core-client"; +import { readFileContent } from "@/features/file-system/controllers/file-operations"; +import { joinPath, normalizePath, pathStartsWithRoot } from "@/utils/path-helpers"; +import type { LspLocation } from "./lsp-client"; + +interface JavaSourceDefinition { + line: number; + utf16Column: number; +} + +interface LombokAccessorTarget { + declarationName: string; + fieldName: string; + kind: "getter" | "boolean-getter" | "setter"; +} + +interface LombokNavigationDependencies { + listWorkspaceFiles: (root: string) => Promise; + readSource: (filePath: string) => Promise; + findSourceDefinition: ( + source: string, + declarationName: string, + memberName?: string, + ) => Promise; +} + +interface ResolveLombokAccessorDefinitionOptions { + source: string; + sourceFilePath: string; + workspaceRoot: string; + line: number; + character: number; +} + +function coreData(response: Awaited>>): T { + if (response.ok) return response.data; + throw new Error(response.error.message); +} + +function javaBeansFieldName(propertyName: string): string { + if (propertyName.length > 1 && /[A-Z]/.test(propertyName[0]) && /[A-Z]/.test(propertyName[1])) { + return propertyName; + } + return `${propertyName[0].toLowerCase()}${propertyName.slice(1)}`; +} + +function maskJavaCommentsAndStrings(source: string): string { + let masked = ""; + let state: "code" | "line-comment" | "block-comment" | "string" | "character" = "code"; + let escaped = false; + + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + const nextCharacter = source[index + 1]; + if (character === "\r" || character === "\n") { + masked += character; + if (state === "line-comment") state = "code"; + continue; + } + + if (state === "code") { + if (character === "/" && nextCharacter === "/") { + masked += " "; + index += 1; + state = "line-comment"; + } else if (character === "/" && nextCharacter === "*") { + masked += " "; + index += 1; + state = "block-comment"; + } else if (character === '"') { + masked += " "; + state = "string"; + escaped = false; + } else if (character === "'") { + masked += " "; + state = "character"; + escaped = false; + } else { + masked += character; + } + continue; + } + + if (state === "block-comment" && character === "*" && nextCharacter === "/") { + masked += " "; + index += 1; + state = "code"; + continue; + } + + if (state === "string" || state === "character") { + const delimiter = state === "string" ? '"' : "'"; + if (!escaped && character === delimiter) state = "code"; + escaped = !escaped && character === "\\"; + } + masked += " "; + } + + return masked; +} + +function openBraceStackAt(source: string, endOffset: number): number[] { + const stack: number[] = []; + for (let index = 0; index < endOffset; index += 1) { + if (source[index] === "{") stack.push(index); + if (source[index] === "}") stack.pop(); + } + return stack; +} + +function isScopePrefix(candidateScope: number[], cursorScope: number[]): boolean { + return candidateScope.every((braceOffset, index) => cursorScope[index] === braceOffset); +} + +function declaredReceiverType(sourcePrefix: string, receiverName: string): string | null { + const escapedReceiver = receiverName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const declaration = new RegExp( + `(?= 0 && + cursorScope.includes(nextBrace) && + (nextSemicolon < 0 || nextBrace < nextSemicolon); + } + + if (visible) declarationName = match[1]; + } + return declarationName; +} + +export function lombokAccessorTargetAtPosition( + source: string, + line: number, + character: number, +): LombokAccessorTarget | null { + const maskedLines = maskJavaCommentsAndStrings(source).split(/\r?\n/); + const lineText = maskedLines[line]; + if (!lineText || character < 0) return null; + + const methodReference = /\b([A-Z][A-Za-z0-9_$]*)\s*::\s*((get|set|is)([A-Z][A-Za-z0-9_$]*))/g; + let match: RegExpExecArray | null; + while ((match = methodReference.exec(lineText))) { + const accessorStart = match.index + match[0].lastIndexOf(match[2]); + const accessorEnd = accessorStart + match[2].length; + if (character < accessorStart || character > accessorEnd) continue; + const prefix = match[3]; + return { + declarationName: match[1], + fieldName: javaBeansFieldName(match[4]), + kind: prefix === "set" ? "setter" : prefix === "is" ? "boolean-getter" : "getter", + }; + } + + const instanceAccessor = + /(? accessorEnd) continue; + + const sourcePrefix = [...maskedLines.slice(0, line), lineText.slice(0, match.index)].join("\n"); + const declarationName = declaredReceiverType(sourcePrefix, match[1]); + if (!declarationName) return null; + + const prefix = match[3]; + return { + declarationName, + fieldName: javaBeansFieldName(match[4]), + kind: prefix === "set" ? "setter" : prefix === "is" ? "boolean-getter" : "getter", + }; + } + + return null; +} + +function importedTypeName(source: string, declarationName: string): string | null { + const escapedName = declarationName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const explicitImport = new RegExp( + `^\\s*import\\s+([A-Za-z_$][A-Za-z0-9_$.]*\\.${escapedName})\\s*;`, + "m", + ).exec(source)?.[1]; + if (explicitImport) return explicitImport; + + const packageName = /^\s*package\s+([A-Za-z_$][A-Za-z0-9_$.]*)\s*;/m.exec(source)?.[1]; + return packageName ? `${packageName}.${declarationName}` : declarationName; +} + +function selectTypeSourcePath( + source: string, + declarationName: string, + workspaceFiles: string[], +): string | null { + const expectedName = `${declarationName}.java`; + const candidates = workspaceFiles + .map(normalizePath) + .filter((path) => path.split("/").pop() === expectedName) + .sort((left, right) => left.localeCompare(right)); + if (candidates.length === 0) return null; + + const qualifiedName = importedTypeName(source, declarationName); + if (!qualifiedName) return null; + const expectedSuffix = `${qualifiedName.replace(/\./g, "/")}.java`; + const exactMatches = candidates.filter((path) => path.endsWith(expectedSuffix)); + return exactMatches.length === 1 ? exactMatches[0] : null; +} + +function annotationPrefix(source: string, definition: JavaSourceDefinition): string { + const lines = source.split(/\r?\n/); + const declarationLine = lines[definition.line] ?? ""; + const precedingAnnotations: string[] = []; + for (let index = definition.line - 1; index >= 0; index -= 1) { + const line = lines[index]; + if (!/^(?:@[A-Za-z_$][A-Za-z0-9_$.]*(?:\s*\([^)]*\))?\s*)+$/.test(line.trim())) break; + precedingAnnotations.unshift(line); + } + return [...precedingAnnotations, declarationLine.slice(0, definition.utf16Column)].join("\n"); +} + +function hasLombokAnnotation(source: string, prefix: string, annotation: string): boolean { + if (new RegExp(`@lombok\\.${annotation}\\b`).test(prefix)) return true; + if (!new RegExp(`@${annotation}\\b`).test(prefix)) return false; + return new RegExp(`^\\s*import\\s+lombok\\.(?:${annotation}|\\*)\\s*;`, "m").test(source); +} + +function hasLombokAccessor( + source: string, + target: LombokAccessorTarget, + typeDefinition: JavaSourceDefinition, + fieldDefinition: JavaSourceDefinition, +): boolean { + const fieldPrefix = annotationPrefix(source, fieldDefinition); + if (target.kind === "setter" && /\bfinal\b/.test(fieldPrefix)) return false; + if (target.kind === "boolean-getter" && !/\bboolean\s*$/.test(fieldPrefix)) return false; + + const annotation = target.kind === "setter" ? "Setter" : "Getter"; + const typePrefix = annotationPrefix(source, typeDefinition); + return ( + hasLombokAnnotation(source, typePrefix, "Data") || + hasLombokAnnotation(source, typePrefix, annotation) || + hasLombokAnnotation(source, fieldPrefix, annotation) + ); +} + +const defaultDependencies: LombokNavigationDependencies = { + async listWorkspaceFiles(root) { + const response = await executeCore<{ files: string[] }>({ + id: crypto.randomUUID(), + command: "workspace.snapshot", + payload: { root }, + }); + return coreData(response).files; + }, + readSource: readFileContent, + async findSourceDefinition(source, declarationName, memberName) { + const response = await executeCore({ + id: crypto.randomUUID(), + command: "java.sourceDefinition", + payload: { source, declarationName, memberName }, + }); + return coreData(response); + }, +}; + +export async function resolveLombokAccessorDefinition( + options: ResolveLombokAccessorDefinitionOptions, + dependencies: LombokNavigationDependencies = defaultDependencies, +): Promise { + if (!pathStartsWithRoot(options.sourceFilePath, options.workspaceRoot)) return null; + const target = lombokAccessorTargetAtPosition(options.source, options.line, options.character); + if (!target) return null; + + const workspaceFiles = await dependencies.listWorkspaceFiles(options.workspaceRoot); + const relativePath = selectTypeSourcePath(options.source, target.declarationName, workspaceFiles); + if (!relativePath) return null; + + const filePath = normalizePath(joinPath(options.workspaceRoot, relativePath)); + const targetSource = await dependencies.readSource(filePath); + if (!/@(?:lombok\.)?(?:Data|Getter|Setter)\b/.test(targetSource)) return null; + const definition = await dependencies.findSourceDefinition( + targetSource, + target.declarationName, + target.fieldName, + ); + if (!definition) return null; + const typeDefinition = await dependencies.findSourceDefinition( + targetSource, + target.declarationName, + ); + if (!typeDefinition || !hasLombokAccessor(targetSource, target, typeDefinition, definition)) { + return null; + } + + const position = { line: definition.line, character: definition.utf16Column }; + return { + uri: filePath, + filePath, + range: { start: position, end: position }, + }; +} diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 4df22d423..b743d6963 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -1,4 +1,8 @@ -import { invokeLsp as invoke } from "@/platform/lsp-core-adapter"; +import { + getLspSessionSnapshot, + invokeLsp as invoke, + isLspSemanticCommandSupported, +} from "@/platform/lsp-core-adapter"; import { listen } from "@tauri-apps/api/event"; import type { CompletionItem, @@ -14,14 +18,20 @@ import type { Diagnostic, DiagnosticCodeAction, } from "@/features/diagnostics/types/diagnostics.types"; -import { hasTextContent } from "@/features/panes/types/pane-content.types"; +import { hasTextContent, shouldStartLsp } 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 { normalizePath } from "@/utils/path-helpers"; import { isBuiltInLspPath, languageIdForEditorFile } from "./built-in-language-support"; +import { resolvePublishedDiagnosticsFilePath } from "./diagnostics-file-path"; import { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; import type { LspSemanticTokensResponse } from "./semantic-token-types"; -import { useLspStore } from "./stores/lsp.store"; +import { useLspStore, type LspStatus } from "./stores/lsp.store"; +import { + lspDocumentRequestArgs, + normalizeLspDocumentTarget, + type LspDocumentTargetInput, +} from "./lsp-document-target"; import { applyWorkspaceEdit, applyTextEditsToContent, @@ -36,8 +46,22 @@ export interface LspError { code?: string; } +export interface LspDocumentAvailability { + languageId?: string; + status: LspStatus; + hasSession: boolean; + ready: boolean; + featuresKnown: boolean; + supportsFeature: boolean; + available: boolean; + workspacePath?: string; +} + export interface LspLocation { uri: string; + filePath?: string | null; + displayPath?: string | null; + isReadOnly?: boolean; range: { start: { line: number; character: number }; end: { line: number; character: number }; @@ -124,6 +148,11 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function trackedFileKey(filePath: string): string { + const normalized = normalizePath(filePath); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + function getCodeActionEdit(actionPayload: unknown): unknown { if (!isRecord(actionPayload)) return null; return actionPayload.edit; @@ -258,21 +287,38 @@ export class LspClient { } private findServerKeyForFile(filePath: string, languageId?: string): string | null { + const targetKey = trackedFileKey(filePath); + const tracksFile = (trackedFiles: Set) => + Array.from(trackedFiles).some((trackedFile) => trackedFileKey(trackedFile) === targetKey); if (languageId) { const directMatch = Array.from(this.activeServerFiles.entries()).find( - ([key, trackedFiles]) => trackedFiles.has(filePath) && key.endsWith(`:${languageId}`), + ([key, trackedFiles]) => tracksFile(trackedFiles) && key.endsWith(`:${languageId}`), ); if (directMatch) return directMatch[0]; } const fallbackMatch = Array.from(this.activeServerFiles.entries()).find(([, trackedFiles]) => - trackedFiles.has(filePath), + tracksFile(trackedFiles), ); return fallbackMatch?.[0] ?? null; } private addTrackedFile(serverKey: string, filePath: string) { + const targetKey = trackedFileKey(filePath); + for (const [existingKey, trackedFiles] of this.activeServerFiles) { + if (existingKey === serverKey) continue; + for (const trackedFile of trackedFiles) { + if (trackedFileKey(trackedFile) === targetKey) trackedFiles.delete(trackedFile); + } + if (trackedFiles.size === 0) { + this.activeServerFiles.delete(existingKey); + this.activeLanguageServers.delete(existingKey); + } + } const trackedFiles = this.activeServerFiles.get(serverKey) ?? new Set(); + for (const trackedFile of trackedFiles) { + if (trackedFileKey(trackedFile) === targetKey) trackedFiles.delete(trackedFile); + } trackedFiles.add(filePath); this.activeServerFiles.set(serverKey, trackedFiles); } @@ -281,7 +327,10 @@ export class LspClient { const trackedFiles = this.activeServerFiles.get(serverKey); if (!trackedFiles) return; - trackedFiles.delete(filePath); + const targetKey = trackedFileKey(filePath); + for (const trackedFile of trackedFiles) { + if (trackedFileKey(trackedFile) === targetKey) trackedFiles.delete(trackedFile); + } if (trackedFiles.size === 0) { this.activeServerFiles.delete(serverKey); return; @@ -375,14 +424,22 @@ export class LspClient { logger.debug("LSPClient", `Received diagnostics for ${uri}:`, diagnostics); - // Convert URI to file path - const filePath = filePathFromUri(uri); - const isOpenInEditor = - this.openDocuments.has(filePath) || - !!getSourceEditorBufferByPath(useBufferStore.getState().buffers, filePath); + const publishedFilePath = filePathFromUri(uri); + const sourceBufferPaths = useBufferStore + .getState() + .buffers.filter(shouldStartLsp) + .map((buffer) => buffer.path); + const filePath = resolvePublishedDiagnosticsFilePath( + publishedFilePath, + sourceBufferPaths, + this.openDocuments, + ); - if (!isOpenInEditor) { - logger.debug("LSPClient", `Ignoring diagnostics for closed document: ${filePath}`); + if (!filePath) { + logger.debug( + "LSPClient", + `Ignoring diagnostics for closed document: ${publishedFilePath}`, + ); return; } @@ -463,7 +520,10 @@ export class LspClient { return; } - logger.debug("LSPClient", `Using LSP server: ${launch.serverPath} for language: ${launch.languageId}`); + 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`); @@ -590,7 +650,10 @@ export class LspClient { } const languageId = launch.languageId; - logger.debug("LSPClient", `Using LSP server: ${launch.serverPath} for language: ${languageId}`); + logger.debug( + "LSPClient", + `Using LSP server: ${launch.serverPath} for language: ${languageId}`, + ); const serverKey = `${workspacePath}:${languageId}`; if (options.forceRetry) { @@ -659,7 +722,38 @@ export class LspClient { } hasSessionForFile(filePath: string): boolean { - return this.findServerKeyForFile(filePath, languageIdForEditorFile(filePath)) !== null; + return getLspSessionSnapshot({ filePath }) !== null; + } + + getDocumentAvailability( + target: LspDocumentTargetInput, + feature?: string, + ): LspDocumentAvailability { + const document = normalizeLspDocumentTarget(target); + const session = getLspSessionSnapshot({ + filePath: document.filePath, + sessionFilePath: document.sessionFilePath, + }); + const languageId = + document.languageId ?? session?.languageId ?? languageIdForEditorFile(document.filePath); + const supportsFeature = Boolean( + session && (!feature || (session.featuresKnown && session.features.includes(feature))), + ); + const status: LspStatus = session + ? session.ready + ? "connected" + : "connecting" + : "disconnected"; + return { + languageId, + status, + hasSession: session !== null, + ready: session?.ready ?? false, + featuresKnown: session?.featuresKnown ?? false, + supportsFeature, + available: Boolean(session?.ready && supportsFeature), + workspacePath: session?.workspacePath, + }; } /** @@ -799,18 +893,22 @@ export class LspClient { } async getCompletions( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { + const document = normalizeLspDocumentTarget(target); try { - logger.debug("LSPClient", `Getting completions for ${filePath}:${line}:${character}`); + logger.debug( + "LSPClient", + `Getting completions for ${document.filePath}:${line}:${character}`, + ); logger.debug( "LSPClient", `Active language servers: ${Array.from(this.activeLanguageServers).join(", ")}`, ); const completions = await invoke("lsp_get_completions", { - filePath, + ...lspDocumentRequestArgs(document), line, character, }); @@ -826,10 +924,14 @@ export class LspClient { } } - async getHover(filePath: string, line: number, character: number): Promise { + async getHover( + target: LspDocumentTargetInput, + line: number, + character: number, + ): Promise { try { return await invoke("lsp_get_hover", { - filePath, + ...lspDocumentRequestArgs(target), line, character, }); @@ -844,14 +946,15 @@ export class LspClient { private async getNavigationLocations( command: "lsp_get_definition" | "lsp_get_implementation" | "lsp_get_type_definition", label: string, - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { + const document = normalizeLspDocumentTarget(target); try { - logger.debug("LSPClient", `Getting ${label} for ${filePath}:${line}:${character}`); + logger.debug("LSPClient", `Getting ${label} for ${document.filePath}:${line}:${character}`); const locations = await invoke(command, { - filePath, + ...lspDocumentRequestArgs(document), line, character, }); @@ -866,48 +969,55 @@ export class LspClient { } async getDefinition( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { - return this.getNavigationLocations( - "lsp_get_definition", - "definition", - filePath, - line, - character, - ); + return this.getNavigationLocations("lsp_get_definition", "definition", target, line, character); } async getImplementation( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { return this.getNavigationLocations( "lsp_get_implementation", "implementation", - filePath, + target, line, character, ); } async getTypeDefinition( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise { return this.getNavigationLocations( "lsp_get_type_definition", "type definition", - filePath, + target, line, character, ); } + async getVirtualDocument(filePath: string, virtualUri: string): Promise { + try { + return await invoke("lsp_get_virtual_document", { + filePath, + virtualUri, + }); + } catch (error) { + logger.error("LSPClient", `LSP virtual document error for ${virtualUri}:`, error); + return null; + } + } + async getSemanticTokens(filePath: string): Promise { + if (!isLspSemanticCommandSupported("lsp_get_semantic_tokens")) return null; try { return await invoke("lsp_get_semantic_tokens", { filePath }); } catch (error) { @@ -917,7 +1027,7 @@ export class LspClient { } } - async getCodeLens(filePath: string): Promise< + async getCodeLens(target: LspDocumentTargetInput): Promise< { line: number; title: string; @@ -926,7 +1036,7 @@ export class LspClient { }[] > { try { - return await invoke("lsp_get_code_lens", { filePath }); + return await invoke("lsp_get_code_lens", lspDocumentRequestArgs(target)); } catch (error) { if (isCanceledLspRequest(error)) return []; logger.error("LSPClient", "LSP code lens error:", error); @@ -935,7 +1045,7 @@ export class LspClient { } async getInlayHints( - filePath: string, + target: LspDocumentTargetInput, startLine: number, endLine: number, ): Promise< @@ -950,7 +1060,7 @@ export class LspClient { > { try { return await invoke("lsp_get_inlay_hints", { - filePath, + ...lspDocumentRequestArgs(target), startLine, endLine, }); @@ -974,6 +1084,7 @@ export class LspClient { hierarchyPath?: number[]; }[] > { + if (!isLspSemanticCommandSupported("lsp_get_document_symbols")) return []; try { logger.debug("LSPClient", `Getting document symbols for ${filePath}`); const symbols = await invoke< @@ -1013,6 +1124,7 @@ export class LspClient { filePath: string; }[] > { + if (!isLspSemanticCommandSupported("lsp_get_workspace_symbols")) return []; try { logger.debug("LSPClient", `Getting workspace symbols for "${query}" in ${workspacePath}`); const symbols = await invoke< @@ -1054,6 +1166,7 @@ export class LspClient { activeSignature?: number; activeParameter?: number; } | null> { + if (!isLspSemanticCommandSupported("lsp_get_signature_help")) return null; try { return await invoke("lsp_get_signature_help", { filePath, @@ -1067,6 +1180,7 @@ export class LspClient { } async getSignatureTriggerCharacters(filePath: string): Promise { + if (!isLspSemanticCommandSupported("lsp_get_signature_trigger_characters")) return []; try { return await invoke("lsp_get_signature_trigger_characters", { filePath }); } catch (error) { @@ -1075,9 +1189,12 @@ export class LspClient { } } - async formatDocument(filePath: string, content: string): Promise { + async formatDocument(target: LspDocumentTargetInput, content: string): Promise { try { - const edits = await invoke("lsp_format_document", { filePath }); + const edits = await invoke( + "lsp_format_document", + lspDocumentRequestArgs(target), + ); if (!edits.length) return content; return applyTextEditsToContent(content, edits); } catch (error) { @@ -1094,6 +1211,7 @@ export class LspClient { end: { line: number; character: number }; }, ): Promise { + if (!isLspSemanticCommandSupported("lsp_format_range")) return null; try { const edits = await invoke("lsp_format_range", { filePath, @@ -1111,7 +1229,7 @@ export class LspClient { } async getReferences( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, ): Promise< @@ -1124,8 +1242,9 @@ export class LspClient { }[] | null > { + const document = normalizeLspDocumentTarget(target); try { - logger.debug("LSPClient", `Getting references for ${filePath}:${line}:${character}`); + logger.debug("LSPClient", `Getting references for ${document.filePath}:${line}:${character}`); const references = await invoke< | { uri: string; @@ -1136,7 +1255,7 @@ export class LspClient { }[] | null >("lsp_get_references", { - filePath, + ...lspDocumentRequestArgs(document), line, character, }); @@ -1151,15 +1270,19 @@ export class LspClient { } async rename( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, newName: string, ): Promise { + const document = normalizeLspDocumentTarget(target); try { - logger.debug("LSPClient", `Renaming at ${filePath}:${line}:${character} to "${newName}"`); + logger.debug( + "LSPClient", + `Renaming at ${document.filePath}:${line}:${character} to "${newName}"`, + ); const result = await invoke("lsp_rename", { - filePath, + ...lspDocumentRequestArgs(document), line, character, newName, @@ -1179,6 +1302,7 @@ export class LspClient { line: number, character: number, ): Promise { + if (!isLspSemanticCommandSupported("lsp_prepare_rename")) return null; try { return await invoke("lsp_prepare_rename", { filePath, @@ -1191,10 +1315,13 @@ export class LspClient { } } - async getCodeActions(filePath: string, diagnostic: Diagnostic): Promise { + async getCodeActions( + target: LspDocumentTargetInput, + diagnostic: Diagnostic, + ): Promise { try { return await invoke("lsp_get_code_actions", { - filePath, + ...lspDocumentRequestArgs(target), diagnostic: { line: diagnostic.line, column: diagnostic.column, @@ -1213,7 +1340,7 @@ export class LspClient { } async applyCodeAction( - filePath: string, + target: LspDocumentTargetInput, actionPayload: unknown, ): Promise { try { @@ -1231,7 +1358,7 @@ export class LspClient { } const result = await invoke("lsp_apply_code_action", { - filePath, + ...lspDocumentRequestArgs(target), actionPayload, }); diff --git a/windows/tauri/src/features/editor/lsp/lsp-document-target.test.ts b/windows/tauri/src/features/editor/lsp/lsp-document-target.test.ts new file mode 100644 index 000000000..5524a82ce --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lsp-document-target.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; +import { + lspDocumentRequestArgs, + lspDocumentTargetForEditor, + lspDocumentTargetForEditorPath, + lspSessionFilePath, +} from "./lsp-document-target"; + +function editor(overrides: Partial): EditorContent { + return { + id: "buffer-1", + type: "editor", + path: "C:/work/Main.java", + name: "Main.java", + content: "class Main {}", + savedContent: "class Main {}", + isDirty: false, + isVirtual: false, + isPinned: false, + isPreview: false, + isActive: true, + tokens: [], + ...overrides, + }; +} + +describe("LSP document targets", () => { + test("uses a physical editor path as its session and document identity", () => { + const target = lspDocumentTargetForEditor(editor({})); + + expect(target).toEqual({ + filePath: "C:/work/Main.java", + documentUri: undefined, + sessionFilePath: undefined, + languageId: "java", + }); + expect(lspSessionFilePath(target)).toBe("C:/work/Main.java"); + }); + + test("preserves an opaque virtual URI and reuses its source-file session", () => { + const target = lspDocumentTargetForEditor( + editor({ + path: "jdt://contents/java.base/java/lang/String.class?=demo", + name: "String.java", + isVirtual: true, + readOnly: true, + language: "java", + lspDocument: { + documentUri: "jdt://contents/java.base/java/lang/String.class?=demo", + sessionFilePath: "C:/work/Main.java", + languageId: "java", + }, + }), + ); + + expect(lspDocumentRequestArgs(target)).toEqual({ + filePath: "jdt://contents/java.base/java/lang/String.class?=demo", + sessionFilePath: "C:/work/Main.java", + documentUri: "jdt://contents/java.base/java/lang/String.class?=demo", + }); + expect(target.languageId).toBe("java"); + }); + + test("prefers an explicit language override over the provider binding", () => { + const target = lspDocumentTargetForEditor( + editor({ + languageOverride: "kotlin", + lspDocument: { + documentUri: "provider://document", + sessionFilePath: "C:/work/Main.java", + languageId: "java", + }, + }), + ); + + expect(target.languageId).toBe("kotlin"); + }); + + test("resolves the provider target from the backing virtual editor buffer", () => { + const virtual = editor({ + path: "jdt://contents/java.base/java/lang/String.class?=demo", + isVirtual: true, + lspDocument: { + documentUri: "jdt://contents/java.base/java/lang/String.class?=demo", + sessionFilePath: "C:/work/Main.java", + languageId: "java", + }, + }); + + expect(lspDocumentTargetForEditorPath([virtual], virtual.path)).toEqual({ + filePath: virtual.path, + documentUri: virtual.path, + sessionFilePath: "C:/work/Main.java", + languageId: "java", + }); + expect(lspDocumentTargetForEditorPath([virtual], "C:/work/Missing.java")).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/lsp-document-target.ts b/windows/tauri/src/features/editor/lsp/lsp-document-target.ts new file mode 100644 index 000000000..c6c5d997b --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lsp-document-target.ts @@ -0,0 +1,51 @@ +import { getBufferByPath } from "@/features/editor/utils/buffer-index"; +import type { EditorContent, PaneContent } from "@/features/panes/types/pane-content.types"; +import { languageIdForEditorFile } from "./built-in-language-support"; + +export interface LspDocumentTarget { + filePath: string; + documentUri?: string; + sessionFilePath?: string; + languageId?: string; +} + +export type LspDocumentTargetInput = string | LspDocumentTarget; + +export function normalizeLspDocumentTarget(target: LspDocumentTargetInput): LspDocumentTarget { + return typeof target === "string" ? { filePath: target } : target; +} + +export function lspDocumentTargetForEditor(buffer: EditorContent): LspDocumentTarget { + return { + filePath: buffer.path, + documentUri: buffer.lspDocument?.documentUri, + sessionFilePath: buffer.lspDocument?.sessionFilePath, + languageId: + buffer.languageOverride ?? + buffer.lspDocument?.languageId ?? + buffer.language ?? + languageIdForEditorFile(buffer.path), + }; +} + +export function lspDocumentTargetForEditorPath( + buffers: readonly PaneContent[], + filePath: string, +): LspDocumentTarget | null { + const buffer = getBufferByPath(buffers, filePath); + return buffer?.type === "editor" ? lspDocumentTargetForEditor(buffer) : null; +} + +export function lspDocumentRequestArgs(target: LspDocumentTargetInput) { + const document = normalizeLspDocumentTarget(target); + return { + filePath: document.filePath, + sessionFilePath: document.sessionFilePath, + documentUri: document.documentUri, + }; +} + +export function lspSessionFilePath(target: LspDocumentTargetInput): string { + const document = normalizeLspDocumentTarget(target); + return document.sessionFilePath ?? document.filePath; +} diff --git a/windows/tauri/src/features/editor/lsp/navigation-target.test.ts b/windows/tauri/src/features/editor/lsp/navigation-target.test.ts new file mode 100644 index 000000000..9dbb2c4d9 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/navigation-target.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { LspLocation } from "./lsp-client"; +import { openLspNavigationLocation } from "./navigation-target"; +import type { OpenContentSpec, PaneContent } from "@/features/panes/types/pane-content.types"; + +const range = { + start: { line: 1, character: 2 }, + end: { line: 1, character: 3 }, +}; + +function createOptions(location: LspLocation, buffers: PaneContent[] = []) { + const openContent = mock((_spec: OpenContentSpec) => "opened-buffer"); + const setActiveBuffer = mock((_bufferId: string) => undefined); + const updateBuffer = mock((_buffer: PaneContent) => undefined); + const getVirtualDocument = mock( + async (): Promise => "public final class String {}", + ); + const readFileContent = mock(async () => "class Main {}"); + + return { + options: { + location, + sourceFilePath: "C:/work/src/Main.java", + buffers, + actions: { openContent, setActiveBuffer, updateBuffer }, + getVirtualDocument, + readFileContent, + }, + openContent, + setActiveBuffer, + updateBuffer, + getVirtualDocument, + readFileContent, + }; +} + +describe("LSP navigation targets", () => { + test("opens physical file locations with their Core-projected path", async () => { + const context = createOptions({ + uri: "file:///C:/work/src/Service.java", + filePath: "C:/work/src/Service.java", + range, + }); + + expect(await openLspNavigationLocation(context.options)).toBe("opened-buffer"); + expect(context.readFileContent).toHaveBeenCalledWith("C:/work/src/Service.java"); + expect(context.openContent).toHaveBeenCalledWith({ + type: "editor", + path: "C:/work/src/Service.java", + name: "Service.java", + content: "class Main {}", + }); + expect(context.getVirtualDocument).not.toHaveBeenCalled(); + }); + + test("normalizes a leading slash from Windows file URIs", async () => { + const context = createOptions({ + uri: "file:///C:/work/src/Service.java", + filePath: "/C:/work/src/Service.java", + range, + }); + + await openLspNavigationLocation(context.options); + + expect(context.readFileContent).toHaveBeenCalledWith("C:/work/src/Service.java"); + }); + + test("opens JDT class files as read-only Java buffers", async () => { + const location: LspLocation = { + uri: "jdt://contents/java.base/java/lang/String.class?=demo", + filePath: null, + displayPath: "java.base/java/lang/String.java", + isReadOnly: true, + range, + }; + const context = createOptions(location); + + expect(await openLspNavigationLocation(context.options)).toBe("opened-buffer"); + expect(context.getVirtualDocument).toHaveBeenCalledWith("C:/work/src/Main.java", location.uri); + expect(context.openContent).toHaveBeenCalledWith({ + type: "editor", + path: location.uri, + name: "String.java", + content: "public final class String {}", + isVirtual: true, + readOnly: true, + language: "java", + lspDocument: { + documentUri: location.uri, + sessionFilePath: "C:/work/src/Main.java", + languageId: "java", + }, + }); + expect(context.readFileContent).not.toHaveBeenCalled(); + }); + + test("reuses an open virtual buffer without resolving it again", async () => { + const uri = "jdt://contents/java.base/java/lang/String.class?=demo"; + const existing = { id: "existing-buffer", path: uri } as PaneContent; + const context = createOptions({ uri, filePath: null, range }, [existing]); + + expect(await openLspNavigationLocation(context.options)).toBe("existing-buffer"); + expect(context.setActiveBuffer).toHaveBeenCalledWith("existing-buffer"); + expect(context.getVirtualDocument).not.toHaveBeenCalled(); + expect(context.openContent).not.toHaveBeenCalled(); + }); + + test("rebinds a reused virtual buffer to the current source session", async () => { + const uri = "jdt://contents/java.base/java/lang/String.class?=demo"; + const existing = { + id: "existing-buffer", + type: "editor", + path: uri, + name: "String.java", + content: "old source", + savedContent: "old source", + isDirty: false, + isVirtual: true, + isPinned: false, + isPreview: false, + isActive: false, + readOnly: true, + language: "java", + lspDocument: { + documentUri: uri, + sessionFilePath: "C:/work/parent/src/Old.java", + languageId: "java", + }, + tokens: [], + } satisfies PaneContent; + const context = createOptions({ uri, filePath: null, range }, [existing]); + + expect(await openLspNavigationLocation(context.options)).toBe("existing-buffer"); + expect(context.getVirtualDocument).toHaveBeenCalledWith("C:/work/src/Main.java", uri); + expect(context.updateBuffer).toHaveBeenCalledWith({ + ...existing, + content: "public final class String {}", + savedContent: "public final class String {}", + isActive: false, + lspDocument: { + documentUri: uri, + sessionFilePath: "C:/work/src/Main.java", + languageId: "java", + }, + }); + expect(context.setActiveBuffer).toHaveBeenCalledWith("existing-buffer"); + expect(context.openContent).not.toHaveBeenCalled(); + }); + + test("does not open a buffer when virtual source is unavailable", async () => { + const context = createOptions({ + uri: "jdt://contents/java.base/java/lang/String.class?=demo", + filePath: null, + range, + }); + context.getVirtualDocument.mockImplementation(async () => null); + + expect(await openLspNavigationLocation(context.options)).toBeNull(); + expect(context.openContent).not.toHaveBeenCalled(); + expect(context.setActiveBuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/navigation-target.ts b/windows/tauri/src/features/editor/lsp/navigation-target.ts new file mode 100644 index 000000000..3182fd500 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/navigation-target.ts @@ -0,0 +1,119 @@ +import type { LspLocation } from "./lsp-client"; +import { filePathFromUri } from "./workspace-edit"; +import type { OpenContentSpec, PaneContent } from "@/features/panes/types/pane-content.types"; +import { getBaseName, normalizePath } from "@/utils/path-helpers"; + +interface NavigationBufferActions { + openContent: (spec: OpenContentSpec) => string; + setActiveBuffer: (bufferId: string) => void; + updateBuffer: (buffer: PaneContent) => void; +} + +interface OpenLspNavigationLocationOptions { + location: LspLocation; + sourceFilePath: string; + buffers: readonly PaneContent[]; + actions: NavigationBufferActions; + getVirtualDocument: (sourceFilePath: string, virtualUri: string) => Promise; + readFileContent: (filePath: string) => Promise; +} + +function physicalPath(location: LspLocation): string | null { + const explicitPath = location.filePath?.trim(); + const uriPath = + location.uri.startsWith("file://") || !location.uri.includes("://") + ? filePathFromUri(location.uri) + : null; + const path = explicitPath || uriPath; + if (!path) return null; + + const normalized = normalizePath(path); + return /^\/[A-Za-z]:\//.test(normalized) ? normalized.slice(1) : normalized; +} + +function virtualDocumentName(location: LspLocation): string { + const displayPath = location.displayPath?.trim(); + const identityWithoutQuery = location.uri.split(/[?#]/, 1)[0]; + return getBaseName(displayPath || identityWithoutQuery, "Decompiled.java").replace( + /\.class$/i, + ".java", + ); +} + +function physicalPathKey(path: string): string { + const normalized = normalizePath(path); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +export async function openLspNavigationLocation({ + location, + sourceFilePath, + buffers, + actions, + getVirtualDocument, + readFileContent, +}: OpenLspNavigationLocationOptions): Promise { + const filePath = physicalPath(location); + const targetPath = filePath ?? location.uri; + const existingBuffer = buffers.find((buffer) => buffer.path === targetPath); + if (existingBuffer) { + if ( + !filePath && + existingBuffer.type === "editor" && + (existingBuffer.lspDocument?.documentUri !== location.uri || + physicalPathKey(existingBuffer.lspDocument.sessionFilePath) !== + physicalPathKey(sourceFilePath)) + ) { + const content = await getVirtualDocument(sourceFilePath, location.uri); + if (content == null) return null; + actions.updateBuffer({ + ...existingBuffer, + content, + savedContent: content, + isDirty: false, + isVirtual: true, + readOnly: true, + language: "java", + lspDocument: { + documentUri: location.uri, + sessionFilePath: sourceFilePath, + languageId: "java", + }, + }); + } + actions.setActiveBuffer(existingBuffer.id); + return existingBuffer.id; + } + + if (filePath) { + const content = await readFileContent(filePath); + const bufferId = actions.openContent({ + type: "editor", + path: filePath, + name: getBaseName(filePath), + content, + }); + actions.setActiveBuffer(bufferId); + return bufferId; + } + + const content = await getVirtualDocument(sourceFilePath, location.uri); + if (content == null) return null; + + const bufferId = actions.openContent({ + type: "editor", + path: location.uri, + name: virtualDocumentName(location), + content, + isVirtual: true, + readOnly: true, + language: "java", + lspDocument: { + documentUri: location.uri, + sessionFilePath: sourceFilePath, + languageId: "java", + }, + }); + actions.setActiveBuffer(bufferId); + return bufferId; +} 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 42aa8b05f..5f98f3330 100644 --- a/windows/tauri/src/features/editor/lsp/use-code-lens.ts +++ b/windows/tauri/src/features/editor/lsp/use-code-lens.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { isEditorLspSupported } from "./built-in-language-support"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { LspClient } from "./lsp-client"; +import { lspDocumentTargetForEditorPath } from "./lsp-document-target"; import { useLspStore } from "./stores/lsp.store"; export interface CodeLensItem { @@ -19,19 +20,24 @@ export const useCodeLens = (filePath: string | undefined, enabled: boolean) => { }); const fetchLenses = useCallback(async () => { - if (!filePath || !enabled || !isEditorLspSupported(filePath)) { + if (!filePath || !enabled) { setLenses([]); return; } const id = ++requestIdRef.current; const lspClient = LspClient.getInstance(); - if (!lspClient.getActiveServerEntryForFile(filePath) || !lspClient.isDocumentOpen(filePath)) { + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); + if ( + !target || + !lspClient.getDocumentAvailability(target, "codeLens").available || + (!target.documentUri && !lspClient.isDocumentOpen(target.filePath)) + ) { setLenses([]); return; } - const result = await lspClient.getCodeLens(filePath); + const result = await lspClient.getCodeLens(target); if (id !== requestIdRef.current) return; setLenses(result); diff --git a/windows/tauri/src/features/editor/lsp/use-rename.ts b/windows/tauri/src/features/editor/lsp/use-rename.ts index f050e7830..5df80975f 100644 --- a/windows/tauri/src/features/editor/lsp/use-rename.ts +++ b/windows/tauri/src/features/editor/lsp/use-rename.ts @@ -1,9 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { editorAPI } from "@/features/editor/extensions/api"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useEditorStateStore } from "@/features/editor/stores/state.store"; import { getLineTextFromContent } from "@/features/editor/utils/position"; +import { lspDocumentTargetForEditorPath } from "./lsp-document-target"; import { LspClient } from "./lsp-client"; -import { applyWorkspaceEdit, isWorkspaceEdit, offsetFromPosition } from "./workspace-edit"; +import { applyWorkspaceEdit, isWorkspaceEdit } from "./workspace-edit"; import { logger } from "../utils/logger"; interface RenameState { @@ -19,18 +21,6 @@ function getWordUnderCursor(line: string, column: number): string { return (before?.[0] || "") + (after?.[0]?.slice(1) || ""); } -function getTextForRange( - content: string, - range: { - start: { line: number; character: number }; - end: { line: number; character: number }; - }, -): string { - const start = offsetFromPosition(content, range.start); - const end = offsetFromPosition(content, range.end); - return content.slice(start, end); -} - export const useRename = (filePath: string | undefined) => { const [renameState, setRenameState] = useState(null); const inputRef = useRef(null); @@ -42,20 +32,9 @@ export const useRename = (filePath: string | undefined) => { const content = editorAPI.getContent(); const currentLine = getLineTextFromContent(content, cursorPosition.line); const lspClient = LspClient.getInstance(); - const prepared = await lspClient.prepareRename( - filePath, - cursorPosition.line, - cursorPosition.column, - ); - const preparedRange = prepared?.range - ? prepared.range - : prepared?.start && prepared?.end - ? { start: prepared.start, end: prepared.end } - : null; - const symbol = - prepared?.placeholder || - (preparedRange ? getTextForRange(content, preparedRange) : "") || - getWordUnderCursor(currentLine, cursorPosition.column); + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); + if (!target || !lspClient.getDocumentAvailability(target, "rename").available) return; + const symbol = getWordUnderCursor(currentLine, cursorPosition.column); if (!symbol) return; @@ -91,8 +70,10 @@ export const useRename = (filePath: string | undefined) => { try { const lspClient = LspClient.getInstance(); + const target = lspDocumentTargetForEditorPath(useBufferStore.getState().buffers, filePath); + if (!target || !lspClient.getDocumentAvailability(target, "rename").available) return; const result = await lspClient.rename( - filePath, + target, renameState.line, renameState.column, trimmed, diff --git a/windows/tauri/src/features/editor/lsp/workspace-edit.test.ts b/windows/tauri/src/features/editor/lsp/workspace-edit.test.ts new file mode 100644 index 000000000..be1b7036c --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/workspace-edit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { filePathFromUri } from "./workspace-edit"; + +describe("LSP file URI paths", () => { + test("removes the URI slash before a Windows drive", () => { + expect(filePathFromUri("file:///C:/work/src/Main.java")).toBe("C:/work/src/Main.java"); + }); + + test("preserves UNC hosts", () => { + expect(filePathFromUri("file://server/share/src/Main.java")).toBe( + "//server/share/src/Main.java", + ); + }); + + test("keeps POSIX absolute paths absolute", () => { + expect(filePathFromUri("file:///Users/dev/src/Main.java")).toBe("/Users/dev/src/Main.java"); + }); + + test("decodes escaped path segments", () => { + expect(filePathFromUri("file:///C:/work/My%20Project/Main.java")).toBe( + "C:/work/My Project/Main.java", + ); + }); + + test("keeps malformed escape sequences readable", () => { + expect(filePathFromUri("file:///C:/work/%invalid/Main.java")).toBe( + "C:/work/%invalid/Main.java", + ); + }); +}); diff --git a/windows/tauri/src/features/editor/lsp/workspace-edit.ts b/windows/tauri/src/features/editor/lsp/workspace-edit.ts index 2d9b35501..a6d1c7ad8 100644 --- a/windows/tauri/src/features/editor/lsp/workspace-edit.ts +++ b/windows/tauri/src/features/editor/lsp/workspace-edit.ts @@ -67,14 +67,28 @@ export function isWorkspaceEdit(value: unknown): value is WorkspaceEdit { return hasChanges || hasDocumentChanges; } +function decodeUriPath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + export function filePathFromUri(uri: string): string { if (!uri.startsWith("file://")) return uri; try { const url = new URL(uri); - return decodeURIComponent(url.pathname); + const pathname = decodeUriPath(url.pathname); + // URL.pathname keeps the leading slash in `file:///C:/...`; remove it so + // Windows hosts receive a drive-qualified path instead of `/C:/...`. + if (/^\/[A-Za-z]:\//.test(pathname)) return pathname.slice(1); + if (url.hostname) return `//${url.hostname}${pathname}`; + return pathname; } catch { - return decodeURIComponent(uri.replace(/^file:\/\//, "")); + const path = decodeUriPath(uri.replace(/^file:\/\//, "")); + return path.replace(/^\/([A-Za-z]:[\\/])/, "$1"); } } diff --git a/windows/tauri/src/features/editor/stores/buffer-content-factory.ts b/windows/tauri/src/features/editor/stores/buffer-content-factory.ts index ef5b49754..5c44abe87 100644 --- a/windows/tauri/src/features/editor/stores/buffer-content-factory.ts +++ b/windows/tauri/src/features/editor/stores/buffer-content-factory.ts @@ -23,6 +23,7 @@ export const createPaneContent = (id: string, spec: OpenContentSpec): PaneConten isPreview: spec.isPreview ?? false, readOnly: spec.readOnly, language: spec.language ?? detectLanguageFromFileName(spec.name), + lspDocument: spec.lspDocument, tokens: [], }; case "terminal": { diff --git a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts index f58cf8ff7..ec2e72cfc 100644 --- a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts +++ b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.test.ts @@ -1,14 +1,19 @@ import { describe, expect, test } from "bun:test"; -import { isEditorGoToDefinitionModifierClick } from "./go-to-definition-gesture"; +import { + isEditorGoToDefinitionModifierActive, + isEditorGoToDefinitionModifierClick, +} from "./go-to-definition-gesture"; describe("IDEA-style go to definition click", () => { + test("recognizes the unmodified Ctrl or Cmd hover modifier", () => { + expect(isEditorGoToDefinitionModifierActive({ ctrlKey: true })).toBe(true); + expect(isEditorGoToDefinitionModifierActive({ metaKey: true })).toBe(true); + expect(isEditorGoToDefinitionModifierActive({ ctrlKey: true, shiftKey: true })).toBe(false); + }); + test("accepts unmodified Ctrl or Cmd left clicks", () => { - expect( - isEditorGoToDefinitionModifierClick({ leftButton: true, ctrlKey: true }), - ).toBe(true); - expect( - isEditorGoToDefinitionModifierClick({ leftButton: true, metaKey: true }), - ).toBe(true); + expect(isEditorGoToDefinitionModifierClick({ leftButton: true, ctrlKey: true })).toBe(true); + expect(isEditorGoToDefinitionModifierClick({ leftButton: true, metaKey: true })).toBe(true); }); test("ignores right clicks and extra modifiers", () => { diff --git a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts index 8e32fa117..d4acad574 100644 --- a/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts +++ b/windows/tauri/src/features/editor/utils/go-to-definition-gesture.ts @@ -1,14 +1,18 @@ -export function isEditorGoToDefinitionModifierClick(event: { - leftButton?: boolean; +interface EditorGoToDefinitionModifierEvent { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean; -}): boolean { - return Boolean( - event.leftButton && - (event.ctrlKey || event.metaKey) && - !event.altKey && - !event.shiftKey, - ); +} + +export function isEditorGoToDefinitionModifierActive( + event: EditorGoToDefinitionModifierEvent, +): boolean { + return Boolean((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey); +} + +export function isEditorGoToDefinitionModifierClick( + event: EditorGoToDefinitionModifierEvent & { leftButton?: boolean }, +): boolean { + return Boolean(event.leftButton && isEditorGoToDefinitionModifierActive(event)); } diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index ad4a3f1ca..e1222b30c 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -12,7 +12,16 @@ import { useReferencesStore } from "@/features/references/stores/references.stor import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { languageIdForEditorFile } from "@/features/editor/lsp/built-in-language-support"; import { languageServerUnavailableMessage } from "@/features/editor/lsp/language-server-navigation"; +import { resolveLombokAccessorDefinition } from "@/features/editor/lsp/lombok-accessor-navigation"; +import { openLspNavigationLocation } from "@/features/editor/lsp/navigation-target"; +import { + lspDocumentTargetForEditor, + type LspDocumentTarget, + type LspDocumentTargetInput, +} from "@/features/editor/lsp/lsp-document-target"; +import type { LspDocumentAvailability, LspLocation } from "@/features/editor/lsp/lsp-client"; import { useLspStore } from "@/features/editor/lsp/stores/lsp.store"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; import { useSpringStore } from "@/features/spring/stores/spring.store"; import type { SpringNavigationLocation } from "@/features/spring/types/spring.types"; import { @@ -20,35 +29,34 @@ import { resolveSpringReferences, } from "@/features/spring/utils/spring-navigation"; import { useUIState } from "@/features/window/stores/ui-state.store"; +import { useProjectStore } from "@/features/window/stores/project.store"; import { createTranslator } from "@/i18n/locale"; -import { getBaseName, normalizePath } from "@/utils/path-helpers"; +import { logger } from "@/features/editor/utils/logger"; +import { normalizePath } from "@/utils/path-helpers"; import { showPromptDialog } from "@/ui/dialog"; import { toast } from "sonner"; -type LspNavigationLocation = { - uri: string; - range: { - start: { line: number; character: number }; - end: { line: number; character: number }; - }; -}; - type LspNavigationClient = { getDefinition: ( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, - ) => Promise; + ) => Promise; getImplementation: ( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, - ) => Promise; + ) => Promise; getTypeDefinition: ( - filePath: string, + target: LspDocumentTargetInput, line: number, character: number, - ) => Promise; + ) => Promise; + getDocumentAvailability: ( + target: LspDocumentTargetInput, + feature?: string, + ) => LspDocumentAvailability; + getVirtualDocument: (filePath: string, virtualUri: string) => Promise; }; const getCurrentTranslator = () => @@ -65,7 +73,9 @@ function translateNavigationLabel(label: string): string { function activeEditorNavigationContext() { const bufferStore = useBufferStore.getState(); - const activeBuffer = bufferStore.buffers.find((buffer) => buffer.id === bufferStore.activeBufferId); + const activeBuffer = bufferStore.buffers.find( + (buffer) => buffer.id === bufferStore.activeBufferId, + ); const editorState = useEditorStateStore.getState(); if (!activeBuffer || activeBuffer.type !== "editor" || !activeBuffer.path) return null; return { bufferStore, activeBuffer, editorState }; @@ -81,20 +91,36 @@ function isCurrentNavigationTarget( targetPath: string, targetLine: number, ): boolean { - return canonicalizeEditorPath(filePath) === canonicalizeEditorPath(targetPath) && line === targetLine; + return ( + canonicalizeEditorPath(filePath) === canonicalizeEditorPath(targetPath) && line === targetLine + ); } -function unavailableLanguageServerToast(filePath: string, lspClient: { hasSessionForFile(path: string): boolean }): string | null { - const status = useLspStore.getState().lspStatus; +function unavailableLanguageServerToast( + buffer: EditorContent, + feature: string, + lspClient: Pick, +): string | null { + const availability = lspClient.getDocumentAvailability( + lspDocumentTargetForEditor(buffer), + feature, + ); + const globalStatus = useLspStore.getState().lspStatus; return languageServerUnavailableMessage({ - languageId: languageIdForEditorFile(filePath), - status: status.status, - lastError: status.lastError, - hasSession: lspClient.hasSessionForFile(filePath), + languageId: availability.languageId, + status: availability.hasSession ? availability.status : globalStatus.status, + lastError: availability.hasSession ? undefined : globalStatus.lastError, + hasSession: availability.hasSession, + ready: availability.ready, + featuresKnown: availability.featuresKnown, + supportsFeature: availability.supportsFeature, + featureLabel: feature, }); } -function springLocationsForActiveFile(kind: "definition" | "references"): SpringNavigationLocation[] { +function springLocationsForActiveFile( + kind: "definition" | "references", +): SpringNavigationLocation[] { const context = activeEditorNavigationContext(); const springState = useSpringStore.getState(); if (!context || !springState.root) return []; @@ -193,16 +219,15 @@ async function goToActiveLspLocation( label: string, resolveLocations: ( lspClient: LspNavigationClient, - filePath: string, + target: LspDocumentTarget, line: number, character: number, - ) => Promise, - options: { requireLanguageServer?: boolean } = {}, + ) => Promise, + options: { requireLanguageServer?: boolean; feature?: string } = {}, ): Promise { - const [{ LspClient }, { readFileContent }, { filePathFromUri }] = await Promise.all([ + const [{ LspClient }, { readFileContent }] = await Promise.all([ import("@/features/editor/lsp/lsp-client"), import("@/features/file-system/controllers/file-operations"), - import("@/features/editor/lsp/workspace-edit"), ]); const lspClient = LspClient.getInstance(); @@ -212,22 +237,49 @@ async function goToActiveLspLocation( const cursorPosition = editorState.cursorPosition; if (!activeBuffer || activeBuffer.type !== "editor" || !activeBuffer.path) return; + const documentTarget = lspDocumentTargetForEditor(activeBuffer); if (options.requireLanguageServer !== false) { - const unavailable = unavailableLanguageServerToast(activeBuffer.path, lspClient); + const unavailable = unavailableLanguageServerToast( + activeBuffer, + options.feature ?? label, + lspClient, + ); if (unavailable) { toast.error(unavailable); return; } } - const locations = await resolveLocations( + let locations = await resolveLocations( lspClient, - activeBuffer.path, + documentTarget, cursorPosition.line, cursorPosition.column, ); + if ( + (!locations || locations.length === 0) && + label === "definition" && + documentTarget.languageId === "java" + ) { + const workspaceRoot = useProjectStore.getState().rootFolderPath; + if (workspaceRoot) { + try { + const fallback = await resolveLombokAccessorDefinition({ + source: activeBuffer.content, + sourceFilePath: activeBuffer.path, + workspaceRoot, + line: cursorPosition.line, + character: cursorPosition.column, + }); + if (fallback) locations = [fallback]; + } catch (error) { + logger.error("LombokNavigation", "Could not resolve generated accessor:", error); + } + } + } + if (!locations || locations.length === 0) { toast.info( getCurrentTranslator()("navigation.noTargetFound", { @@ -248,16 +300,22 @@ async function goToActiveLspLocation( }); const target = locations[0]; - const filePath = target.uri.includes("://") ? filePathFromUri(target.uri) : target.uri; - const existingBuffer = bufferStore.buffers.find((b) => b.path === filePath); - - if (existingBuffer) { - bufferStore.actions.setActiveBuffer(existingBuffer.id); - } else { - const content = await readFileContent(filePath); - const fileName = getBaseName(filePath); - const bufferId = bufferStore.actions.openBuffer(filePath, fileName, content); - bufferStore.actions.setActiveBuffer(bufferId); + const openedBufferId = await openLspNavigationLocation({ + location: target, + sourceFilePath: documentTarget.sessionFilePath ?? documentTarget.filePath, + buffers: bufferStore.buffers, + actions: bufferStore.actions, + getVirtualDocument: (filePath, virtualUri) => + lspClient.getVirtualDocument(filePath, virtualUri), + readFileContent, + }); + if (!openedBufferId) { + toast.info( + getCurrentTranslator()("navigation.noTargetFound", { + target: translateNavigationLabel(label), + }), + ); + return; } setTimeout(() => { @@ -315,20 +373,26 @@ export async function goToDefinition(): Promise { await presentSpringReferences(springLocations, springLocations[0]?.symbol || "Spring"); return; } - await goToActiveLspLocation("definition", (lspClient, filePath, line, character) => - lspClient.getDefinition(filePath, line, character), + await goToActiveLspLocation( + "definition", + (lspClient, target, line, character) => lspClient.getDefinition(target, line, character), + { feature: "definition" }, ); } export async function goToImplementation(): Promise { - await goToActiveLspLocation("implementation", (lspClient, filePath, line, character) => - lspClient.getImplementation(filePath, line, character), + await goToActiveLspLocation( + "implementation", + (lspClient, target, line, character) => lspClient.getImplementation(target, line, character), + { feature: "implementation" }, ); } export async function goToTypeDefinition(): Promise { - await goToActiveLspLocation("type definition", (lspClient, filePath, line, character) => - lspClient.getTypeDefinition(filePath, line, character), + await goToActiveLspLocation( + "type definition", + (lspClient, target, line, character) => lspClient.getTypeDefinition(target, line, character), + { feature: "typeDefinition" }, ); } @@ -364,7 +428,9 @@ export async function goToReferences(): Promise { return; } - const unavailable = unavailableLanguageServerToast(activeBuffer.path, lspClient); + if (activeBuffer.type !== "editor") return; + const documentTarget = lspDocumentTargetForEditor(activeBuffer); + const unavailable = unavailableLanguageServerToast(activeBuffer, "references", lspClient); if (unavailable) { toast.error(unavailable); return; @@ -376,7 +442,7 @@ export async function goToReferences(): Promise { const symbol = (wordMatch?.[0] || "") + (wordEnd?.[0]?.slice(1) || ""); const references = await lspClient.getReferences( - activeBuffer.path, + documentTarget, cursorPosition.line, cursorPosition.column, ); diff --git a/windows/tauri/src/features/panes/components/pane-container.tsx b/windows/tauri/src/features/panes/components/pane-container.tsx index 2da8e6750..d92cd3387 100644 --- a/windows/tauri/src/features/panes/components/pane-container.tsx +++ b/windows/tauri/src/features/panes/components/pane-container.tsx @@ -287,9 +287,7 @@ function PullRequestPreviewCard({ buffer }: { buffer: PullRequestContent }) {
- {details?.body?.trim() - ? details.body - : t("panes.pullRequestPreviewFallback")} + {details?.body?.trim() ? details.body : t("panes.pullRequestPreviewFallback")}
@@ -307,9 +305,7 @@ function WebViewerDisabledState() { {t("panes.webViewerDisabled")} - - {t("panes.webViewerDisabledDescription")} - + {t("panes.webViewerDisabledDescription")} ); @@ -372,6 +368,15 @@ export function PaneContainer({ pane }: PaneContainerProps) { }), ); + useEffect(() => { + if (!isActivePane || !pane.activeBufferId) return; + + const bufferStore = useBufferStore.getState(); + if (bufferStore.activeBufferId !== pane.activeBufferId) { + bufferStore.actions.setActiveBuffer(pane.activeBufferId); + } + }, [isActivePane, pane.activeBufferId]); + useEffect(() => { const openEditorBufferIds = paneBuffers .filter(isStandardEditorBuffer) @@ -399,10 +404,8 @@ export function PaneContainer({ pane }: PaneContainerProps) { }, [activeBuffer, paneBuffers]); const handlePaneClick = useCallback(() => { - if (!isActivePane) { - activatePaneAndSyncBuffer(pane.id); - } - }, [isActivePane, pane.id]); + activatePaneAndSyncBuffer(pane.id); + }, [pane.id]); const handlePaneMouseDownCapture = useCallback( (e: React.MouseEvent) => { @@ -417,11 +420,9 @@ export function PaneContainer({ pane }: PaneContainerProps) { return; } - if (!isActivePane) { - activatePaneAndSyncBuffer(pane.id); - } + activatePaneAndSyncBuffer(pane.id); }, - [isActivePane, pane.id], + [pane.id], ); const handleTabClick = useCallback( diff --git a/windows/tauri/src/features/panes/types/pane-content.types.ts b/windows/tauri/src/features/panes/types/pane-content.types.ts index 2c281f45a..0efa5d902 100644 --- a/windows/tauri/src/features/panes/types/pane-content.types.ts +++ b/windows/tauri/src/features/panes/types/pane-content.types.ts @@ -12,6 +12,12 @@ export interface TokenEntry { class_name: string; } +export interface EditorLspDocumentBinding { + documentUri: string; + sessionFilePath: string; + languageId: string; +} + // ── Content type discriminant ─────────────────────────────────────── export type PaneContentType = @@ -63,6 +69,7 @@ export interface EditorContent extends PaneContentBase { readOnly?: boolean; language?: string; languageOverride?: string; + lspDocument?: EditorLspDocumentBinding; tokens: TokenEntry[]; } @@ -307,6 +314,7 @@ export type OpenContentSpec = isPreview?: boolean; readOnly?: boolean; language?: string; + lspDocument?: EditorLspDocumentBinding; } | { type: "terminal"; diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts new file mode 100644 index 000000000..9f7f8e1bd --- /dev/null +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -0,0 +1,641 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +const emit = mock(async () => undefined); +const emitTo = mock(async () => undefined); +const listen = mock(async () => () => undefined); +const once = mock(async () => () => undefined); +const TauriEvent = { + WINDOW_RESIZED: "tauri://resize", + WINDOW_MOVED: "tauri://move", + WINDOW_CLOSE_REQUESTED: "tauri://close-requested", + WINDOW_DESTROYED: "tauri://destroyed", + WINDOW_FOCUS: "tauri://focus", + WINDOW_BLUR: "tauri://blur", + WINDOW_SCALE_FACTOR_CHANGED: "tauri://scale-change", + WINDOW_THEME_CHANGED: "tauri://theme-changed", + WINDOW_CREATED: "tauri://window-created", + WINDOW_SUSPENDED: "tauri://suspended", + WINDOW_RESUMED: "tauri://resumed", + WEBVIEW_CREATED: "tauri://webview-created", + DRAG_ENTER: "tauri://drag-enter", + DRAG_OVER: "tauri://drag-over", + DRAG_DROP: "tauri://drag-drop", + DRAG_LEAVE: "tauri://drag-leave", +} as const; +const frontendTrace = mock(() => undefined); +const commands: string[] = []; +let scenario: + | "capabilities" + | "delayed-start" + | "failure" + | "multi-session" + | "semantic-request" + | "virtual-document" = "failure"; +let startPayload: Record | undefined; +let requestPayload: Record | undefined; +let pollCount = 0; +let startCount = 0; +const sessionPollCounts = new Map(); +let virtualDocumentPending = false; +let semanticRequestPending = false; +let semanticOperationId = ""; +let releaseInitialization: (() => void) | undefined; + +function readyEvents(sessionId: string) { + return [ + { + type: "featuresChanged", + providerId: "java", + sessionId, + capabilities: [ + "codeActions", + "completion", + "definition", + "executeCommand", + "hover", + "implementation", + "references", + "rename", + "typeDefinition", + ], + }, + { + type: "stateChanged", + state: "ready", + providerId: "java", + sessionId, + }, + ]; +} + +const executeCore = mock( + async (request: { id: string; command: string; payload?: Record }) => { + commands.push(request.command); + if (request.command === "lsp.startServer") { + startCount += 1; + startPayload = request.payload; + const sessionId = + scenario === "failure" + ? "failed-java-session" + : scenario === "multi-session" + ? `java-session-${startCount}` + : "java-session"; + return { + id: request.id, + ok: true as const, + data: { sessionId }, + }; + } + if (request.command === "lsp.pollEvents") { + pollCount += 1; + const sessionId = String(request.payload?.sessionId ?? "java-session"); + const sessionPollCount = (sessionPollCounts.get(sessionId) ?? 0) + 1; + sessionPollCounts.set(sessionId, sessionPollCount); + if (scenario === "delayed-start") { + if (pollCount === 1) { + await new Promise((resolve) => { + releaseInitialization = resolve; + }); + return { + id: request.id, + ok: true as const, + data: { + events: [ + { + type: "stateChanged", + state: "ready", + providerId: "java", + sessionId, + }, + ], + }, + }; + } + return { id: request.id, ok: true as const, data: { events: [] } }; + } + if (scenario === "capabilities" || scenario === "multi-session") { + return { + id: request.id, + ok: true as const, + data: { events: sessionPollCount === 1 ? readyEvents(sessionId) : [] }, + }; + } + if (scenario === "semantic-request") { + if (sessionPollCount === 1) { + return { + id: request.id, + ok: true as const, + data: { events: readyEvents(sessionId) }, + }; + } + if (semanticRequestPending) { + semanticRequestPending = false; + return { + id: request.id, + ok: true as const, + data: { + events: [ + { + type: "requestCompleted", + providerId: "java", + sessionId, + operationId: semanticOperationId, + result: { locations: [] }, + }, + ], + }, + }; + } + return { id: request.id, ok: true as const, data: { events: [] } }; + } + if (scenario === "virtual-document") { + if (pollCount === 1) { + return { + id: request.id, + ok: true as const, + data: { + events: [ + { + type: "stateChanged", + state: "ready", + providerId: "java", + sessionId, + }, + ], + }, + }; + } + if (virtualDocumentPending) { + virtualDocumentPending = false; + return { + id: request.id, + ok: true as const, + data: { + events: [ + { + type: "requestCompleted", + providerId: "java", + sessionId, + operationId: "virtualDocument-operation", + result: { text: "public final class String {}" }, + }, + ], + }, + }; + } + return { id: request.id, ok: true as const, data: { events: [] } }; + } + return { + id: request.id, + ok: true as const, + data: { + events: + pollCount === 1 + ? [ + { + type: "log", + level: "warning", + message: "Language-server stderr", + detail: "JDTLS failed before initialization", + providerId: "java", + sessionId: "failed-java-session", + }, + { + type: "stateChanged", + state: "failed", + providerId: "java", + sessionId: "failed-java-session", + error: { + code: "serverExited", + stage: "process", + message: "Language-server process exited.", + underlyingMessage: "JVM startup failed", + processExitCode: 13, + }, + }, + ] + : [], + }, + }; + } + if (request.command === "lsp.request") { + requestPayload = request.payload; + const operation = String(request.payload?.operation ?? "request"); + const operationId = `${operation}-operation`; + if (scenario === "semantic-request") { + semanticRequestPending = true; + semanticOperationId = operationId; + } else { + virtualDocumentPending = true; + } + return { + id: request.id, + ok: true as const, + data: { operationId }, + }; + } + return { id: request.id, ok: true as const, data: null }; + }, +); + +mock.module("@tauri-apps/api/event", () => ({ emit, emitTo, listen, once, TauriEvent })); +mock.module("@/core/lithe-core-client", () => ({ executeCore })); +mock.module("@/utils/frontend-trace", () => ({ frontendTrace })); + +const { + getLspSessionSnapshot, + invokeLsp, + LSP_EXPLICITLY_UNAVAILABLE_COMMANDS, + LSP_OPERATION_BY_COMMAND, +} = await import("./lsp-core-adapter"); + +function installSessionStorage() { + const previous = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); + const values = new Map(); + const storage: Storage = { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: storage }); + return { + values, + restore() { + if (previous) { + Object.defineProperty(globalThis, "sessionStorage", previous); + } else { + delete (globalThis as { sessionStorage?: Storage }).sessionStorage; + } + }, + }; +} + +describe("Rust Core LSP adapter failures", () => { + beforeEach(() => { + scenario = "failure"; + commands.length = 0; + startPayload = undefined; + requestPayload = undefined; + pollCount = 0; + startCount = 0; + sessionPollCounts.clear(); + virtualDocumentPending = false; + semanticRequestPending = false; + semanticOperationId = ""; + releaseInitialization = undefined; + emit.mockClear(); + frontendTrace.mockClear(); + executeCore.mockClear(); + }); + + test("logs Core output, preserves failure details, and destroys the session", async () => { + let failure: (Error & { code?: string; details?: string }) | null = null; + try { + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath: "C:/work/Main.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + } catch (error) { + failure = error as Error & { code?: string; details?: string }; + } + + expect(failure).not.toBeNull(); + expect(failure?.message).toBe( + "Language-server process exited. JVM startup failed; exit code 13", + ); + expect(failure?.code).toBe("serverExited"); + expect(failure?.details).toBe("JVM startup failed; exit code 13"); + expect(startPayload?.initializeTimeoutMilliseconds).toBe(30_000); + expect(frontendTrace).toHaveBeenCalledWith( + "warn", + "lsp.runtime", + "Language-server stderr", + expect.objectContaining({ detail: "JDTLS failed before initialization" }), + ); + expect(emit).toHaveBeenCalledWith("lsp://server-crashed", {}); + expect(commands).toEqual([ + "lsp.startServer", + "lsp.pollEvents", + "lsp.stopServer", + "lsp.destroyServer", + ]); + }); + + test("stores negotiated features and exposes them in the owning session snapshot", async () => { + scenario = "capabilities"; + const testStorage = installSessionStorage(); + const filePath = "C:/work/Main.java"; + + try { + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + + expect(getLspSessionSnapshot({ filePath })).toEqual({ + id: "java-session", + workspacePath: "C:/work", + languageId: "java", + ready: true, + features: [ + "codeActions", + "completion", + "definition", + "executeCommand", + "hover", + "implementation", + "references", + "rename", + "typeDefinition", + ], + featuresKnown: true, + }); + expect(JSON.parse(testStorage.values.get("lithe:lsp-core-sessions:v1") ?? "[]")).toEqual([ + expect.objectContaining({ + ready: true, + features: expect.arrayContaining(["definition", "references", "executeCommand"]), + }), + ]); + expect(emit).toHaveBeenCalledWith( + "lsp://features-changed", + expect.objectContaining({ sessionId: "java-session", languageId: "java" }), + ); + + await invokeLsp("lsp_stop_for_file", { filePath }); + } finally { + testStorage.restore(); + } + }); + + test("routes virtual references through the physical source session without rewriting the URI", async () => { + scenario = "semantic-request"; + const filePath = "C:/work/Main.java"; + const virtualUri = "jdt://contents/java.base/java/lang/String.class?=demo"; + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + + const references = await invokeLsp("lsp_get_references", { + filePath: virtualUri, + sessionFilePath: filePath, + documentUri: virtualUri, + line: 12, + character: 7, + }); + + expect(references).toEqual([]); + expect(requestPayload).toEqual({ + sessionId: "java-session", + operation: "references", + uri: virtualUri, + position: { line: 12, utf16Column: 7 }, + }); + expect(requestPayload?.uri).not.toStartWith("file:"); + + await invokeLsp("lsp_stop_for_file", { filePath }); + }); + + test("moves one normalized file to the new workspace session and stops the empty owner", async () => { + scenario = "multi-session"; + const filePath = "C:/work/project/src/Main.java"; + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + expect(getLspSessionSnapshot({ filePath })?.id).toBe("java-session-1"); + + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:\\work\\project", + filePath: "C:\\work\\project\\src\\Main.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + + expect(getLspSessionSnapshot({ filePath })).toEqual( + expect.objectContaining({ + id: "java-session-2", + workspacePath: "C:\\work\\project", + }), + ); + expect(commands.filter((command) => command === "lsp.startServer")).toHaveLength(2); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(1); + expect(commands.filter((command) => command === "lsp.destroyServer")).toHaveLength(1); + + const parentFilePath = "C:/work/src/Other.java"; + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath: parentFilePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + expect(getLspSessionSnapshot({ filePath: parentFilePath })).toEqual( + expect.objectContaining({ + id: "java-session-3", + workspacePath: "C:/work", + }), + ); + expect(commands.filter((command) => command === "lsp.startServer")).toHaveLength(3); + + await invokeLsp("lsp_stop_for_file", { filePath }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(2); + await invokeLsp("lsp_stop_for_file", { filePath: parentFilePath }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(3); + }); + + test("returns a structured capability error for explicitly unavailable commands", async () => { + await expect( + invokeLsp("lsp_prepare_rename", { + filePath: "C:/work/Main.java", + line: 0, + character: 0, + }), + ).rejects.toMatchObject({ + code: "unsupported_capability", + message: "LSP operation is not available through the shared Core: lsp_prepare_rename", + }); + }); + + test("maps or explicitly rejects every LspClient adapter command", () => { + expect(LSP_OPERATION_BY_COMMAND).toEqual({ + lsp_get_completions: "completion", + lsp_get_hover: "hover", + lsp_get_definition: "definition", + lsp_get_implementation: "implementation", + lsp_get_type_definition: "typeDefinition", + lsp_get_references: "references", + lsp_rename: "rename", + lsp_format_document: "formatting", + lsp_get_code_actions: "codeActions", + lsp_get_inlay_hints: "inlayHints", + lsp_get_code_lens: "codeLens", + lsp_get_virtual_document: "virtualDocument", + }); + + const explicitlyHandled = new Set([ + ...Object.keys(LSP_OPERATION_BY_COMMAND), + ...LSP_EXPLICITLY_UNAVAILABLE_COMMANDS, + "lsp_apply_code_action", + "lsp_document_change", + "lsp_document_close", + "lsp_document_open", + "lsp_document_save", + "lsp_start", + "lsp_start_for_file", + "lsp_stop", + "lsp_stop_for_file", + ]); + const clientSource = readFileSync( + new URL("../features/editor/lsp/lsp-client.ts", import.meta.url), + "utf8", + ); + const clientCommands = new Set( + [...clientSource.matchAll(/["'](lsp_[a-z_]+)["']/g)].map((match) => match[1]), + ); + + expect([...clientCommands].filter((command) => !explicitlyHandled.has(command))).toEqual([]); + }); + + test("resolves a provider virtual document without fabricating a file URI", async () => { + scenario = "virtual-document"; + const filePath = "C:/work/Main.java"; + const virtualUri = "jdt://contents/java.base/java/lang/String.class?=demo"; + await invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath, + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + + const text = await invokeLsp("lsp_get_virtual_document", { + filePath, + virtualUri, + }); + + expect(text).toBe("public final class String {}"); + expect(requestPayload).toEqual({ + sessionId: "java-session", + operation: "virtualDocument", + virtualUri, + }); + expect(requestPayload).not.toHaveProperty("uri"); + + await invokeLsp("lsp_stop_for_file", { filePath }); + expect(commands).toContain("lsp.stopServer"); + expect(commands).toContain("lsp.destroyServer"); + }); + + test("shares an in-flight server start across files and normalizes Windows paths", async () => { + scenario = "virtual-document"; + + await Promise.all([ + invokeLsp("lsp_start_for_file", { + workspacePath: "C:\\work", + filePath: "C:\\work\\Main.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }), + invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath: "C:/work/Other.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }), + ]); + + expect(commands.filter((command) => command === "lsp.startServer")).toHaveLength(1); + + await invokeLsp("lsp_stop_for_file", { filePath: "C:/work/Main.java" }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(0); + + await invokeLsp("lsp_stop_for_file", { filePath: "C:\\work\\Other.java" }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(1); + expect(commands.filter((command) => command === "lsp.destroyServer")).toHaveLength(1); + }); + + test("keeps initializing sessions recoverable and makes an in-flight file stop deterministic", async () => { + scenario = "delayed-start"; + const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); + const values = new Map(); + const storage: Storage = { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: storage }); + + try { + const firstStart = invokeLsp("lsp_start_for_file", { + workspacePath: "C:\\work", + filePath: "C:\\work\\Main.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + for (let attempt = 0; attempt < 10 && !releaseInitialization; attempt += 1) { + await Promise.resolve(); + } + + expect(releaseInitialization).toBeDefined(); + expect(JSON.parse(values.get("lithe:lsp-core-sessions:v1") ?? "[]")).toEqual([ + expect.objectContaining({ id: "java-session", ready: false }), + ]); + + const secondStart = invokeLsp("lsp_start_for_file", { + workspacePath: "C:/work", + filePath: "C:/work/Other.java", + languageId: "java", + providerId: "java", + serverPath: "C:/Lithe/jdtls.bat", + }); + const stopSecond = invokeLsp("lsp_stop_for_file", { filePath: "C:\\work\\Other.java" }); + + releaseInitialization?.(); + await Promise.all([firstStart, secondStart, stopSecond]); + + expect(commands.filter((command) => command === "lsp.startServer")).toHaveLength(1); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(0); + expect(JSON.parse(values.get("lithe:lsp-core-sessions:v1") ?? "[]")).toEqual([ + expect.objectContaining({ files: ["C:\\work\\Main.java"], ready: true }), + ]); + + await invokeLsp("lsp_stop_for_file", { filePath: "C:/work/Main.java" }); + expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(1); + expect(values.has("lithe:lsp-core-sessions:v1")).toBe(false); + } finally { + if (previousStorage) { + Object.defineProperty(globalThis, "sessionStorage", previousStorage); + } else { + delete (globalThis as { sessionStorage?: Storage }).sessionStorage; + } + } + }); +}); diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index a90a0bfdf..c02aff413 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -1,34 +1,231 @@ import { emit } from "@tauri-apps/api/event"; import { executeCore, type CoreResponse } from "@/core/lithe-core-client"; +import { frontendTrace } from "@/utils/frontend-trace"; type JsonRecord = Record; +const INITIALIZE_TIMEOUT_MS = 30_000; +const SESSION_CLEANUP_TIMEOUT_MS = 5_000; +const POLL_INTERVAL_MS = 20; + interface Session { id: string; workspacePath: string; languageId: string; files: Set; running: boolean; - pending: Map< - string, - { resolve: (value: unknown) => void; reject: (reason: Error) => void } - >; + ready: boolean; + features: Set; + featuresKnown: boolean; + recovered: boolean; + pending: Map void; reject: (reason: Error) => void }>; completed: Map; } +interface StoredSession { + id: string; + workspacePath: string; + languageId: string; + files: string[]; + ready?: boolean; + features?: string[]; +} + +interface RuntimeError { + code?: string; + providerId?: string; + sessionId?: string; + stage?: string; + method?: string; + documentUri?: string; + message?: string; + underlyingMessage?: string; + processExitCode?: number; +} + interface RuntimeEvent { type: string; + providerId?: string; + sessionId?: string; state?: string; operationId?: string; uri?: string; version?: number; diagnostics?: unknown[]; result?: unknown; - error?: { code?: string; message?: string }; + error?: RuntimeError; + level?: string; + message?: string; + detail?: string; + capabilities?: string[]; +} + +export interface LspSessionSnapshot { + id: string; + workspacePath: string; + languageId: string; + ready: boolean; + features: string[]; + featuresKnown: boolean; } const sessions = new Map(); const fileSessions = new Map(); +const sessionStarts = new Map>(); +const sessionStops = new Map>(); +const pendingFileSessions = new Map(); +const SESSION_STORAGE_KEY = "lithe:lsp-core-sessions:v1"; + +function normalizedPathKey(path: string): string { + const normalized = path.replace(/\\/g, "/"); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +function sessionKey(workspacePath: string, languageId: string): string { + return `${normalizedPathKey(workspacePath)}:${languageId}`; +} + +function fileKey(filePath: string): string { + return normalizedPathKey(filePath); +} + +function availableSessionStorage(): Storage | null { + try { + return typeof sessionStorage === "undefined" ? null : sessionStorage; + } catch { + return null; + } +} + +function persistSessions(): void { + const storage = availableSessionStorage(); + if (!storage) return; + + const stored: StoredSession[] = [...sessions.values()] + .map((session) => ({ + id: session.id, + workspacePath: session.workspacePath, + languageId: session.languageId, + files: [...session.files].sort(), + ready: session.ready, + features: [...session.features].sort(), + })) + .sort((left, right) => + sessionKey(left.workspacePath, left.languageId).localeCompare( + sessionKey(right.workspacePath, right.languageId), + ), + ); + + try { + if (stored.length === 0) { + storage.removeItem(SESSION_STORAGE_KEY); + } else { + storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(stored)); + } + } catch (reason) { + frontendTrace("warn", "lsp.runtime", "Could not persist language-server sessions", { + error: reason instanceof Error ? reason.message : String(reason), + }); + } +} + +function attachFile(session: Session, filePath: string): Session | null { + const key = fileKey(filePath); + const previous = fileSessions.get(key); + if (previous && previous !== session) { + for (const existing of previous.files) { + if (fileKey(existing) === key) previous.files.delete(existing); + } + } + for (const existing of session.files) { + if (fileKey(existing) === key) session.files.delete(existing); + } + session.files.add(filePath); + fileSessions.set(key, session); + return previous && previous !== session ? previous : null; +} + +function detachFile(session: Session, filePath: string): void { + const key = fileKey(filePath); + for (const existing of session.files) { + if (fileKey(existing) === key) session.files.delete(existing); + } + if (fileSessions.get(key) === session) fileSessions.delete(key); +} + +function removeSessionMappings(session: Session): void { + const key = sessionKey(session.workspacePath, session.languageId); + if (sessions.get(key) === session) sessions.delete(key); + for (const file of session.files) { + if (fileSessions.get(fileKey(file)) === session) fileSessions.delete(fileKey(file)); + } +} + +function restorePersistedSessions(): void { + const storage = availableSessionStorage(); + if (!storage) return; + + try { + const value: unknown = JSON.parse(storage.getItem(SESSION_STORAGE_KEY) ?? "[]"); + if (!Array.isArray(value)) throw new Error("Stored sessions are not an array"); + + for (const candidate of value) { + const stored = candidate as Partial | null; + if ( + !stored || + typeof stored !== "object" || + typeof stored.id !== "string" || + typeof stored.workspacePath !== "string" || + typeof stored.languageId !== "string" || + !Array.isArray(stored.files) || + !stored.files.every((file: unknown) => typeof file === "string") || + (stored.ready !== undefined && typeof stored.ready !== "boolean") || + (stored.features !== undefined && + (!Array.isArray(stored.features) || + !stored.features.every((feature: unknown) => typeof feature === "string"))) + ) { + continue; + } + + const session: Session = { + id: stored.id, + workspacePath: stored.workspacePath, + languageId: stored.languageId, + files: new Set(), + running: false, + // Version-one entries created before this field existed were all ready. + ready: stored.ready ?? true, + features: new Set(stored.features ?? []), + featuresKnown: stored.features !== undefined, + recovered: true, + pending: new Map(), + completed: new Map(), + }; + const key = sessionKey(session.workspacePath, session.languageId); + const previous = sessions.get(key); + if (previous) removeSessionMappings(previous); + sessions.set(key, session); + for (const file of stored.files) attachFile(session, file); + } + for (const session of sessions.values()) { + if (session.files.size === 0) removeSessionMappings(session); + } + } catch (reason) { + storage.removeItem(SESSION_STORAGE_KEY); + frontendTrace("warn", "lsp.runtime", "Could not restore language-server sessions", { + error: reason instanceof Error ? reason.message : String(reason), + }); + } +} + +restorePersistedSessions(); + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + persistSessions(); + for (const session of sessions.values()) session.running = false; + }); +} function coreData(response: CoreResponse): T { if (response.ok) return response.data; @@ -37,6 +234,12 @@ function coreData(response: CoreResponse): T { throw error; } +function lspAdapterError(code: string, message: string): Error & { code: string } { + const error = new Error(message) as Error & { code: string }; + error.code = code; + return error; +} + async function core(command: string, payload: JsonRecord): Promise { return coreData( await executeCore({ @@ -65,6 +268,14 @@ function normalizeCoreValue(value: unknown): unknown { } async function dispatchRuntimeEvent(event: RuntimeEvent): Promise { + if (event.type === "log") { + const level = event.level === "error" ? "error" : event.level === "warning" ? "warn" : "info"; + frontendTrace(level, "lsp.runtime", event.message ?? "Language-server log", { + detail: event.detail ?? null, + providerId: event.providerId, + sessionId: event.sessionId, + }); + } if (event.type === "diagnostics" && event.uri) { await emit("lsp://diagnostics", { uri: event.uri, @@ -79,6 +290,17 @@ async function dispatchRuntimeEvent(event: RuntimeEvent): Promise { async function dispatchSessionEvent(session: Session, event: RuntimeEvent): Promise { await dispatchRuntimeEvent(event); + if (event.type === "featuresChanged") { + session.features = new Set(event.capabilities ?? []); + session.featuresKnown = true; + persistSessions(); + await emit("lsp://features-changed", { + sessionId: session.id, + workspacePath: session.workspacePath, + languageId: session.languageId, + features: [...session.features].sort(), + }); + } if (event.type !== "requestCompleted" || !event.operationId) return; const pending = session.pending.get(event.operationId); if (!pending) { @@ -108,88 +330,290 @@ async function poll(session: Session): Promise { async function runEventPump(session: Session): Promise { while (session.running) { try { - await poll(session); + const events = await poll(session); + if (!session.running) return; + const terminalState = [...events] + .reverse() + .find( + (event) => + event.type === "stateChanged" && + (event.state === "failed" || event.state === "stopped"), + )?.state; + if (terminalState) { + const error = new Error(`Language server entered ${terminalState} state`); + for (const pending of session.pending.values()) pending.reject(error); + session.pending.clear(); + session.running = false; + session.ready = false; + removeSessionMappings(session); + persistSessions(); + return; + } } catch (reason) { if (!session.running) return; const error = reason instanceof Error ? reason : new Error(String(reason)); for (const pending of session.pending.values()) pending.reject(error); session.pending.clear(); session.running = false; + removeSessionMappings(session); + persistSessions(); await emit("lsp://server-crashed", {}); return; } - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } } async function waitUntilReady(session: Session): Promise { - const deadline = Date.now() + 12_000; + const deadline = Date.now() + INITIALIZE_TIMEOUT_MS; while (Date.now() < deadline) { const events = await poll(session); const state = [...events].reverse().find((event) => event.type === "stateChanged")?.state; - if (state === "ready") return; + if (state === "ready") { + session.ready = true; + persistSessions(); + return; + } if (state === "failed" || state === "stopped") { - throw new Error(`Language server entered ${state} state`); + const failure = [...events] + .reverse() + .find((event) => event.type === "stateChanged" && event.error)?.error; + const detail = [ + failure?.underlyingMessage, + failure?.processExitCode != null ? `exit code ${failure.processExitCode}` : null, + ] + .filter(Boolean) + .join("; "); + const error = new Error( + [failure?.message ?? `Language server entered ${state} state`, detail] + .filter(Boolean) + .join(" "), + ) as Error & { code?: string; details?: string }; + error.code = failure?.code; + error.details = detail || failure?.stage; + throw error; } - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + const error = new Error("Language server initialization timed out") as Error & { code?: string }; + error.code = "timed_out"; + throw error; +} + +async function stopAndDestroySession(session: Session): Promise { + session.running = false; + session.ready = false; + await core("lsp.stopServer", { sessionId: session.id }); + + const deadline = Date.now() + SESSION_CLEANUP_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + await core("lsp.destroyServer", { sessionId: session.id }); + return; + } catch { + // A graceful stop is asynchronous; keep draining events until Core is terminal. + } + await poll(session); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + await core("lsp.destroyServer", { sessionId: session.id }); +} + +async function cleanupFailedStart(key: string, session: Session): Promise { + if (sessions.get(key) === session) sessions.delete(key); + removeSessionMappings(session); + persistSessions(); + try { + await stopAndDestroySession(session); + } catch (reason) { + frontendTrace("warn", "lsp.runtime", "Language-server cleanup failed", { + sessionId: session.id, + error: reason instanceof Error ? reason.message : String(reason), + }); } - throw new Error("Language server initialization timed out"); } function sessionForFile(filePath: string): Session { - const session = fileSessions.get(filePath); - if (!session) throw new Error(`No LSP client for this file: ${filePath}`); + const session = fileSessions.get(fileKey(filePath)); + if (!session) { + throw lspAdapterError("no_session", `No language-server session owns this file: ${filePath}`); + } return session; } -async function start(args: JsonRecord): Promise { +async function recoverSession(session: Session): Promise { + try { + const events = await poll(session); + const state = [...events].reverse().find((event) => event.type === "stateChanged")?.state; + if (state === "failed" || state === "stopped" || state === "stopping") { + try { + if (state === "stopping") { + await stopAndDestroySession(session); + } else { + await core("lsp.destroyServer", { sessionId: session.id }); + } + } catch (reason) { + frontendTrace("warn", "lsp.runtime", "Could not destroy stale language-server session", { + sessionId: session.id, + error: reason instanceof Error ? reason.message : String(reason), + }); + } + removeSessionMappings(session); + persistSessions(); + return null; + } + + if (state === "ready") { + session.ready = true; + } else if (state) { + session.ready = false; + } + if (!session.ready) await waitUntilReady(session); + + if (!session.featuresKnown) { + await stopAndDestroySession(session); + removeSessionMappings(session); + persistSessions(); + return null; + } + + session.recovered = false; + session.running = true; + persistSessions(); + void runEventPump(session); + return session; + } catch (reason) { + removeSessionMappings(session); + persistSessions(); + frontendTrace("warn", "lsp.runtime", "Could not recover language-server session", { + sessionId: session.id, + error: reason instanceof Error ? reason.message : String(reason), + }); + return null; + } +} + +async function createSession(args: JsonRecord, key: string): 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, - executablePath: args.serverPath, - arguments: args.serverArgs ?? [], - environment, - rootUri: fileUri(workspacePath), - workingDirectory: workspacePath, - initializationOptions: args.initializationOptions ?? null, - runtimeExecutablePath: args.runtimeExecutablePath ?? null, - cacheDirectory: args.cacheDirectory ?? null, - }); - session = { - id: started.sessionId, - workspacePath, - languageId, - files: new Set(), - running: false, - pending: new Map(), - completed: new Map(), - }; - sessions.set(key, session); + const environment = { + ...args.tools?.lsp?.env, + ...args.environment, + }; + const started = await core<{ sessionId: string }>("lsp.startServer", { + providerId, + executablePath: args.serverPath, + arguments: args.serverArgs ?? [], + environment, + rootUri: fileUri(workspacePath), + workingDirectory: workspacePath, + initializationOptions: args.initializationOptions ?? null, + runtimeExecutablePath: args.runtimeExecutablePath ?? null, + cacheDirectory: args.cacheDirectory ?? null, + initializeTimeoutMilliseconds: INITIALIZE_TIMEOUT_MS, + }); + const session: Session = { + id: started.sessionId, + workspacePath, + languageId, + files: new Set(), + running: false, + ready: false, + features: new Set(), + featuresKnown: false, + recovered: false, + pending: new Map(), + completed: new Map(), + }; + sessions.set(key, session); + persistSessions(); + try { await waitUntilReady(session); - session.running = true; - void runEventPump(session); + } catch (error) { + await cleanupFailedStart(key, session); + throw error; } - if (args.filePath) { - session.files.add(args.filePath); - fileSessions.set(args.filePath, session); + session.running = true; + persistSessions(); + void runEventPump(session); + return session; +} + +async function resolveSession(args: JsonRecord, key: string): Promise { + const stopping = sessionStops.get(key); + if (stopping) await stopping; + + const existing = sessions.get(key); + if (existing && !existing.recovered) return existing; + + if (existing) { + const recovered = await recoverSession(existing); + if (recovered) return recovered; + } + + return createSession(args, key); +} + +async function start(args: JsonRecord): Promise { + const workspacePath = String(args.workspacePath ?? ""); + const languageId = String(args.languageId ?? "plaintext"); + const key = sessionKey(workspacePath, languageId); + const filePath = args.filePath ? String(args.filePath) : null; + const pendingFileKey = filePath ? fileKey(filePath) : null; + if (pendingFileKey) pendingFileSessions.set(pendingFileKey, key); + let session = sessions.get(key); + + try { + if (!session || session.recovered || !session.ready) { + let startPromise = sessionStarts.get(key); + if (!startPromise) { + startPromise = resolveSession(args, key).finally(() => sessionStarts.delete(key)); + sessionStarts.set(key, startPromise); + } + session = await startPromise; + } + + if (filePath) { + const displaced = attachFile(session, filePath); + persistSessions(); + if (displaced && displaced.files.size === 0) await stopSession(displaced); + } + } finally { + if (pendingFileKey && pendingFileSessions.get(pendingFileKey) === key) { + pendingFileSessions.delete(pendingFileKey); + } } } async function stopSession(session: Session): Promise { - session.running = false; - await core("lsp.stopServer", { sessionId: session.id }); - await core("lsp.destroyServer", { sessionId: session.id }); - sessions.delete(`${session.workspacePath}:${session.languageId}`); - for (const file of session.files) fileSessions.delete(file); + const key = sessionKey(session.workspacePath, session.languageId); + removeSessionMappings(session); + persistSessions(); + + let stopPromise = sessionStops.get(key); + if (!stopPromise) { + stopPromise = stopAndDestroySession(session).finally(() => { + if (sessionStops.get(key) === stopPromise) sessionStops.delete(key); + }); + sessionStops.set(key, stopPromise); + } + await stopPromise; +} + +async function sessionForStoppingFile(filePath: string): Promise { + const key = fileKey(filePath); + const pendingSessionKey = pendingFileSessions.get(key); + const pendingStart = pendingSessionKey ? sessionStarts.get(pendingSessionKey) : null; + if (pendingStart) { + try { + await pendingStart; + } catch { + return null; + } + } + return fileSessions.get(key) ?? null; } async function requestOperation(session: Session, payload: JsonRecord): Promise { @@ -221,7 +645,7 @@ async function requestOperation(session: Session, payload: JsonRecord): Promise< }); } -const operations: Record = { +export const LSP_OPERATION_BY_COMMAND = { lsp_get_completions: "completion", lsp_get_hover: "hover", lsp_get_definition: "definition", @@ -233,14 +657,36 @@ const operations: Record = { lsp_get_code_actions: "codeActions", lsp_get_inlay_hints: "inlayHints", lsp_get_code_lens: "codeLens", -}; + lsp_get_virtual_document: "virtualDocument", +} as const; + +export const LSP_EXPLICITLY_UNAVAILABLE_COMMANDS = [ + "lsp_get_semantic_tokens", + "lsp_get_document_symbols", + "lsp_get_workspace_symbols", + "lsp_get_signature_help", + "lsp_get_signature_trigger_characters", + "lsp_format_range", + "lsp_prepare_rename", +] as const; + +const operations: Record = LSP_OPERATION_BY_COMMAND; +const explicitlyUnavailableCommands = new Set(LSP_EXPLICITLY_UNAVAILABLE_COMMANDS); + +export function isLspSemanticCommandSupported(command: string): boolean { + return command in operations; +} function semanticPayload(command: string, args: JsonRecord, session: Session): JsonRecord { const payload: JsonRecord = { sessionId: session.id, operation: operations[command], - uri: fileUri(args.filePath), }; + if (command === "lsp_get_virtual_document") { + payload.virtualUri = args.virtualUri; + } else { + payload.uri = typeof args.documentUri === "string" ? args.documentUri : fileUri(args.filePath); + } if (typeof args.line === "number") { payload.position = { line: args.line, utf16Column: args.character ?? 0 }; } @@ -260,13 +706,15 @@ function semanticPayload(command: string, args: JsonRecord, session: Session): J utf16Column: diagnostic.endColumn ?? diagnostic.column ?? 0, }, }; - payload.diagnostics = [{ - range: payload.range, - message: diagnostic.message ?? "", - severity: diagnostic.severity ?? null, - source: diagnostic.source ?? null, - code: diagnostic.code == null ? null : String(diagnostic.code), - }]; + payload.diagnostics = [ + { + range: payload.range, + message: diagnostic.message ?? "", + severity: diagnostic.severity ?? null, + source: diagnostic.source ?? null, + code: diagnostic.code == null ? null : String(diagnostic.code), + }, + ]; } return payload; } @@ -274,24 +722,29 @@ function semanticPayload(command: string, args: JsonRecord, session: Session): J function unwrapResult(command: string, result: any): unknown { const normalized = normalizeCoreValue(result) as JsonRecord; switch (command) { - case "lsp_get_completions": return normalized.items ?? []; + case "lsp_get_completions": + return normalized.items ?? []; case "lsp_get_hover": { const hover = normalized.hover; if (!hover) return null; return { contents: hover.isMarkdown ? { kind: "markdown", value: hover.contents ?? "" } - : hover.contents ?? "", + : (hover.contents ?? ""), range: hover.range, }; } case "lsp_get_definition": case "lsp_get_implementation": case "lsp_get_type_definition": - case "lsp_get_references": return normalized.locations ?? []; - case "lsp_rename": return normalized.changes ? { changes: normalized.changes } : null; - case "lsp_format_document": return normalized.edits ?? []; - case "lsp_get_code_actions": return normalized.actions ?? []; + case "lsp_get_references": + return normalized.locations ?? []; + case "lsp_rename": + return normalized.changes ? { changes: normalized.changes } : null; + case "lsp_format_document": + return normalized.edits ?? []; + case "lsp_get_code_actions": + return normalized.actions ?? []; case "lsp_get_inlay_hints": return (normalized.hints ?? []).map((hint: JsonRecord) => ({ ...hint, @@ -305,16 +758,35 @@ function unwrapResult(command: string, result: any): unknown { command: lens.command?.command, arguments: lens.command?.arguments, })); - default: return normalized; + case "lsp_get_virtual_document": + return typeof normalized.text === "string" ? normalized.text : null; + default: + return normalized; } } async function semanticRequest(command: string, args: JsonRecord): Promise { - const session = sessionForFile(args.filePath); + const session = sessionForFile(args.sessionFilePath ?? args.filePath); const result = await requestOperation(session, semanticPayload(command, args, session)); return unwrapResult(command, result); } +export function getLspSessionSnapshot(args: { + filePath: string; + sessionFilePath?: string; +}): LspSessionSnapshot | null { + const session = fileSessions.get(fileKey(args.sessionFilePath ?? args.filePath)); + if (!session) return null; + return { + id: session.id, + workspacePath: session.workspacePath, + languageId: session.languageId, + ready: session.ready, + features: [...session.features].sort(), + featuresKnown: session.featuresKnown, + }; +} + export async function invokeLsp(command: string, args: JsonRecord = {}): Promise { if (command === "lsp_start" || command === "lsp_start_for_file") { await start(args); @@ -322,19 +794,28 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom } if (command === "lsp_stop") { const matches = [...sessions.values()].filter( - (session) => session.workspacePath === args.workspacePath, + (session) => + normalizedPathKey(session.workspacePath) === normalizedPathKey(args.workspacePath), ); await Promise.all(matches.map(stopSession)); return undefined as T; } if (command === "lsp_stop_for_file") { - const session = sessionForFile(args.filePath); - session.files.delete(args.filePath); - fileSessions.delete(args.filePath); - if (session.files.size === 0) await stopSession(session); + const session = await sessionForStoppingFile(args.filePath); + if (!session) return undefined as T; + detachFile(session, args.filePath); + if (session.files.size === 0) { + await stopSession(session); + } else { + persistSessions(); + } return undefined as T; } - if (command === "lsp_document_open" || command === "lsp_document_change" || command === "lsp_document_save") { + if ( + command === "lsp_document_open" || + command === "lsp_document_change" || + command === "lsp_document_save" + ) { const session = sessionForFile(args.filePath); await core("lsp.syncDocument", { sessionId: session.id, @@ -350,14 +831,14 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom return undefined as T; } if (command === "lsp_apply_code_action") { - const session = sessionForFile(args.filePath); + const session = sessionForFile(args.sessionFilePath ?? args.filePath); const commandPayload = args.actionPayload?.command ?? args.actionPayload; if (!commandPayload?.command) return { applied: true } as T; try { await requestOperation(session, { - sessionId: session.id, - operation: "executeCommand", - command: commandPayload, + sessionId: session.id, + operation: "executeCommand", + command: commandPayload, }); return { applied: true } as T; } catch (reason) { @@ -368,5 +849,11 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom } } if (command in operations) return (await semanticRequest(command, args)) as T; - throw new Error(`LSP operation is not supported by the shared Core: ${command}`); + if (explicitlyUnavailableCommands.has(command)) { + throw lspAdapterError( + "unsupported_capability", + `LSP operation is not available through the shared Core: ${command}`, + ); + } + throw lspAdapterError("invalid_request", `Unknown LSP adapter command: ${command}`); }