diff --git a/crates/ide-build/src/kotlin_bridge.rs b/crates/ide-build/src/kotlin_bridge.rs new file mode 100644 index 00000000..150170ef --- /dev/null +++ b/crates/ide-build/src/kotlin_bridge.rs @@ -0,0 +1,475 @@ +//! Private Maven builds for JDT's Kotlin binary libraries. Never use launch outputs. + +use crate::BuildModel; +use crate::compile::{self, BuildEvent, BuildKind, BuildMessage}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Module { + key: String, + project_uri: String, + output: PathBuf, + test_output: Option, + dependencies: Vec, + has_kotlin: bool, + kotlin_goals: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct KotlinGoal { + execution: String, + status: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct Dependency { + key: String, + scope: String, + #[serde(rename = "type")] + kind: Option, + classifier: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct Library { + pub path: PathBuf, + pub test: bool, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ProjectLibraries { + pub project_uri: String, + pub libraries: Vec, +} + +pub struct Outcome { + pub projects: Vec, + pub messages: Vec, + pub success: bool, +} + +/// Inspect sources, excluding generated/build/VCS trees and never following symlinks. +pub fn has_kotlin(root: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { return false }; + entries.flatten().any(|entry| { + let Ok(kind) = entry.file_type() else { return false }; + if kind.is_file() { + entry.path().extension().is_some_and(|e| e == "kt") + } else { + kind.is_dir() + && !matches!( + entry.file_name().to_str(), + Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea") + ) + && has_kotlin(&entry.path()) + } + }) +} + +/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output +/// redirector loaded before Maven calculates its lifecycle execution plans. +pub fn build( + root: &Path, + model: &BuildModel, + bundle: &Path, + generation: &Path, + cancel: &CancellationToken, +) -> IdeResult { + std::fs::create_dir_all(generation)?; + let mut plan = compile::plan(root, model, BuildKind::Build, None)?; + if plan.system != compile::BuildSystem::Maven { + return Err(IdeError::invalid("Kotlin bridge requires a Maven build root")); + } + let mut manifests = Vec::new(); + for (index, step) in plan.steps.iter_mut().enumerate() { + step.args.push("--fail-at-end".into()); + let directory = generation.join(format!("reactor-{index}")); + std::fs::create_dir_all(&directory)?; + manifests.push(directory.join("modules.json")); + let mut extensions = vec![bundle.to_path_buf()]; + step.args.retain(|arg| { + if let Some(value) = arg.strip_prefix("-Dmaven.ext.class.path=") { + extensions.extend(std::env::split_paths(value)); + false + } else { + true + } + }); + let extension_path = std::env::join_paths(extensions).map_err(|e| IdeError::invalid(e.to_string()))?; + step.args.push(format!("-Dmaven.ext.class.path={}", extension_path.to_string_lossy())); + step.args.push(format!("-Dopenidea.kotlin.staging={}", directory.join("staging").display())); + } + let mut messages = Vec::new(); + // Independent linked reactors must still run after another reactor's javac fails. + for step in &plan.steps { + let mut reactor = plan.clone(); + reactor.steps = vec![step.clone()]; + let result = compile::run(root, &reactor, cancel, &|event| { + if let BuildEvent::Output { text, .. } = event { + tracing::debug!("kotlin bridge: {}", text.trim_end()); + } + })?; + messages.extend(result.messages); + } + // javac diagnostics belong to JDT, including when a Kotlin target was skipped. + messages.retain(|message| { + message.path.as_ref().is_some_and(|path| PathBuf::from(path.clone()).extension().is_some_and(|e| e == "kt")) + }); + let mut modules = Vec::new(); + for manifest in manifests { + let parsed: Vec = serde_json::from_slice(&std::fs::read(manifest)?) + .map_err(|e| IdeError::internal(format!("Kotlin module manifest: {e}")))?; + modules.extend(parsed); + } + messages.extend(kotlin_failures(&modules)); + if messages.iter().any(|message| message.severity == ide_core::Severity::Error) { + return Ok(Outcome { projects: Vec::new(), messages, success: false }); + } + let pending = generation.join("libraries.pending"); + let published = generation.join("libraries"); + std::fs::create_dir_all(&pending)?; + let mut libraries = BTreeMap::new(); + for (index, module) in modules.iter().enumerate() { + let mut outputs = ModuleLibraries::default(); + for (source, target, name) in [ + (Some(&module.output), &mut outputs.main, format!("main-{index}")), + (module.test_output.as_ref(), &mut outputs.test, format!("test-{index}")), + ] { + let Some(source) = source else { continue }; + if !source.starts_with(generation) { + return Err(IdeError::internal("Kotlin build output escaped staging")); + } + if copy_kotlin(source, &pending.join(&name), cancel)? { + *target = Some(published.join(name)); + } + } + libraries.insert(module.key.clone(), outputs); + } + publish(&pending, &published, cancel)?; + Ok(Outcome { projects: consumers(&modules, &libraries), messages, success: true }) +} + +fn kotlin_failures(modules: &[Module]) -> Vec { + let mut messages = Vec::new(); + for module in modules { + if !module.has_kotlin { + continue; + } + if module.kotlin_goals.is_empty() { + messages.push(kotlin_failure(module, "compile target was not executed")); + } + for goal in &module.kotlin_goals { + if goal.status != "success" { + messages.push(kotlin_failure(module, &format!("{} {}", goal.execution, goal.status))); + } + } + } + messages +} + +fn kotlin_failure(module: &Module, reason: &str) -> BuildMessage { + BuildMessage { + severity: ide_core::Severity::Error, + text: format!("Kotlin compilation for {}: {reason}", module.key), + path: None, + range: None, + code: None, + } +} + +fn publish(pending: &Path, published: &Path, cancel: &CancellationToken) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + std::fs::rename(pending, published)?; + Ok(()) +} + +#[derive(Default)] +struct ModuleLibraries { + main: Option, + test: Option, +} + +fn consumers(modules: &[Module], libraries: &BTreeMap) -> Vec { + modules + .iter() + .map(|consumer| { + let mut visible = Vec::::new(); + let mut add = |path: &Option, test: bool| { + if let Some(path) = path { + if let Some(previous) = visible.iter_mut().find(|entry| &entry.path == path) { + previous.test &= test; + } else { + visible.push(Library { path: path.clone(), test }); + } + } + }; + if let Some(own) = libraries.get(&consumer.key) { + add(&own.test, true); + add(&own.main, false); + } + // Maven's resolved artifacts already apply exclusions, mediation and optional + // propagation. Preserve their classpath order, after the module's own output. + for dependency in &consumer.dependencies { + let Some(output) = libraries.get(&dependency.key) else { continue }; + let test = match dependency.scope.as_str() { + "compile" | "provided" | "system" | "" => false, + "test" | "runtime" => true, + _ => continue, + }; + match (dependency.kind.as_deref(), dependency.classifier.as_deref()) { + (Some("test-jar"), None | Some("tests")) | (Some("jar"), Some("tests")) => add(&output.test, test), + (None | Some("jar"), None | Some("")) => add(&output.main, test), + _ => {} + } + } + ProjectLibraries { project_uri: consumer.project_uri.clone(), libraries: visible } + }) + .collect() +} + +fn copy_kotlin(source: &Path, destination: &Path, cancel: &CancellationToken) -> IdeResult { + let Ok(entries) = std::fs::read_dir(source) else { return Ok(false) }; + let mut found = false; + for entry in entries { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let entry = entry?; + let kind = entry.file_type()?; + let path = entry.path(); + let output = destination.join(entry.file_name()); + if kind.is_dir() { + found |= copy_kotlin(&path, &output, cancel)?; + } else if kind.is_file() { + let bytes = std::fs::read(&path)?; + let class = path.extension().is_some_and(|e| e == "class"); + let metadata = path.extension().is_some_and(|e| e == "kotlin_module"); + if metadata || (class && kotlin_class(&bytes).unwrap_or(false)) { + std::fs::create_dir_all(destination)?; + std::fs::write(output, bytes)?; + found |= class; + } + } + } + Ok(found) +} + +/// Read the actual class-level SourceFile attribute, rather than searching strings +/// (a Java constant containing "Something.kt" must never become a Kotlin library). +fn kotlin_class(bytes: &[u8]) -> Option { + struct Reader<'a> { + bytes: &'a [u8], + pos: usize, + } + impl<'a> Reader<'a> { + fn take(&mut self, count: usize) -> Option<&'a [u8]> { + let value = self.bytes.get(self.pos..self.pos.checked_add(count)?)?; + self.pos += count; + Some(value) + } + fn u2(&mut self) -> Option { + Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize) + } + fn u4(&mut self) -> Option { + Some(u32::from_be_bytes(self.take(4)?.try_into().ok()?) as usize) + } + fn attributes(&mut self) -> Option<()> { + for _ in 0..self.u2()? { + self.u2()?; + let n = self.u4()?; + self.take(n)?; + } + Some(()) + } + } + let mut reader = Reader { bytes, pos: 0 }; + if reader.take(4)? != [0xca, 0xfe, 0xba, 0xbe] { + return None; + } + reader.take(4)?; + let count = reader.u2()?; + let mut utf8 = BTreeMap::new(); + let mut index = 1; + while index < count { + match reader.take(1)?[0] { + 1 => { + let n = reader.u2()?; + utf8.insert(index, reader.take(n)?); + } + 3 | 4 | 9..=12 | 17 | 18 => { + reader.take(4)?; + } + 5 | 6 => { + reader.take(8)?; + index += 1; + } + 7 | 8 | 16 | 19 | 20 => { + reader.take(2)?; + } + 15 => { + reader.take(3)?; + } + _ => return None, + } + index += 1; + } + reader.take(6)?; + let interfaces = reader.u2()?; + reader.take(interfaces.checked_mul(2)?)?; + for _ in 0..2 { + for _ in 0..reader.u2()? { + reader.take(6)?; + reader.attributes()?; + } + } + for _ in 0..reader.u2()? { + let name = reader.u2()?; + let length = reader.u4()?; + if utf8.get(&name).copied() == Some(b"SourceFile".as_slice()) && length == 2 { + let source = reader.u2()?; + return Some(utf8.get(&source)?.ends_with(b".kt")); + } + reader.take(length)?; + } + Some(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_kotlin_target_must_succeed_even_when_maven_fails_elsewhere() { + let mut modules: Vec = serde_json::from_str(r#"[ + {"key":"lib","projectUri":"lib","output":"lib","dependencies":[],"hasKotlin":true, + "kotlinGoals":[{"execution":"main:compile","status":"success"},{"execution":"test:test-compile","status":"success"}]}, + {"key":"java","projectUri":"java","output":"java","dependencies":[],"hasKotlin":false,"kotlinGoals":[]} + ]"#).unwrap(); + assert!(kotlin_failures(&modules).is_empty()); + for status in ["failed", "skipped"] { + modules[0].kotlin_goals[1].status = status.into(); + let failures = kotlin_failures(&modules); + assert_eq!(failures.len(), 1); + assert!(failures[0].text.contains(&format!("test:test-compile {status}"))); + } + modules[0].kotlin_goals.clear(); + assert_eq!(kotlin_failures(&modules).len(), 1); + modules[0].has_kotlin = false; + assert!(kotlin_failures(&modules).is_empty()); + assert!( + serde_json::from_str::>( + r#"[{"key":"old","projectUri":"old","output":"old","dependencies":[]}]"# + ) + .is_err() + ); + } + + #[test] + fn respects_mavens_resolved_closure_and_scopes() { + let json = r#"[ + {"key":"zapp","projectUri":"app","output":"app", "hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"mid","scope":"compile","optional":false},{"key":"lib","scope":"compile","optional":false}]}, + {"key":"mid","projectUri":"mid","output":"mid", "hasKotlin":false,"kotlinGoals":[],"dependencies":[ + {"key":"lib","scope":"compile","optional":false}, + {"key":"optional","scope":"compile","optional":true}, + {"key":"test","scope":"test","optional":false}, + {"key":"provided","scope":"provided","optional":false}]}, + {"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]} + ]"#; + let modules: Vec = serde_json::from_str(json).unwrap(); + let libraries = ["zapp", "lib", "optional", "test", "provided"] + .into_iter() + .map(|key| (key.to_owned(), ModuleLibraries { main: Some(PathBuf::from(key)), test: None })) + .collect(); + let result = consumers(&modules, &libraries); + assert_eq!( + result[0].libraries, + vec![Library { path: "zapp".into(), test: false }, Library { path: "lib".into(), test: false },] + ); + assert_eq!(result[1].libraries.len(), 4); + assert!(result[1].libraries.iter().find(|l| l.path == Path::new("test")).unwrap().test); + } + + #[test] + fn test_outputs_are_private_unless_maven_resolves_a_test_jar() { + let modules: Vec = serde_json::from_str(r#"[ + {"key":"lib","projectUri":"lib","output":"lib","hasKotlin":false,"kotlinGoals":[],"dependencies":[]}, + {"key":"app","projectUri":"app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"compile","type":"jar","classifier":null}]}, + {"key":"test-app","projectUri":"test-app","output":"app","hasKotlin":false,"kotlinGoals":[],"dependencies":[{"key":"lib","scope":"test","type":"test-jar","classifier":"tests"}]} + ]"#).unwrap(); + let libraries = + BTreeMap::from([("lib".into(), ModuleLibraries { main: Some("main".into()), test: Some("test".into()) })]); + let result = consumers(&modules, &libraries); + assert_eq!( + result[0].libraries, + vec![Library { path: "test".into(), test: true }, Library { path: "main".into(), test: false }] + ); + assert_eq!(result[1].libraries, vec![Library { path: "main".into(), test: false }]); + assert_eq!(result[2].libraries, vec![Library { path: "test".into(), test: true }]); + } + + #[test] + fn publishes_complete_directories_and_cancellation_keeps_them_private() { + let directory = tempfile::tempdir().unwrap(); + let pending = directory.path().join("pending"); + let final_path = directory.path().join("published"); + std::fs::create_dir(&pending).unwrap(); + std::fs::write(pending.join("Channel.class"), "complete").unwrap(); + let cancelled = CancellationToken::new(); + cancelled.cancel(); + assert!(publish(&pending, &final_path, &cancelled).is_err()); + assert!(!final_path.exists()); + assert!(pending.exists()); + publish(&pending, &final_path, &CancellationToken::new()).unwrap(); + assert!(!pending.exists()); + assert_eq!(std::fs::read(final_path.join("Channel.class")).unwrap(), b"complete"); + } + + #[test] + fn never_runs_another_build_system_with_maven_output_properties() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("Cargo.toml"), "").unwrap(); + let error = build( + directory.path(), + &BuildModel::default(), + Path::new("unused.jar"), + &directory.path().join("private"), + &CancellationToken::new(), + ) + .err() + .unwrap(); + assert_eq!(error.message, "Kotlin bridge requires a Maven build root"); + } + + #[test] + fn does_not_find_deleted_sources_in_build_outputs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("target/generated")).unwrap(); + std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap(); + assert!(!has_kotlin(dir.path())); + std::fs::write(dir.path().join("New.kt"), "").unwrap(); + assert!(has_kotlin(dir.path())); + } + + #[test] + fn parses_sourcefile_and_rejects_truncated_classes() { + let mut class = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52, 0, 3, 1, 0, 10]; + class.extend_from_slice(b"SourceFile"); + class.extend_from_slice(&[1, 0, 10]); + class.extend_from_slice(b"Channel.kt"); + class.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2]); + assert_eq!(kotlin_class(&class), Some(true)); + for end in 0..class.len() { + assert!(kotlin_class(&class[..end]).is_none()); + } + let offset = 26; + class[offset..offset + 10].copy_from_slice(b"Other.java"); + assert_eq!(kotlin_class(&class), Some(false)); + } +} diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 10dcd9b1..96358419 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -8,6 +8,7 @@ pub mod application_update; pub mod compile; pub mod gradle; +pub mod kotlin_bridge; pub mod maven; pub mod pom_edit; mod process; diff --git a/crates/ide-host/src/kotlin_bridge.rs b/crates/ide-host/src/kotlin_bridge.rs new file mode 100644 index 00000000..c29be5a2 --- /dev/null +++ b/crates/ide-host/src/kotlin_bridge.rs @@ -0,0 +1,429 @@ +//! Saved Kotlin -> isolated Maven output -> marked JDT libraries. + +use ide_build::kotlin_bridge::{self, Outcome, ProjectLibraries}; +use ide_core::event::NotifyLevel; +use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink}; +use ide_lsp::LspManager; +use ide_workspace::SettingsStore; +use ide_workspace::documents::DocumentObserver; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::time::Duration; +use tokio::sync::Notify; + +const DELAY: Duration = Duration::from_millis(750); +const SOURCE: &str = "kotlin-bridge"; +const COMMAND: &str = "openidea.kotlin.replace"; + +#[derive(Default)] +struct Revision { + generation: u64, + cancel: CancellationToken, +} + +impl Revision { + fn change(&mut self) { + self.cancel.cancel(); + self.generation += 1; + self.cancel = CancellationToken::new(); + } + fn current(&self, generation: u64) -> bool { + self.generation == generation && !self.cancel.is_cancelled() + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] +struct MavenOptions { + offline: bool, + profiles: Vec, +} + +pub(crate) struct KotlinBridge { + root: PathBuf, + cache: PathBuf, + bundle: Option, + settings: Arc, + lsp: Arc, + sink: SharedSink, + trusted: Arc, + lifetime: CancellationToken, + revision: Mutex, + changed: Notify, + options: Mutex, + worker: Mutex>>, +} + +impl KotlinBridge { + pub(crate) fn new( + root: PathBuf, + cache: PathBuf, + data: &Path, + settings: Arc, + lsp: Arc, + sink: SharedSink, + trusted: Arc, + ) -> Arc { + let options = std::fs::read(cache.join("kotlin-bridge-options.json")) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + Arc::new(Self { + root, + cache, + bundle: ide_sdk::java::kotlin_bridge_bundle(data), + settings, + lsp, + sink, + trusted, + lifetime: CancellationToken::new(), + revision: Mutex::new(Revision::default()), + changed: Notify::new(), + options: Mutex::new(options), + worker: Mutex::new(None), + }) + } + + pub(crate) fn start(self: &Arc, runtime: &tokio::runtime::Handle) { + let bridge = self.clone(); + *self.worker.lock() = Some(runtime.spawn(async move { bridge.run().await })); + self.schedule(); + } + + pub(crate) fn configure_maven(&self, offline: bool, profiles: Vec) { + let options = MavenOptions { offline, profiles }; + if let Ok(bytes) = serde_json::to_vec(&options) { + let _ = std::fs::create_dir_all(&self.cache); + let _ = std::fs::write(self.cache.join("kotlin-bridge-options.json"), bytes); + } + *self.options.lock() = options; + self.schedule(); + } + + pub(crate) async fn shutdown(&self) { + self.stop(); + let worker = self.worker.lock().take(); + if let Some(worker) = worker { + let _ = worker.await; + } + } + + pub(crate) fn schedule(&self) { + if self.lifetime.is_cancelled() { + return; + } + self.revision.lock().change(); + self.changed.notify_one(); + } + + pub(crate) fn files_changed<'a>(&self, mut paths: impl Iterator) { + let settings = self.settings.effective(); + if paths.any(|path| relevant_change(&self.root, path, &settings)) { + self.schedule(); + } + } + + pub(crate) fn stop(&self) { + self.lifetime.cancel(); + self.revision.lock().cancel.cancel(); + } + + async fn replace(&self, projects: &[ProjectLibraries], cancel: &CancellationToken) -> ide_core::IdeResult<()> { + let snapshot = serde_json::to_value(projects).map_err(|e| ide_core::IdeError::internal(e.to_string()))?; + let response = self.lsp.jdtls_command_cancellable(&self.root, COMMAND, vec![snapshot], cancel).await?; + if response.as_u64().is_none() { + return Err(ide_core::IdeError::unavailable( + "Kotlin bridge command did not acknowledge its library snapshot", + )); + } + Ok(()) + } + + async fn run(self: Arc) { + let mut diagnostics = BTreeSet::new(); + let mut last_failure = String::new(); + let mut initialized = false; + // Also retain directories from unacknowledged replacements: JDT may have applied them. + let mut published = Vec::new(); + loop { + tokio::select! { + _ = self.lifetime.cancelled() => return, + _ = self.changed.notified() => {}, + } + let (generation, cancel) = { + let state = self.revision.lock(); + (state.generation, state.cancel.clone()) + }; + if !self.trusted.load(Ordering::Acquire) { + continue; + } + if !debounce(&cancel, &self.lifetime).await { + continue; + } + let root = self.root.clone(); + let cache = self.cache.clone(); + let settings = self.settings.effective(); + let has_sources = tokio::task::spawn_blocking(move || { + let model = crate::ops::build::load(&root, &cache, &settings); + let enabled = + model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root); + (model, enabled) + }) + .await; + let Ok((mut model, enabled)) = has_sources else { continue }; + if !self.revision.lock().current(generation) { + continue; + } + if !enabled { + if self.cache.join("kotlin-bridge").exists() + && let Err(error) = self.replace(&[], &cancel).await + { + if !cancel.is_cancelled() { + tracing::warn!("Kotlin library withdrawal: {error}"); + } + continue; + } + for old in published.drain(..) { + remove_directory(old).await; + } + self.clear_diagnostics(&mut diagnostics); + last_failure.clear(); + continue; + } + if let Some(maven) = &mut model.maven { + let options = self.options.lock(); + for module in &mut maven.projects { + if options.offline { + module.options.push("-o".into()); + } + if !options.profiles.is_empty() { + module.options.push(format!("-P{}", options.profiles.join(","))); + } + } + } + let Some(bundle) = self.bundle.clone() else { + self.failure("Kotlin bridge bundle is unavailable".into(), &mut diagnostics, &mut last_failure); + continue; + }; + // Clear entries persisted by an earlier incarnation only before our first build. + if !initialized { + if let Err(error) = self.replace(&[], &cancel).await { + if !cancel.is_cancelled() { + self.failure(error.message, &mut diagnostics, &mut last_failure); + } + continue; + } + initialized = true; + } + let directory = self.cache.join(format!("kotlin-bridge/{}", uuid::Uuid::new_v4())); + let staging = directory.clone(); + let root = self.root.clone(); + let token = cancel.clone(); + let result = + tokio::task::spawn_blocking(move || kotlin_bridge::build(&root, &model, &bundle, &staging, &token)) + .await; + if !self.revision.lock().current(generation) || self.lifetime.is_cancelled() { + remove_directory(directory).await; + continue; + } + match result { + Ok(Ok(Outcome { success: true, projects, .. })) => { + match self.replace(&projects, &cancel).await { + Ok(()) => { + for old in published.drain(..) { + remove_directory(old).await; + } + published.push(directory); + self.clear_diagnostics(&mut diagnostics); + last_failure.clear(); + } + Err(error) => { + // Keep both snapshots until an acknowledged replacement. + published.push(directory); + if !cancel.is_cancelled() { + self.failure(error.message, &mut diagnostics, &mut last_failure); + } + } + } + } + other => { + let text = match other { + Ok(Ok(outcome)) => outcome + .messages + .iter() + .filter(|m| m.severity == Severity::Error) + .map(|m| m.text.as_str()) + .collect::>() + .join("\n"), + Ok(Err(error)) => error.message, + Err(error) => error.to_string(), + }; + self.failure(text, &mut diagnostics, &mut last_failure); + remove_directory(directory).await; + } + } + } + } + + fn clear_diagnostics(&self, paths: &mut BTreeSet) { + for path in std::mem::take(paths) { + self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items: Vec::new() }); + } + } + + fn failure(&self, text: String, paths: &mut BTreeSet, previous: &mut String) { + let text = if text.is_empty() { "Kotlin compilation failed".to_owned() } else { text }; + let text = format!("{text}\nJava temporarily uses the last successfully compiled Kotlin API."); + self.clear_diagnostics(paths); + let path = self.root.join("pom.xml"); + paths.insert(path.clone()); + self.sink.emit(IdeEvent::Diagnostics { + path: path.into(), + source: SOURCE.into(), + items: vec![Diagnostic { + range: Range { start: Pos { line: 0, col: 0 }, end: Pos { line: 0, col: 0 } }, + severity: Severity::Error, + message: text.clone(), + source: Some(SOURCE.into()), + server: Some(SOURCE.into()), + code: None, + unnecessary: false, + deprecated: false, + }], + }); + if previous != &text { + self.sink.emit(IdeEvent::Notify { + level: NotifyLevel::Error, + title: "Kotlin editor compilation failed".into(), + body: Some(text.clone()), + }); + *previous = text; + } + } +} + +fn relevant_change(root: &Path, path: &Path, settings: &ide_core::settings::Settings) -> bool { + let input = path.extension().is_some_and(|e| e == "kt") + || path.file_name().is_some_and(|n| n == "pom.xml" || n == "mvnw" || n == "mvnw.cmd") + || path.components().any(|c| c.as_os_str() == ".mvn"); + let Ok(relative) = path.strip_prefix(root) else { + // Content roots outside the project only contribute Kotlin sources. + return path.extension().is_some_and(|e| e == "kt"); + }; + // Build outputs and generated sources (e.g. KSP's `.kt` under target/) never trigger, + // so a build cannot schedule itself. + if relative.components().any(|c| { + matches!( + c.as_os_str().to_str(), + Some("target" | "build" | "out" | "bin" | ".git" | "node_modules" | ".openidea") + ) + }) || ide_vfs::ExcludeMatcher::new(&settings.exclude_globs()).is_excluded(relative) + { + return false; + } + if input { + return true; + } + if path.extension().is_some() { + return false; + } + // Works for deleted directories too; the module POM remains on disk. + path.ancestors().take_while(|ancestor| ancestor.starts_with(root)).any(|ancestor| { + ancestor.file_name().is_some_and(|name| name == "src") + && ancestor.parent().is_some_and(|module| module.join("pom.xml").is_file()) + }) +} + +async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool { + tokio::select! { + _ = lifetime.cancelled() => false, + _ = cancel.cancelled() => false, + _ = tokio::time::sleep(DELAY) => true, + } +} + +async fn remove_directory(path: PathBuf) { + let _ = tokio::task::spawn_blocking(move || std::fs::remove_dir_all(path)).await; +} + +impl DocumentObserver for KotlinBridge { + fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { + self.files_changed(std::iter::once(&path.to_path_buf())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_package_directory_does_not_trigger() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pom.xml"), "").unwrap(); + assert!(!relevant_change(dir.path(), &dir.path().join("target/classes/probe"), &Default::default())); + let generated = dir.path().join("target/generated-sources/ksp/main/kotlin/Gen.kt"); + assert!(!relevant_change(dir.path(), &generated, &Default::default())); + assert!(relevant_change(dir.path(), &dir.path().join("src/main/kotlin/A.kt"), &Default::default())); + } + + #[test] + fn deleted_source_package_triggers_unless_excluded() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("module")).unwrap(); + std::fs::write(dir.path().join("module/pom.xml"), "").unwrap(); + let path = dir.path().join("module/src/main/kotlin/pkg"); + let mut settings = ide_core::settings::Settings::default(); + assert!(relevant_change(dir.path(), &path, &settings)); + settings.project_structure.excluded.push("module/src/main/kotlin".into()); + assert!(!relevant_change(dir.path(), &path, &settings)); + assert!(!relevant_change(dir.path(), &dir.path().join("module/src/build/pkg"), &Default::default())); + } + + #[test] + fn extensionless_root_file_does_not_trigger() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("pom.xml"), "").unwrap(); + assert!(!relevant_change(dir.path(), &dir.path().join("LICENSE"), &Default::default())); + } + + #[tokio::test(start_paused = true)] + async fn burst_saves_cancel_the_pending_debounce() { + let mut state = Revision::default(); + state.change(); + let token = state.cancel.clone(); + let lifetime = CancellationToken::new(); + let pending_lifetime = lifetime.clone(); + let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await }); + tokio::task::yield_now().await; + tokio::time::advance(DELAY - Duration::from_millis(1)).await; + assert!(!waiting.is_finished()); + for _ in 0..20 { + state.change(); + } + assert!(!waiting.await.unwrap()); + let current = state.generation; + assert!(debounce(&state.cancel, &lifetime).await); + assert!(state.current(current)); + lifetime.cancel(); + assert!(!debounce(&state.cancel, &lifetime).await); + } + + #[test] + fn superseded_and_cancelled_builds_cannot_publish() { + let mut state = Revision::default(); + state.change(); + let first = state.generation; + let token = state.cancel.clone(); + state.change(); + assert!(token.is_cancelled()); + assert!(!state.current(first)); + assert!(state.current(state.generation)); + state.cancel.cancel(); + assert!(!state.current(state.generation)); + } +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index ccb01db4..0d310d47 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -5,6 +5,7 @@ pub mod environment; mod java_runtime; +mod kotlin_bridge; pub mod notebook; pub mod ops; mod project; @@ -148,9 +149,15 @@ impl Host { if !project.light { self.environment.project_closed(); } + if let Some(bridge) = &project.kotlin_bridge { + bridge.stop(); + } project.agents.shutdown(); project.notebooks.stop_admission(); - self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]); + self.shutdown_project_workers_blocking( + vec![project.notebooks.clone()], + project.kotlin_bridge.iter().cloned().collect(), + ); let lsp = project.lsp.clone(); self.runtime.spawn(async move { lsp.shutdown_all().await }); } @@ -158,7 +165,14 @@ impl Host { /// Synchronous project/window destruction must finish owned kernel cleanup, /// not enqueue work which can be lost as the desktop runtime exits. - fn shutdown_notebooks_blocking(&self, notebooks: Vec>) { + fn shutdown_project_workers_blocking( + &self, + notebooks: Vec>, + bridges: Vec>, + ) { + for bridge in &bridges { + bridge.stop(); + } for service in ¬ebooks { service.stop_admission(); } @@ -166,6 +180,9 @@ impl Host { let wait = move || { let worker = std::thread::spawn(move || { runtime.block_on(async move { + for bridge in bridges { + bridge.shutdown().await; + } for service in notebooks { service.shutdown().await; } @@ -186,7 +203,11 @@ impl Host { /// Called before the desktop runtime exits, including update/restart exits. pub fn shutdown_notebooks_on_exit(&self) { - self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect()); + let projects = self.projects(); + self.shutdown_project_workers_blocking( + projects.iter().map(|project| project.notebooks.clone()).collect(), + projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), + ); } /// Stops what this host started: notebook kernels, language servers, agents, runs and terminals. @@ -197,12 +218,18 @@ impl Host { self.terminals.kill_all(); let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect(); for project in &projects { + if let Some(bridge) = &project.kotlin_bridge { + bridge.stop(); + } project.notebooks.stop_admission(); project.agents.shutdown(); } // Kernel cleanup owns its interrupt/termination/reaping deadlines. An // outer LSP timeout must never abort it before owned children are reaped. for project in &projects { + if let Some(bridge) = &project.kotlin_bridge { + bridge.shutdown().await; + } project.notebooks.shutdown().await; } let stop_language_servers = async { diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 650db06d..ad398330 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -97,6 +97,7 @@ pub async fn build_sync( host.require_trust(window)?; let project = host.project(window)?; let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); + let bridge_profiles = profiles.clone(); let token = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { let current = load(&root, &cache, &settings); @@ -137,6 +138,9 @@ pub async fn build_sync( // jdtls re-imports on pom changes but misses artifacts added to ~/.m2 // meanwhile ("missing artifact" stays until Invalidate Caches); asking it // to update the project configurations re-resolves them (#436). + if let Some(bridge) = &project.kotlin_bridge { + bridge.configure_maven(offline, bridge_profiles); + } let lsp = project.lsp.clone(); let uris = maven.projects.iter().map(|p| ide_lsp::path_to_uri_string(&project.root.join(&p.pom))).collect(); tokio::spawn(async move { diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 0c85d106..f729f798 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -269,7 +269,13 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> { } pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> { - host.project(window)?.lsp.restart(server).await + let project = host.project(window)?; + let java = project.lsp.servers().iter().any(|s| s.id == server && s.name == "jdtls"); + project.lsp.restart(server).await?; + if java && let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } + Ok(()) } /// Applies a (previewed) workspace edit, e.g. after rename confirmation. diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index ec1d4402..4c17cdde 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -177,6 +177,9 @@ pub fn set_settings( .any(|k| key == *k || key.starts_with(&format!("{k}."))) { for project in affected() { + if let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } let lsp = project.lsp.clone(); host.runtime.spawn(async move { lsp.reconfigure().await }); } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index e3d868e2..f28c7cff 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -52,6 +52,7 @@ pub struct Project { pub agents: Arc, /// Native notebooks and owned kernels, scoped to this project incarnation. pub notebooks: Arc, + pub(crate) kotlin_bridge: Option>, pub(crate) watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. pub(crate) content_watchers: Mutex>, @@ -152,6 +153,7 @@ impl Host { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug") .into_iter() .chain(ide_sdk::java::usages_bundle(&data_dir)) + .chain(ide_sdk::java::kotlin_bridge_bundle(&data_dir)) .collect(); let min_java = ide_sdk::java::jdtls_min_java(cfg); let jdks = ide_sdk::java::jdtls_jdks(min_java, &data_dir); @@ -228,11 +230,22 @@ impl Host { trusted: trusted.clone(), })); + let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new( + root.clone(), + self.data_dir.join("cache").join(&project_key).join("build"), + &self.data_dir, + settings.clone(), + lsp.clone(), + sink.clone(), + trusted.clone(), + ); + documents.add_observer(kotlin_bridge.clone()); let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); for repo in repos.read().all() { repo.set_execution_allowed(trusted.load(Ordering::Acquire)); } let watcher = { + let kotlin_bridge = kotlin_bridge.clone(); let repos = repos.clone(); let settings = settings.clone(); let index = index.clone(); @@ -253,6 +266,7 @@ impl Host { Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); index.update(&changed, &batch.removed); + kotlin_bridge.files_changed(changed.iter().chain(&batch.removed)); lsp.project_files_changed(batch.created.iter().chain(&batch.removed)); // Atomic saves by external tools surface as `created`. documents.on_disk_changed(&changed); @@ -332,7 +346,7 @@ impl Host { let content_watchers = extra_roots .iter() .filter_map(|r| { - let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?; + let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?; Some((r.clone(), w)) }) .collect(); @@ -375,6 +389,7 @@ impl Host { http_history, agents, notebooks, + kotlin_bridge: Some(kotlin_bridge), watcher, content_watchers: Mutex::new(content_watchers), _scratch_watcher: scratch_watcher, @@ -393,6 +408,9 @@ impl Host { })); } self.insert_project(window, project.clone()); + if let Some(bridge) = &project.kotlin_bridge { + bridge.start(&self.runtime); + } if let Some(cmd) = post_start { self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); } @@ -493,6 +511,7 @@ impl Host { http_history, agents, notebooks, + kotlin_bridge: None, watcher, content_watchers: Mutex::new(Vec::new()), _scratch_watcher: None, @@ -786,9 +805,15 @@ impl Project { if watchers.iter().any(|(p, _)| *p == r) { continue; } - if let Some(w) = - content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history) - { + if let Some(w) = content_root_watcher( + &r, + &excludes, + &self.sink, + &self.index, + &self.documents, + &self.history, + self.kotlin_bridge.as_ref(), + ) { watchers.push((r, w)); } } @@ -920,7 +945,9 @@ fn content_root_watcher( index: &Arc, documents: &Arc, history: &Arc, + kotlin_bridge: Option<&Arc>, ) -> Option { + let kotlin_bridge = kotlin_bridge.cloned(); let (index, documents, history) = (index.clone(), documents.clone(), history.clone()); Watcher::start( dir, @@ -929,6 +956,9 @@ fn content_root_watcher( Box::new(move |batch| { let changed: Vec = batch.created.iter().chain(&batch.modified).cloned().collect(); index.update(&changed, &batch.removed); + if let Some(bridge) = &kotlin_bridge { + bridge.files_changed(changed.iter().chain(&batch.removed)); + } documents.on_disk_changed(&changed); record_external_changes(&history, &changed, &batch.removed); }), diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 96c7c974..b829bd63 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -106,6 +106,9 @@ impl crate::Host { if previous == trusted { continue; } + if let Some(bridge) = &project.kotlin_bridge { + bridge.schedule(); + } if !trusted { project.lsp.set_execution_allowed(false); } diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 09d6ed61..f2b79e0c 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -396,7 +396,8 @@ impl LspManager { self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await } - async fn jdtls_command_cancellable( + /// Cancellable delegate commands for project-owned background services. + pub async fn jdtls_command_cancellable( &self, path: &Path, command: &str, diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index bf86f581..9d104583 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -948,6 +948,49 @@ mod tests { } } +/// The same jar supplies the JDT command and the private Maven output redirector. +pub fn kotlin_bridge_bundle(data_dir: &Path) -> Option { + let extension = data_dir.join("kotlin-bridge-1.0.0.jar"); + let bytes = include_bytes!("../../../plugins/kotlin-bridge/openidea-kotlin-bridge.jar"); + if std::fs::read(&extension).is_ok_and(|old| old == bytes) { + return Some(extension); + } + let temporary = data_dir.join(format!( + "kotlin-bridge-{}-{}.tmp", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?.as_nanos() + )); + let result = std::fs::create_dir_all(data_dir) + .and_then(|_| std::fs::write(&temporary, bytes)) + .and_then(|_| std::fs::rename(&temporary, &extension)); + if let Err(error) = result { + let _ = std::fs::remove_file(temporary); + tracing::warn!("Kotlin bridge extension unavailable: {error}"); + return std::fs::read(&extension).is_ok_and(|old| old == bytes).then_some(extension); + } + Some(extension) +} + +#[cfg(test)] +mod kotlin_bridge_bundle_tests { + #[test] + fn embeds_both_runtime_targets_and_repairs_a_damaged_installation() { + let directory = tempfile::tempdir().unwrap(); + let path = super::kotlin_bridge_bundle(directory.path()).unwrap(); + let expected = std::fs::read(&path).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&expected)).unwrap(); + for (class, major) in [("KotlinLibraries", 65), ("IsolatedOutput", 52)] { + use std::io::Read; + let mut bytes = Vec::new(); + archive.by_name(&format!("ai/openidea/kotlin/{class}.class")).unwrap().read_to_end(&mut bytes).unwrap(); + assert_eq!(u16::from_be_bytes([bytes[6], bytes[7]]), major); + } + std::fs::write(&path, "incomplete").unwrap(); + assert_eq!(super::kotlin_bridge_bundle(directory.path()), Some(path.clone())); + assert_eq!(std::fs::read(path).unwrap(), expected); + } +} + /// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure. pub fn usages_bundle(data_dir: &Path) -> Option { let extension = data_dir.join("java-usages-1.1.1.jar"); diff --git a/plugins/kotlin-bridge/MANIFEST.MF b/plugins/kotlin-bridge/MANIFEST.MF new file mode 100644 index 00000000..f0d3eeba --- /dev/null +++ b/plugins/kotlin-bridge/MANIFEST.MF @@ -0,0 +1,8 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: OpenIDEA Kotlin binary bridge +Bundle-SymbolicName: ai.openidea.kotlin;singleton:=true +Bundle-Version: 1.0.0 +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core, + org.eclipse.core.runtime,org.eclipse.core.resources,com.google.gson diff --git a/plugins/kotlin-bridge/README.md b/plugins/kotlin-bridge/README.md new file mode 100644 index 00000000..a1eb4bf2 --- /dev/null +++ b/plugins/kotlin-bridge/README.md @@ -0,0 +1,73 @@ +# Kotlin binary bridge + +App-owned source, licensed under this repository's Apache-2.0 license. The jar +contains no third-party implementation classes. JDT LS and Maven supply their +respective APIs at runtime. + +`KotlinLibraries` is a JDT LS delegate command. `openidea.kotlin.replace` takes +one complete array of `{projectUri, libraries: [{path, test}]}` objects and returns +the number of changed projects. Paths name fully published, immutable directories. +An empty array withdraws all owned libraries. Only library entries carrying +`ai.openidea.kotlin=true` are removed; all other `IClasspathEntry` objects are +retained, including containers, access rules, attributes and project references. +A Java model listener restores the desired snapshot after Maven reimport. + +`IsolatedOutput` is a Java 8 Maven lifecycle participant, activated only in the +private editor build via `-Dmaven.ext.class.path=` and +`-Dopenidea.kotlin.staging=`. It redirects reactor output +before lifecycle planning and records Maven's resolved dependency closure after +each session, including failed builds. An execution listener records each module's +Kotlin compile/test-compile targets as successful, failed or skipped. The host uses +the project's Maven lifecycle/configuration, +then publishes only classes whose actual `SourceFile` attribute ends in `.kt`, +plus Kotlin module metadata. Java output is not bridged. Main/test Kotlin APIs and +same-reactor consumers are covered; Precise Kotlin +source navigation, unsaved analysis and refactoring are outside this bridge. + +The host debounces saved/source/configuration changes, cancels superseded process +groups, and retains the last successful snapshot while rebuilding. Maven runs +with `--fail-at-end`. A new snapshot is attached only when all Kotlin targets in +modules containing Kotlin sources succeed, even if javac fails. Java diagnostics +belong to JDT. Failed or skipped Kotlin targets retain the previous snapshot and +report errors under `kotlin-bridge` Problems and a notification explaining the +older API. Old directories are deleted only after JDT acknowledges replacement. +With no Kotlin sources left, all managed libraries and bridge diagnostics are +withdrawn, even when the POM still declares the Kotlin plugin. Extensionless +watcher paths trigger only below a Maven module's `src/`, outside generated and +user-excluded directories. Run/Debug preparation is independent of this editor +build. + +## Build + +Built against JDT LS 1.63.0 and Maven 3.9.11, using their installed API jars. +`build-dependencies.sha256` pins all build inputs. The build refuses different +inputs; `--record-dependencies` is an explicit maintainer action when updating +those dependencies. Compilation uses the JDT batch compiler with Java 21 target +for the JDT command and Java 8 target for the Maven participant. Use Java 25 to +run the build compiler. Archive order and timestamps are deterministic. + +```sh +python3 plugins/kotlin-bridge/build.py /path/to/jdtls /path/to/maven +shasum -a 256 plugins/kotlin-bridge/openidea-kotlin-bridge.jar +``` + +Committed jar SHA-256: +`5c6407a85bd3cfcde0ee68b2f223dd3d1ca0e5165ff76a2249191617496b1fcd`. + +The SDK embeds this jar with `include_bytes!`, writes it atomically into the app +data directory, and includes it in JDT LS initialization bundles. + +## Integration regression + +```sh +# `java` on PATH must satisfy JDT LS; JAVA_HOME selects Maven's project JDK. +JAVA_HOME=/path/to/project/jdk python3 plugins/kotlin-bridge/tests/verify.py /path/to/jdtls +``` + +Requires Maven on PATH and access to Kotlin 2.0.10 and the fixture's Maven +plugins (or their existing local cache). The test creates its own projects, +workspace, bundle copy and JDT process. It checks baseline errors, single/reactor +resolution, SMS/member completion, binary definition locations, preservation of +unowned entries, reimport, API changes, publication despite javac failure, +failed/skipped Kotlin target retention, deletion withdrawal and repair. Evidence +and protocol transcripts are written to a printed temporary directory. Only the spawned JDT process is terminated. diff --git a/plugins/kotlin-bridge/build-dependencies.sha256 b/plugins/kotlin-bridge/build-dependencies.sha256 new file mode 100644 index 00000000..23592d90 --- /dev/null +++ b/plugins/kotlin-bridge/build-dependencies.sha256 @@ -0,0 +1,161 @@ +beebede8db065fe1b72909ecc66bb49acda618901635d86c25fce14a5915e37e jdt/ch.qos.logback.classic_1.6.3.jar +a6967a28c8b086dee75a694d2f6fb8830c71153539866d7d8af065c9b6df91e0 jdt/ch.qos.logback.core_1.6.3.jar +2cbd119bf1961c28788310963dc80ba65f58cdeec1dd139c8bdb1240faa2c36f jdt/com.google.gson_2.14.0.jar +cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb jdt/com.google.guava.failureaccess_1.0.3.jar +796d8e28ac64e83a47c4c5935a8fecc4682650a04bbdead738ef0f5a3a0e6c46 jdt/com.google.guava_33.7.1.jre.jar +3b3864f5b449e9c3c24b16861524b622b086563f44e0cd8384c8efc5a6052f82 jdt/com.sun.jna.platform_5.19.1.jar +467b2990d4f406e7d41f7a989f89df257767ca11d566f844560e2ab8d09f6433 jdt/com.sun.jna_5.19.1.v20260612-1000.jar +85fb03fc054cdf4efca8efd9b6712bbb418e1ab98241c4539c8585bbc23e1b8a jdt/jakarta.annotation-api_1.3.5.jar +a852b0971c589491e24334bd77e92760c048d2c102fc4c68e582e1cd53130f84 jdt/jakarta.inject.jakarta.inject-api_1.0.5.jar +2506fb25a748d4a5e5ec549d8bb063889d0f213e9cfbd6fd6fcbdc93b4f35bbb jdt/jakarta.servlet-api_6.2.0.M2.jar +cbf2aa09c14441d2ddea7096f60b738b3aee7f08fbf98f5f91e2ca48b08fff4a jdt/org.apache.ant_1.10.18.v20260906-1000.jar +8f7f8605d68e15bf32db61ec94eac6fdafc51b1bdbe1e0e0802b57d23f387792 jdt/org.apache.commons.cli_1.11.0.jar +78a5d732fbd715e2d10bd7150d2f8030bae57267f8aacc5c88f642cb6c2e5d3f jdt/org.apache.commons.commons-codec_1.22.1.jar +69e5c9fa35da7a51a5fd2099dfe56a2d8d32cf233e2f6d770e796146440263f4 jdt/org.apache.commons.lang3_3.20.0.jar +c5bd887c8fd5ec4cd563249e7552bb1a1a1ca57e05a1f1ef8425a7b7b7bd2fbe jdt/org.apache.felix.scr_2.2.18.jar +c375b6d5b71efe179e5763a6d83fcf10e303c0bf82031310a46769727ac90da0 jdt/org.commonmark.ext-gfm-tables_0.30.1.jar +9b0d20064975a17e876fcb4b599c6b3667f46a7739ed61e2f06c96f44b50629f jdt/org.commonmark_0.30.1.jar +a6a16f8f2b6977a10f951ab4ff46b155e4e20dd8c2fcd2cb835b74d90f30b456 jdt/org.eclipse.ant.core_3.7.1100.v20260831-0701.jar +61c479b6544db7eeebb5ba796051c092032d03a5136295baabd72a233baef29c jdt/org.eclipse.buildship.compat_3.1.13.v20260923-0232-s.jar +213baab808816e0661acd61a5382a8f120923288212defbd189b4ee6540ecce1 jdt/org.eclipse.buildship.core_3.1.13.v20260923-0232-s.jar +e75b07863128a076989d15e815d63a49cab772a338f1f0444d007bef3fa94601 jdt/org.eclipse.compare.core_3.8.900.v20260731-0809.jar +01425989976dccd56d348b207e11239b3d8bf572f7b7ef12dbd29eb39c40205e jdt/org.eclipse.core.commands_3.13.100.v20260902-1129.jar +a63b488fb9d5382fbb3b8bae0951f1da5fd3a20211aa6814c7e8c1e21d2ffc78 jdt/org.eclipse.core.contenttype_3.9.1000.v20260921-1824.jar +06a18236ae181659e1e5499d912d68de527720686f1f51dab632d3e298e32b78 jdt/org.eclipse.core.expressions_3.9.700.v20260710-0925.jar +422a33041ea01954f48ff3969e2c6d96e64831b3e372202a4d2a914282e3ffd9 jdt/org.eclipse.core.filebuffers_3.9.0.v20261010-1449.jar +d0f5e3fb7cd17bb8daebc684311198138e380e089292477f12d2dfbb4c8a30a8 jdt/org.eclipse.core.filesystem_1.12.0.v20260927-0940.jar +941b3c5feeba4ec0a949a8a94ff10283b2505813fa736c9e6345f146ae1b7e8a jdt/org.eclipse.core.jobs_3.15.900.v20260604-0622.jar +6e7a8db7d87a44bb35924bb345ddf0d9372413e3fee2edd842b50ed630ec254f jdt/org.eclipse.core.net_1.5.900.v20260507-0401.jar +e22d47e2e68713a74bfacd134dc01b1aa904a1b429769241483e058de166acea jdt/org.eclipse.core.resources_3.25.0.v20261010-1447.jar +0947f29f5c5b7d234e3b735de2d00d33ae6e3ee428a61bb818f9c0fe2b809735 jdt/org.eclipse.core.runtime_3.35.100.v20260910-1515.jar +553f09a59f2662fa4a3bbc94e84bd83221c1c6fb532d81b57010162fb47460a6 jdt/org.eclipse.core.variables_3.6.900.v20260602-0702.jar +a4e87f5e883e6c864783dc8148e9f915c3e7a9e3e0e26acf6b82210d1134ed79 jdt/org.eclipse.debug.core_3.24.100.v20261009-1915.jar +fd353398835107608f554779a6e91891725efb94e0f57559d0ab3cea4b6156c7 jdt/org.eclipse.equinox.app_1.7.600.v20251211-1038.jar +28749ec64dac9d0e20e00becc3d5f9daccb5637455c9c546ab07be324011ef84 jdt/org.eclipse.equinox.common_3.21.100.v20260829-1451.jar +9beeb1098a3da89a470c547aa840dea0d240d144138a5ba7b9ceedb84ed301ab jdt/org.eclipse.equinox.frameworkadmin.equinox_1.3.500.v20260928-0506.jar +f2b956930b8383d708f1fe9df92938bffbe2e129082d4fd0b3d5f59c20a5c9f0 jdt/org.eclipse.equinox.frameworkadmin_2.3.600.v20260306-0554.jar +8e889f92718fb8c612dd358265da2c94738bbc08b46473b81cad36f1177a8235 jdt/org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.1700.v20260913-1737.jar +347674eb3d3d4000c184573618241141297fb9586c8f65ebdbacace59b8c81a2 jdt/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.1700.v20260913-1737.jar +57e8a4b46906751e214dfa8fab1e02462c40ad4e51f2226d9ef70af5f0941ccb jdt/org.eclipse.equinox.launcher.gtk.linux.aarch64_1.2.1700.v20260913-1737.jar +d0e7418232627c53288e566728d24d1578650514152992b87f82a5d982429606 jdt/org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.1700.v20260913-1737.jar +aa8c31a082b25b87f75008548f6544379174c98091934fb8399e3f917a0445f1 jdt/org.eclipse.equinox.launcher.win32.win32.x86_64_1.3.200.v20260913-1737.jar +9b3b1da60813ac5fa09dd5c198f1884be7076d0a5daec54faa3f351a787e34b7 jdt/org.eclipse.equinox.launcher_1.8.0.v20260804-1928.jar +fd04e81a28ab32b6bb073286eb293c44b760176fcf1e35ef15614d62df820f86 jdt/org.eclipse.equinox.preferences_3.12.100.v20251111-0704.jar +9b149d3367d33fc1f9a19d3f87c425ecf9fc235ee748b3cf200ec387169b042b jdt/org.eclipse.equinox.registry_3.12.700.v20260830-1519.jar +60388d163f70329c39a9cf4e32389ec4fba8dc80ae5518d97cd0be2ebb971510 jdt/org.eclipse.equinox.security.linux_1.2.0.v20260914-1851.jar +a8408d4bcdc70e019d2d89ed8b1b0413eb74ea73e74fa3d064b7401643539797 jdt/org.eclipse.equinox.security.macosx_1.102.500.v20250521-0414.jar +cf9668c1d1e481059366abc2719468aa52e1276475cf9c32d4d10f57b34d7182 jdt/org.eclipse.equinox.security.win32_1.3.0.v20240419-2334.jar +91b74719f9c1a130193407808fe5daab3b92431efbf30b8f886f1ff5e97f468f jdt/org.eclipse.equinox.security_1.4.700.v20250622-1644.jar +1a24a3c0fb01ad71f97432d5c2b364d9868b5a5ebd99a1308aacb1a2c03c47da jdt/org.eclipse.equinox.simpleconfigurator.manipulator_2.3.700.v20260306-0556.jar +d250938d3715529a9822fcdf46ad2f5de39720558d7195b5c1a8713af00bcd13 jdt/org.eclipse.equinox.simpleconfigurator_1.5.700.v20251111-1031.jar +db9a4e856bb4728a2719df544b0f2ccef52b54ebf4e643a417fdb471f85703c1 jdt/org.eclipse.equinox.spi_1.0.0.v20261009-1945.jar +7615ee7abdd14695957a52864c57869018dc10ddaac80d3cd24b6a97e05d0d9f jdt/org.eclipse.jdt.apt.core_3.8.1000.v20261012-1009.jar +ea365808514a22c2e5a1a3fe0b9c07b88a371094d5d5716d31906a8818d56b2a jdt/org.eclipse.jdt.apt.pluggable.core_1.4.800.v20260930-1106.jar +f667bb3666d9875a2309fa2447a19e6cdd960281473bd7125a22c5a435246d21 jdt/org.eclipse.jdt.core.compiler.batch_3.46.200.v20261008-1115.jar +a6d0f8fabd64c248c37c463789a1a13f801252b68b0a4f4808c207677b5cae17 jdt/org.eclipse.jdt.core.javac_1.0.0.z20260430-1959.jar +8ea98fcbecbd89f311849e448f4c4274a2aba5e15cfcdcad29951330c40e0ee8 jdt/org.eclipse.jdt.core.manipulation_1.26.0.v20261009-1526.jar +70b4bb168aab096ee7f33da7b72f470f4172fe3e18e80289dd89cf344937ed22 jdt/org.eclipse.jdt.core_3.48.0.v20261007-1246.jar +12113df750bb22f7ef0dd3f044f7c334feecd55ddb56b9340529899c55f5893b jdt/org.eclipse.jdt.debug_3.27.0.v20261012-1250.jar +f140fad97dc5c452af0cb0f87ed3c6c83ff518f1dda49a43b8b9bec07f17d0fd jdt/org.eclipse.jdt.junit.core_3.16.0.v20261008-2002.jar +4ea3fe4a12a0e1e8b10b7ce3f577a0ff4f5115c6a5e40364c53b8b329a8505b0 jdt/org.eclipse.jdt.junit.runtime_3.8.200.v20260911-1643.jar +8b928263817f5dd0a9210a8842682903f779dc83253fea501591e150e5303095 jdt/org.eclipse.jdt.launching_3.25.0.v20261010-0421.jar +6e5e5e39d82e9ac04925e8b3f432ef6fe81284441e99952d2f720779c9e2831b jdt/org.eclipse.jdt.ls.core_1.63.0.202610191322.jar +666cc544f8a63e41d2dc8e6f3626071214027e4f698dea5bf36137f85477bba4 jdt/org.eclipse.jdt.ls.filesystem_1.63.0.202610191322.jar +792f7df214d4ab5e4915ac1805922d44de61ebb880f1fc4881ecd4ecfdca6aca jdt/org.eclipse.jdt.ls.logback.appender_1.63.0.202610191322.jar +9647feb0524bf763c878e12ab878a684102b81cccb3f77feecbec709d54f9bbb jdt/org.eclipse.lsp4j.jsonrpc_1.0.0.v20260209-1721.jar +ccd78893facc6bfcc359d56cba05d3d5b85eb41e4c40d4b4215ca45db5f416d9 jdt/org.eclipse.lsp4j_1.0.0.v20260209-1721.jar +a5d6bdc3e6f5017f19375697c7a5929fbc83f5734f35d5ed4540c7b2c843d4bb jdt/org.eclipse.ltk.core.refactoring_3.17.0.v20261010-1449.jar +69d1b41401686fd99337e1ddd8aeec309255ae464393ca19a8025c85bfae6601 jdt/org.eclipse.m2e.apt.core_2.3.200.20260815-0704.jar +34872f7712dffca0b39804708c0c1b58f2f2576e27cb7176b92a1ea437d384e6 jdt/org.eclipse.m2e.core_2.8.0.20260815-0704.jar +8c3fa36fe9f62ace897b0196df8e6bba6a50610c186f6689ada5f386a8b223b1 jdt/org.eclipse.m2e.jdt_2.5.300.20260813-1359.jar +5c5b419a101210127f21fa7d37bd2605b90f96efe9d3e91ea31099fb243dec8e jdt/org.eclipse.m2e.maven.runtime_3.9.1601.20260722-2149.jar +53376fa2d7bfd2d5166806e74af9576ee3f8f1c1ac916e2cb4d2bb0c3e72c02f jdt/org.eclipse.m2e.workspace.cli_0.4.0.jar +20e09b8f88845480b6ad0b89ac001679b6713592cd0ee4bd78cdb326172eee62 jdt/org.eclipse.osgi.compatibility.state_1.3.100.v20260909-1123.jar +fe66fd0b071bebc7e1a122be571ed8ecde373d2966fa6b1e161a899b3c7a054d jdt/org.eclipse.osgi_3.25.0.v20261008-2211.jar +2714e0c7548a0c23ab99d4e84b68d3e67419bbd6b295efda15d881e7cb173a1c jdt/org.eclipse.search.core_3.17.0.v20261010-1449.jar +0ca3b552c867619285b8ae36c7defdd6c0f013e692643d433fd111796da55f19 jdt/org.eclipse.text_3.14.900.v20261012-1310.jar +c6c2b8593cd39c279e74e7055cbd146d39dc6f16011961b4928b92728d32d00d jdt/org.eclipse.xtext.xbase.lib_2.44.0.v20260824-1228.jar +ea7a53f8b7966039c6f7d54f462cbbaaad38a5e313a4e7d9125fa39640988725 jdt/org.gradle.toolingapi_9.7.1.v20260923-0232-s.jar +5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc jdt/org.hamcrest_3.0.0.jar +e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003 jdt/org.jsoup_1.23.2.jar +3efac657139b0b8024748e937e3e1dba71fbdf69fb526c60f504a3fb5490381b jdt/org.junit_4.13.2.v20240929-1000.jar +ed825d10ab1399c8c0cb669e688cf0c8c82629b4c8399b58352b68e92ca10fcb jdt/org.objectweb.asm_9.10.1.jar +0653852deab5c6f6e9c84e3aa8f72e72b9ce7dfaf7a6431f3d80f6f9d7a433ad jdt/org.osgi.service.component_1.5.1.202212101352.jar +43c7c870710e363405d422da653cce0d798a4537f76e4930f79bceadd3a55345 jdt/org.osgi.service.prefs_1.1.2.202109301733.jar +208819c7c71690c15a6bb8b187474e7f9d0147946b680182a62b9f222ae014ec jdt/org.osgi.util.function_1.2.0.202109301733.jar +7053c57e7d7d88fec6b90979a3af125e1d2bb847268a328a2f1ed65ad0a4c185 jdt/org.osgi.util.promise_1.3.0.202212101352.jar +7e1446499b359675d8aabe6af2de86b85e72ae8a2a932c0b40d0e5ba57097439 jdt/slf4j.api_2.0.20.jar +3970dab20b4deb8380ed9656392f289f3f3c2ee882d8c3942f1b5b486284620d jdt/wrapped.com.jetbrains.intellij.java.java-decompiler-engine_253.29346.240.jar +65d87386b7e926c0b6fb330a8d2eb6a987f5949634f7574731c0a3c2329d5629 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-emoji_0.64.8.jar +7a350f43f858da8f4a230552607ced119aeaf97484ca11ce4cd792e1e41e5bf7 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-gfm-strikethrough_0.64.8.jar +7a424dae7df0ee86200a08e163d9d1d8f63cd7a1a0c980b84ccbec029571fb4e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-ins_0.64.8.jar +75a2e7168145f799a039832708aa0bc277a954c4a367f58711ccd6b9b7e7d04e jdt/wrapped.com.vladsch.flexmark.flexmark-ext-superscript_0.64.8.jar +dbe6355ea9e04731d534d3d4151870f83684c7a44c87d541a2c53ef5ee2c4fa4 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-tables_0.64.8.jar +91ae2f77e832d20607fad5b7f407d438ca3f16ac032b4f14956e4e7eb3a60931 jdt/wrapped.com.vladsch.flexmark.flexmark-ext-wikilink_0.64.8.jar +0b650a4b47d29ffe0a08802c5d58e2270f733ce5443420c0e8904cfa1605964f jdt/wrapped.com.vladsch.flexmark.flexmark-html2md-converter_0.64.8.jar +74bab2d767f48f930471c9a72a0c3b89e9073549ad3754e6b7f83f09585463bc jdt/wrapped.com.vladsch.flexmark.flexmark-jira-converter_0.64.8.jar +1c7d205afeef618760a5fa907d579835df625ee258cbebd49813f14c2254a535 jdt/wrapped.com.vladsch.flexmark.flexmark-util-ast_0.64.8.jar +9179310212700753a4ecdc7aa057a04591b9cfdb089787a248e8269c34740310 jdt/wrapped.com.vladsch.flexmark.flexmark-util-builder_0.64.8.jar +796b9acec9af0fa290725a14be8a3fc1d6fe7a9cb518a13f67124b5172a3485b jdt/wrapped.com.vladsch.flexmark.flexmark-util-collection_0.64.8.jar +20458c20bc72808e1718ff8f1652272bbb32f62ddaecb2e9a0ddc84d77fd7283 jdt/wrapped.com.vladsch.flexmark.flexmark-util-data_0.64.8.jar +b59fbc922e3604442ff6aae1c6b83220245ba4c3f89d38870bc28c982b69d01d jdt/wrapped.com.vladsch.flexmark.flexmark-util-dependency_0.64.8.jar +b6073ee177aa2476d64787f9ec813fdb881943d402c195df0631ba672bfb1311 jdt/wrapped.com.vladsch.flexmark.flexmark-util-format_0.64.8.jar +c892e6cdbba715c55462decbe1c83cec432548cf2877cfd0715975c44dfab55c jdt/wrapped.com.vladsch.flexmark.flexmark-util-html_0.64.8.jar +1726c33ee95f77cfadc58c8ad27949ed72653f4045fbc399aff62827b44c7fcd jdt/wrapped.com.vladsch.flexmark.flexmark-util-misc_0.64.8.jar +cd0f9212530aa73f67de94d080018b2c3a3a55d41050d3abb4be8afb69989be0 jdt/wrapped.com.vladsch.flexmark.flexmark-util-options_0.64.8.jar +340167af15da00e3ae5fb89d6b44751abfd648951bb6d2e05c48287d9f9e70c9 jdt/wrapped.com.vladsch.flexmark.flexmark-util-sequence_0.64.8.jar +a8fad4a006411d020624c7539e6145e97920067844acddc10b019a178a680948 jdt/wrapped.com.vladsch.flexmark.flexmark-util-visitor_0.64.8.jar +792c13bc5536704fc0bfba9120e5bac712843a7ae6da6104b1392b382feaf63d jdt/wrapped.com.vladsch.flexmark.flexmark-util_0.64.8.jar +a2ec63056bcfe8c4babc62f13f5f49b502fccccf7c011b5d4ba7dd93f4d09393 jdt/wrapped.com.vladsch.flexmark.flexmark_0.64.8.jar +482b6aade7b0320e28db530a22f1382b3d30addf26a5ffd55a347f5c32f6dfc2 jdt/wrapped.org.jetbrains.annotations_24.0.1.jar +0addec670fedcd3f113c5c8091d783280d23f75e3acb841b61a9cdb079376a08 maven/aopalliance-1.0.jar +876eab6a83daecad5ca67eb9fcabb063c97b5aeb8cf1fca7a989ecde17522051 maven/asm-9.8.jar +d3d530d0f28fd0fbbffe2b0b338f70e8cb96f1605579e2e3abd4db29cac24e69 maven/commons-cli-1.9.0.jar +ba005f304cef92a3dede24a38ad5ac9b8afccf0d8f75839d6c1338634cf7f6e4 maven/commons-codec-1.18.0.jar +6661d5335090a5fc61dd869d2095bc6c1e2156e3aa47a6e4ababdf64c99a7889 maven/error_prone_annotations-2.38.0.jar +cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb maven/failureaccess-1.0.3.jar +94855942d4992f112946d3de1c334e709237b8126d8130bf07807c018a4a2120 maven/gson-2.13.1.jar +f3d7f57f67fd622f4d468dfdd692b3a5e3909246c28017ac3263405f0fe617ed maven/guava-33.4.8-jre.jar +142ad4475e19524d2fe3ac995b3f7cbc962fc726f2edb9dbdccc61feab9b2bf9 maven/guice-5.1.0-classes.jar +c8bc7e1c51a6d4ce72f40d2ebbabf1c4b68bfe76e732104b04381b493478e9d6 maven/httpclient-4.5.14.jar +6c9b3dd142a09dc468e23ad39aad6f75a0f2b85125104469f026e52a474e464f maven/httpcore-4.4.16.jar +0b7b8b003a90ea491579b62f5118828e45112914c65589b00faa49d6ec785839 maven/jansi-2.4.2.jar +e04ba5195bcd555dc95650f7cc614d151e4bcd52d29a10b8aa2197f3ab89ab9b maven/javax.annotation-api-1.3.2.jar +91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff maven/javax.inject-1.jar +ab57ca8fd223772c17365d121f59e94ecbf0ae59d08c03a3cb5b81071c019195 maven/jcl-over-slf4j-1.7.36.jar +1fad6e6be7557781e4d33729d49ae1cdc8fdda6fe477bb0cc68ce351eafdfbab maven/jspecify-1.0.0.jar +0bfd1c121f907642344311d81e4dfd93eba6c24dc712cc0aeae273eb22833022 maven/maven-artifact-3.9.11.jar +5b1de1560ec19c4c69a655f986455888f3a0bcc3c160cac1f1edb87de3825f77 maven/maven-builder-support-3.9.11.jar +b0e2865fc808fd9fb3bee95bf37c0609eeb20c59bf1e6b958b37b365ee11f52e maven/maven-compat-3.9.11.jar +93d3c0523dda6c86c6620d9e692e7df39d0e2e4e4cd860061a8c0d274c2ad185 maven/maven-core-3.9.11.jar +7e15e05035331d0a7e0fe56d7354b2b30e15c0a8c60863cbf28df25190e197db maven/maven-embedder-3.9.11.jar +163aa596f976264384efeb50b4cb8a806086fd64a9b755ad1f69195fed3998ad maven/maven-model-3.9.11.jar +c4c72accdcd848332c1a37995610a6a369d49fdaabc43e85f0022a5b77de3ba7 maven/maven-model-builder-3.9.11.jar +8e985a56412564cf687040efed8bcd3a63eac2202a824bb01e1d5ece2b87458f maven/maven-plugin-api-3.9.11.jar +f8655f5f0060e0032944f2a23688d186e140d2184749bcfa1927f83cd6a8b1a2 maven/maven-repository-metadata-3.9.11.jar +c0ce14849298720dcf3b09fe9e263b3c576d2d6cd43c61f8aa9d6117e04e5e47 maven/maven-resolver-api-1.9.24.jar +18b7d557c41379db8b157ba95263c676f52c293f5416b67bd1db083e99b30383 maven/maven-resolver-connector-basic-1.9.24.jar +3cd88edda00c46242cdbe57aefb47703519d176d9e8a500333de15faf41b0e1d maven/maven-resolver-impl-1.9.24.jar +a222b0a5ca4c145e8e0f45207a23bcce18bdbe1f1c980e4e9fb332591ee8c91a maven/maven-resolver-named-locks-1.9.24.jar +b5d6e52ae1ecf1cb62ede2234234ba3093149524066488e84415187ce478c582 maven/maven-resolver-provider-3.9.11.jar +e3153906e93333c106ee18babb72ba7984ccb15e9116ea08f88c71b8d9387e01 maven/maven-resolver-spi-1.9.24.jar +b09268d41e1dfcae15a829d45a512ca29d62b727421d3bd6383ac6252e5e960f maven/maven-resolver-transport-file-1.9.24.jar +a0de339a8dd3473d9abaa1cf974507f38783e26af31d36a90c1e59cc8714ded0 maven/maven-resolver-transport-http-1.9.24.jar +78ed57fa2123a2d84fad7a0b15ca73b89df47c10294d02d6f8121c4d0e5050cb maven/maven-resolver-transport-wagon-1.9.24.jar +aeb7e776a8c47b1c317a2581f299e2525b044cb8abde31164e970bbbefab12b1 maven/maven-resolver-util-1.9.24.jar +026718d47d19e360d978857bc63d3dc065884fc2c801563d8a2a77b5ab74ccf8 maven/maven-settings-3.9.11.jar +33c96e4ca5a15b7c93180f6b2f8deb15f511dfc15dda0d2b671c968b22108972 maven/maven-settings-builder-3.9.11.jar +b613357e1bad4dfc1dead801691c9460f9585fe7c6b466bc25186212d7d18487 maven/maven-shared-utils-3.4.2.jar +76d9fa894fa61d1492877ca599cb5ab28ca4573d5f389d6c12a8233b995335f9 maven/maven-slf4j-provider-3.9.11.jar +1cbd7a965a5e2a9ea823bab311962a4e5aa5c240705bdbad5a52b40ffdfa1004 maven/org.eclipse.sisu.inject-0.9.0.M4.jar +b90579bc652eac7331436e0a25533fce14130b9c6e015f2dd3a3d4bb07e942b7 maven/org.eclipse.sisu.plexus-0.9.0.M4.jar +9a7f1b5c5a9effd61eadfd8731452a2f76a8e79111fac391ef75ea801bea203a maven/plexus-cipher-2.0.jar +50edb93c73786e62822b4fe1336e22880fdf147191373cf5c911370e16748fcf maven/plexus-component-annotations-2.2.0.jar +ab2a8715570438a2e4164d85ad3e8d489eabc38ea5093c2eb8ab7f58403535b5 maven/plexus-interpolation-1.28.jar +873139960c4c780176dda580b003a2c4bf82188bdce5bb99234e224ef7acfceb maven/plexus-sec-dispatcher-2.0.jar +27ef130e32c236090e408fb5498d94cb9ea26d14070fb1c8985d607b62d098d1 maven/plexus-utils-3.6.0.jar +d3ef575e3e4979678dc01bf1dcce51021493b4d11fb7f1be8ad982877c16a1c0 maven/slf4j-api-1.7.36.jar +afc9216fa97b78dad227b4a8d4d67b9897bf113a57f80598d62993841113e103 maven/wagon-file-3.5.3.jar +d2b6e48c9fcbe579e1858c622d14464011ff265fa6e28e794004a6882154a509 maven/wagon-http-3.5.3.jar +8e7da766f55164fde8779aaaa125832506c2848cab4876b5305138873e28037f maven/wagon-http-shared-3.5.3.jar +5e72000338945ed3e96f8e4f578d1d0672e1af7e19c0e9014197ae5b31af3ef4 maven/wagon-provider-api-3.5.3.jar diff --git a/plugins/kotlin-bridge/build.py b/plugins/kotlin-bridge/build.py new file mode 100644 index 00000000..538b749c --- /dev/null +++ b/plugins/kotlin-bridge/build.py @@ -0,0 +1,32 @@ +"""Build with JDT LS 1.63.0 + Maven 3.9.11. Checksums pin every compiler dependency.""" +import hashlib +import os +import pathlib +import subprocess +import sys +import tempfile +import zipfile + +root = pathlib.Path(__file__).resolve().parent +jdt, maven = map(lambda p: pathlib.Path(p).resolve(), sys.argv[1:3]) +jars = sorted((jdt / 'plugins').glob('*.jar')) + sorted((maven / 'lib').glob('*.jar')) +actual = ''.join(f'{hashlib.sha256(p.read_bytes()).hexdigest()} {"jdt/" if p.parent.name == "plugins" else "maven/"}{p.name}\n' for p in jars) +if '--record-dependencies' in sys.argv: + (root / 'build-dependencies.sha256').write_text(actual) +if (root / 'build-dependencies.sha256').read_text() != actual: + sys.exit('Build dependencies differ from build-dependencies.sha256') +with tempfile.TemporaryDirectory() as output: + for source, target, classpath in [('src', '-21', jars[:len(list((jdt / 'plugins').glob('*.jar')))]), ('maven', '-8', jars)]: + subprocess.run(['java', '-cp', str(jdt / 'plugins/*'), 'org.eclipse.jdt.internal.compiler.batch.Main', target, '-proc:none', '-classpath', os.pathsep.join(map(str, classpath)), '-d', output, *map(str, sorted((root/source).rglob('*.java')))], check=True) + files = {'META-INF/MANIFEST.MF': root/'MANIFEST.MF', 'plugin.xml': root/'plugin.xml', 'META-INF/plexus/components.xml': root/'components.xml'} + files.update({p.relative_to(output).as_posix(): p for p in pathlib.Path(output).rglob('*.class')}) + archive = pathlib.Path(output)/'bundle.jar' + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as jar: + for name, path in sorted(files.items()): + data = path.read_bytes() + if name == 'META-INF/MANIFEST.MF': + data = data.rstrip(b'\r\n') + b'\n\n' + jar.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data) + staged = root/'openidea-kotlin-bridge.jar.pending' + staged.write_bytes(archive.read_bytes()) + staged.replace(root/'openidea-kotlin-bridge.jar') diff --git a/plugins/kotlin-bridge/components.xml b/plugins/kotlin-bridge/components.xml new file mode 100644 index 00000000..156cd7d3 --- /dev/null +++ b/plugins/kotlin-bridge/components.xml @@ -0,0 +1,5 @@ + + org.apache.maven.AbstractMavenLifecycleParticipant + openidea-kotlin-output + ai.openidea.kotlin.IsolatedOutput + diff --git a/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java b/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java new file mode 100644 index 00000000..8da37994 --- /dev/null +++ b/plugins/kotlin-bridge/maven/ai/openidea/kotlin/IsolatedOutput.java @@ -0,0 +1,177 @@ +package ai.openidea.kotlin; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import org.apache.maven.AbstractMavenLifecycleParticipant; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.MavenExecutionException; +import org.apache.maven.execution.*; +import java.lang.reflect.*; +import org.apache.maven.model.*; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; + +/** Loaded only by the private bridge build, before lifecycle plans are calculated. */ +public final class IsolatedOutput extends AbstractMavenLifecycleParticipant { + private final Map outputs = new LinkedHashMap<>(); + private File staging; + private final Map> kotlin = new LinkedHashMap<>(); + private final Set sources = new HashSet<>(); + + @Override public void afterProjectsRead(MavenSession session) throws MavenExecutionException { + String destination = session.getUserProperties().getProperty("openidea.kotlin.staging"); + if (destination == null) throw new MavenExecutionException("Missing Kotlin staging directory", (File) null); + staging = new File(destination); + int index = 0; + for (MavenProject project : session.getProjects()) { + File build = new File(staging, Integer.toString(index++)); + File main = new File(build, "classes"); + File test = new File(build, "test-classes"); + project.getBuild().setDirectory(build.getAbsolutePath()); + project.getBuild().setOutputDirectory(main.getAbsolutePath()); + project.getBuild().setTestOutputDirectory(test.getAbsolutePath()); + for (Plugin plugin : project.getBuildPlugins()) { + if ("kotlin-maven-plugin".equals(plugin.getArtifactId())) { + plugin.setConfiguration(redirect(plugin.getConfiguration(), build, main, test)); + for (PluginExecution execution : plugin.getExecutions()) + execution.setConfiguration(redirect(execution.getConfiguration(), build, main, test)); + } else if ("maven-compiler-plugin".equals(plugin.getArtifactId())) { + plugin.setConfiguration(compiler(plugin.getConfiguration())); + for (PluginExecution execution : plugin.getExecutions()) + execution.setConfiguration(compiler(execution.getConfiguration())); + } + } + outputs.put(project, main); + Map goals = new LinkedHashMap<>(); + for (Plugin plugin : project.getBuildPlugins()) { + if (!isKotlin(plugin)) continue; + for (PluginExecution execution : plugin.getExecutions()) { + if ("none".equals(execution.getPhase())) continue; + for (String goal : execution.getGoals()) + if ("compile".equals(goal) || "test-compile".equals(goal)) + goals.put(execution.getId() + ":" + goal, "skipped"); + } + } + kotlin.put(project, goals); + if (hasKotlin(project.getBasedir().toPath(), project.getBasedir().toPath(), session.getProjects())) + sources.add(project); + } + final ExecutionListener previous = session.getRequest().getExecutionListener(); + // Preserve Maven's logger and any listener installed by another extension. + session.getRequest().setExecutionListener((ExecutionListener) Proxy.newProxyInstance( + ExecutionListener.class.getClassLoader(), new Class[] { ExecutionListener.class }, + (proxy, method, args) -> { + if (args != null && args.length == 1 && args[0] instanceof ExecutionEvent) { + ExecutionEvent event = (ExecutionEvent) args[0]; + if (Arrays.asList("mojoStarted", "mojoSucceeded", "mojoFailed", "mojoSkipped").contains(method.getName()) + && event.getMojoExecution() != null && isKotlin(event.getMojoExecution().getPlugin())) { + String goal = event.getMojoExecution().getGoal(); + if ("compile".equals(goal) || "test-compile".equals(goal)) { + String status = method.getName().equals("mojoSucceeded") ? "success" : + method.getName().equals("mojoFailed") ? "failed" : "skipped"; + Map goals = kotlin.get(event.getProject()); + if (goals != null) goals.put(event.getMojoExecution().getExecutionId() + ":" + goal, status); + } + } + } + if (previous == null) return null; + try { return method.invoke(previous, args); } + catch (InvocationTargetException e) { throw e.getCause(); } + })); + } + + private static boolean isKotlin(Plugin plugin) { + return "org.jetbrains.kotlin".equals(plugin.getGroupId()) && "kotlin-maven-plugin".equals(plugin.getArtifactId()); + } + private static boolean hasKotlin(Path root, Path directory, List projects) throws MavenExecutionException { + if (!directory.equals(root)) { + String name = directory.getFileName().toString(); + if (Arrays.asList("target", "build", "out", "bin", ".git", "node_modules", ".openidea").contains(name)) return false; + for (MavenProject project : projects) if (project.getBasedir().toPath().equals(directory)) return false; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) return false; + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path path : entries) { + if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && path.toString().endsWith(".kt")) return true; + if (hasKotlin(root, path, projects)) return true; + } + return false; + } catch (IOException e) { throw new MavenExecutionException("Cannot inspect Kotlin sources", e); } + } + + @Override public void afterSessionEnd(MavenSession session) throws MavenExecutionException { + if (staging == null) return; + List modules = new ArrayList<>(); + for (Map.Entry entry : outputs.entrySet()) { + MavenProject project = entry.getKey(); + File main = entry.getValue(); + List dependencies = new ArrayList<>(); + // Maven has applied dependency management, mediation, scopes, optional + // propagation and exclusions. Do not reconstruct that graph from POMs. + for (Artifact dependency : project.getArtifacts()) { + dependencies.add("{\"key\":" + quote(dependency.getGroupId() + ":" + dependency.getArtifactId() + ":" + dependency.getVersion()) + + ",\"scope\":" + quote(dependency.getScope() == null ? "compile" : dependency.getScope()) + + ",\"optional\":" + dependency.isOptional() + ",\"type\":" + quote(dependency.getType()) + + ",\"classifier\":" + quote(dependency.getClassifier()) + "}"); + } + List goals = new ArrayList<>(); + for (Map.Entry goal : kotlin.get(project).entrySet()) + goals.add("{\"execution\":" + quote(goal.getKey()) + ",\"status\":" + quote(goal.getValue()) + "}"); + modules.add("{\"key\":" + quote(project.getGroupId() + ":" + project.getArtifactId() + ":" + project.getVersion()) + + ",\"projectUri\":" + quote(project.getBasedir().toURI().toString()) + + ",\"output\":" + quote(main.getAbsolutePath()) + + ",\"testOutput\":" + quote(project.getBuild().getTestOutputDirectory()) + + ",\"hasKotlin\":" + sources.contains(project) + + ",\"kotlinGoals\":[" + String.join(",", goals) + "]" + + ",\"dependencies\":[" + String.join(",", dependencies) + "]}"); + } + try { + Files.createDirectories(staging.toPath()); + Files.write(new File(staging.getParentFile(), "modules.json").toPath(), + ("[" + String.join(",", modules) + "]").getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new MavenExecutionException("Cannot write Kotlin module manifest", e); + } + } + + private static Xpp3Dom config(Object value) { + return value instanceof Xpp3Dom ? new Xpp3Dom((Xpp3Dom) value) : new Xpp3Dom("configuration"); + } + private static void set(Xpp3Dom config, String name, File path) { + Xpp3Dom child = config.getChild(name); + if (child == null) { child = new Xpp3Dom(name); config.addChild(child); } + child.setValue(path.getAbsolutePath()); + } + private static Xpp3Dom redirect(Object value, File build, File main, File test) { + Xpp3Dom config = config(value); + set(config, "output", main); + set(config, "testOutput", test); + set(config, "incrementalCachesRoot", new File(build, "kotlin-ic")); + Xpp3Dom incremental = config.getChild("myIncremental"); + if (incremental == null) { incremental = new Xpp3Dom("myIncremental"); config.addChild(incremental); } + incremental.setValue("false"); + return config; + } + private static Xpp3Dom compiler(Object value) { + Xpp3Dom config = config(value); + // compile and testCompile share the parameter name outputDirectory but + // have different defaults. Let each mojo read the redirected build model. + for (int i = config.getChildCount() - 1; i >= 0; i--) { + String name = config.getChild(i).getName(); + if ("outputDirectory".equals(name) || "testOutputDirectory".equals(name)) config.removeChild(i); + } + return config; + } + private static String quote(String text) { + if (text == null) return "null"; + StringBuilder out = new StringBuilder("\""); + for (char c : text.toCharArray()) { + if (c == '\\' || c == '"') out.append('\\').append(c); + else if (c < 32) out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } + return out.append('"').toString(); + } +} diff --git a/plugins/kotlin-bridge/openidea-kotlin-bridge.jar b/plugins/kotlin-bridge/openidea-kotlin-bridge.jar new file mode 100644 index 00000000..8250780f Binary files /dev/null and b/plugins/kotlin-bridge/openidea-kotlin-bridge.jar differ diff --git a/plugins/kotlin-bridge/plugin.xml b/plugins/kotlin-bridge/plugin.xml new file mode 100644 index 00000000..a0ac23ef --- /dev/null +++ b/plugins/kotlin-bridge/plugin.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java b/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java new file mode 100644 index 00000000..8f92e7ef --- /dev/null +++ b/plugins/kotlin-bridge/src/ai/openidea/kotlin/KotlinLibraries.java @@ -0,0 +1,94 @@ +package ai.openidea.kotlin; + +import com.google.gson.Gson; +import java.io.File; +import java.net.URI; +import java.util.*; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.*; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jdt.core.*; +import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler; + +/** Owns only marked binary entries; Maven and user entries retain their identities. */ +public final class KotlinLibraries implements IDelegateCommandHandler { + private static final String OWNER = "ai.openidea.kotlin"; + private static final Object LOCK = new Object(); + private static List desired = List.of(); + private static boolean listening; + private static final Job reconcileJob = new Job("Kotlin binary libraries") { + @Override protected IStatus run(IProgressMonitor monitor) { + synchronized (LOCK) { + try { + reconcile(monitor); + return Status.OK_STATUS; + } catch (CoreException e) { + return e.getStatus(); + } + } + } + }; + private static class Library { String path; boolean test; } + private static class ProjectLibraries { String projectUri; List libraries; } + + @Override public Object executeCommand(String command, List arguments, IProgressMonitor monitor) throws Exception { + if (!"openidea.kotlin.replace".equals(command) || arguments.size() != 1) + throw new IllegalArgumentException("Expected one Kotlin library snapshot"); + Gson gson = new Gson(); + ProjectLibraries[] snapshot = gson.fromJson(gson.toJson(arguments.get(0)), ProjectLibraries[].class); + for (ProjectLibraries project : snapshot) { + new File(URI.create(project.projectUri)); + for (Library library : project.libraries) { + if (!new File(library.path).isAbsolute() || !new File(library.path).isDirectory()) + throw new IllegalArgumentException("Kotlin library must be a published directory: " + library.path); + } + } + synchronized (LOCK) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + desired = List.of(snapshot); + if (!listening) { + JavaCore.addElementChangedListener(event -> reconcileJob.schedule(200), ElementChangedEvent.POST_CHANGE); + listening = true; + } + return reconcile(monitor); + } + } + + private static File canonical(URI uri) { + File file = new File(uri); + try { return file.getCanonicalFile(); } + catch (java.io.IOException e) { return file.getAbsoluteFile(); } + } + + private static int reconcile(IProgressMonitor monitor) throws CoreException { + int changed = 0; + for (IJavaProject project : JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()) { + if (!project.getProject().isAccessible()) continue; + List entries = new ArrayList<>(); + IClasspathEntry[] original = project.getRawClasspath(); + for (IClasspathEntry entry : original) { + boolean owned = entry.getEntryKind() == IClasspathEntry.CPE_LIBRARY && + Arrays.stream(entry.getExtraAttributes()).anyMatch(a -> OWNER.equals(a.getName()) && "true".equals(a.getValue())); + if (!owned) entries.add(entry); + } + URI location = project.getProject().getLocationURI(); + for (ProjectLibraries item : desired) { + if (location == null || !canonical(location).equals(canonical(URI.create(item.projectUri)))) continue; + for (Library library : item.libraries) { + List attributes = new ArrayList<>(); + attributes.add(JavaCore.newClasspathAttribute(OWNER, "true")); + if (library.test) attributes.add(JavaCore.newClasspathAttribute(IClasspathAttribute.TEST, "true")); + entries.add(JavaCore.newLibraryEntry(Path.fromOSString(library.path), null, null, + new IAccessRule[0], attributes.toArray(IClasspathAttribute[]::new), false)); + } + } + IClasspathEntry[] updated = entries.toArray(IClasspathEntry[]::new); + if (!Arrays.equals(original, updated)) { + if (monitor.isCanceled()) throw new OperationCanceledException(); + project.setRawClasspath(updated, monitor); + changed++; + } + } + return changed; + } +} diff --git a/plugins/kotlin-bridge/tests/verify.py b/plugins/kotlin-bridge/tests/verify.py new file mode 100644 index 00000000..3d6c8986 --- /dev/null +++ b/plugins/kotlin-bridge/tests/verify.py @@ -0,0 +1,275 @@ +"""Real Maven + JDT LS regression: python3 verify.py JDT_HOME (Java 25; Maven uses JAVA_HOME).""" +import json +import os +import pathlib +import platform +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import xml.etree.ElementTree as ET + +home = pathlib.Path(sys.argv[1]).resolve() +bundle = pathlib.Path(__file__).resolve().parents[1] / 'openidea-kotlin-bridge.jar' +work = pathlib.Path(tempfile.mkdtemp(prefix='openidea-kotlin-bridge-', dir=os.environ.get('TMPDIR'))).resolve() +copied_bundle = work/'openidea-kotlin-bridge.jar' +shutil.copyfile(bundle, copied_bundle) +bundle = copied_bundle +projects = work / 'projects' +single = projects / 'single' +multi = projects / 'multi' +lib = multi / 'channel-lib' +app = multi / 'app' +for directory in [single, lib, app]: + (directory / 'src/main/java/probe').mkdir(parents=True) +plugin = '''org.jetbrains.kotlinkotlin-maven-plugin2.0.10kotlin-compileprocess-sourcescompile1.8src/main/javaorg.apache.maven.pluginsmaven-compiler-plugin3.13.0''' +plugin = plugin.replace('', 'kotlin-testprocess-test-sourcestest-compile1.8src/test/java') +stdlib = 'org.jetbrains.kotlinkotlin-stdlib2.0.10' +properties = '88' +prefix = '4.0.0' +(single / 'pom.xml').write_text(prefix + 'probesingle1' + properties + stdlib + plugin + '') +(multi / 'pom.xml').write_text(prefix + 'probereactor1pom' + properties + 'channel-libapp') +parent = 'probereactor1' +(lib / 'pom.xml').write_text(prefix + parent + 'channel-lib' + stdlib + plugin + '') +(app / 'pom.xml').write_text(prefix + parent + 'appprobechannel-lib1') +source = 'package probe;\npublic class Caller {\n public Channel channel() {\n return Channel.SMS;\n }\n}\n' +for directory in [single, app]: + (directory / 'src/main/java/probe/Caller.java').write_text(source) +for directory in [single, lib]: + (directory / 'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + +(single/'src/test/java/probe').mkdir(parents=True) +test_source = source.replace('Caller', 'TestCaller').replace('Channel', 'TestChannel') +(single/'src/test/java/probe/TestCaller.java').write_text(test_source) +(single/'src/test/java/probe/TestChannel.kt').write_text('package probe\nenum class TestChannel { SMS }\n') + +config = work / 'config' +config.mkdir() +base = {'Darwin': 'config_mac', 'Windows': 'config_win'}.get(platform.system(), 'config_linux') +names = [base + '_arm', base] if platform.machine().lower() in ('arm64', 'aarch64') else [base] +shutil.copyfile(next(home / n / 'config.ini' for n in names if (home/n/'config.ini').exists()), config/'config.ini') +proc = subprocess.Popen(['java', '-Declipse.application=org.eclipse.jdt.ls.core.id1', '-Dosgi.bundles.defaultStartLevel=4', '-Declipse.product=org.eclipse.jdt.ls.core.product', '-Xmx1G', '--add-modules=ALL-SYSTEM', '--add-opens', 'java.base/java.util=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '-jar', str(next((home/'plugins').glob('org.eclipse.equinox.launcher_*.jar'))), '-configuration', str(config), '-data', str(work/'metadata')], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=(work/'stderr.log').open('w')) +messages = queue.Queue() +lock = threading.Lock() +transcript = [] +seq = 0 +def send(message): + data = json.dumps({'jsonrpc': '2.0', **message}).encode() + with lock: + proc.stdin.write(f'Content-Length: {len(data)}\r\n\r\n'.encode() + data) + proc.stdin.flush() +def read(): + while True: + headers = {} + while True: + line = proc.stdout.readline() + if not line: return + if line == b'\r\n': break + key, value = line.decode().split(':', 1) + headers[key.lower()] = value.strip() + message = json.loads(proc.stdout.read(int(headers['content-length']))) + if 'method' in message and 'id' in message: + send({'id':message['id'], 'result': [] if message['method']=='workspace/configuration' else None}) + else: + messages.put(message) +threading.Thread(target=read, daemon=True).start() +def request(method, params): + global seq + seq += 1 + send({'id':seq, 'method':method, 'params':params}) + deadline = time.monotonic() + 240 + while time.monotonic() < deadline: + message = messages.get(timeout=max(1, deadline-time.monotonic())) + transcript.append(message) + if message.get('id') == seq: + if 'error' in message: raise RuntimeError(message['error']) + return message.get('result') + raise TimeoutError(method) +def command(snapshot): + response = request('workspace/executeCommand', {'command':'openidea.kotlin.replace', 'arguments':[snapshot]}) + assert isinstance(response, (int, float)), response + return response +def build(directory, revision, expected=True): + staging = work / revision + result = subprocess.run(['mvn', '-B', '-fae', '-Dstyle.color=never', f'-Dmaven.ext.class.path={bundle}', f'-Dopenidea.kotlin.staging={staging}/staging', 'test-compile'], cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (work/f'{revision}.log').write_bytes(result.stdout) + modules = json.loads((staging/'modules.json').read_text()) + eligible = all(m['kotlinGoals'] and all(g['status'] == 'success' for g in m['kotlinGoals']) for m in modules if m['hasKotlin']) + assert eligible == expected, (modules, result.stdout.decode()) + evidence[revision + '-build'] = {'exitCode':result.returncode, 'modules':modules, 'eligible':eligible} + return modules +def library(module, revision, test=False, name="Channel"): + directory = work/'published'/revision + directory.mkdir(parents=True) + output = module['testOutput' if test else 'output'] + for file in pathlib.Path(output).rglob('TestChannel*.class' if test else name+'*.class'): + destination = directory/file.relative_to(output) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(file, destination) + assert list(directory.rglob('*.class')), module + return {'path':str(directory), 'test':test} +def state(directory, label, expected, test=False): + file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java') + deadline = time.monotonic() + 30 + while True: + time.sleep(0.25) + request('workspace/symbol', {'query':'Caller'}) + reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==file.as_uri()] + errors = [d for d in reports[-1]['diagnostics'] if d.get('severity')==1] if reports else None + if errors is not None and ((errors == []) == expected): break + if time.monotonic() > deadline: raise AssertionError((label, errors)) + completion = request('textDocument/completion', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':3,'character':27 if test else 23}}) + items = completion.get('items', []) if isinstance(completion, dict) else completion or [] + labels = [item['label'] for item in items] + definition = request('textDocument/definition', {'textDocument':{'uri':file.as_uri()}, 'position':{'line':2,'character':12}}) + result = {'errors':errors, 'completion':labels, 'definition':definition} + evidence[label] = result + assert errors is not None, (label, "no published diagnostics") + assert (errors == []) == expected, (label, errors) + assert any(label.startswith('SMS') for label in labels) == expected, (label, labels) + assert bool(definition) == expected, (label, definition) + if expected: + location = definition[0] + assert location.get('uri', location.get('targetUri', '')).startswith('jdt:'), definition + return result + +evidence = {'work':str(work)} +try: + init = request('initialize', {'processId':None, 'rootUri':projects.as_uri(), 'workspaceFolders':[{'uri':projects.as_uri(), 'name':'projects'}], 'capabilities':{'textDocument':{'completion':{'completionItem':{'snippetSupport':True}}, 'definition':{'linkSupport':True}}}, 'initializationOptions':{'bundles':[str(bundle)], 'extendedClientCapabilities':{'classFileContentsSupport':True}, 'settings':{'java':{'import':{'gradle':{'enabled':False}, 'maven':{'enabled':True}}, 'configuration':{'updateBuildConfiguration':'automatic'}}}}}) + send({'method':'initialized', 'params':{}}) + request('java/buildWorkspace', True) + for directory, test in [(single,False), (app,False), (single,True)]: + file = directory/('src/test/java/probe/TestCaller.java' if test else 'src/main/java/probe/Caller.java') + send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':file.as_uri(),'languageId':'java','version':1,'text':test_source if test else source}}}) + # Allow reconcile jobs to publish their first marker batch. + time.sleep(2) + state(single, 'single-before', False) + state(app, 'multi-before', False) + state(single, 'test-before', False, True) + def raw(directory): + entries = ET.parse(directory/'.classpath').getroot() + return [ET.tostring(e).decode().strip() for e in entries if not any(a.get('name')=='ai.openidea.kotlin' for a in e.findall('./attributes/attribute'))] + before = {str(d):raw(d) for d in [single, lib, app]} + sm = build(single, 'single-build')[0] + assert not (pathlib.Path(sm['output'])/'probe/TestCaller.class').exists() + assert (pathlib.Path(sm['testOutput'])/'probe/TestCaller.class').is_file() + mm = build(multi, 'multi-build') + lm = next(m for m in mm if 'channel-lib' in m['key']) + sl, ml = library(sm, 'single'), library(lm, 'multi') + snapshot = [{'projectUri':single.as_uri(),'libraries':[sl, library(sm, 'single-test', True)]}, {'projectUri':lib.as_uri(),'libraries':[ml]}, {'projectUri':app.as_uri(),'libraries':[ml]}] + command(snapshot) + time.sleep(2) + state(single, 'single-after', True) + state(app, 'multi-after', True) + state(single, 'test-after', True, True) + assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'changed unowned entries' + # Simulate m2e removing the extra entries during a configuration update. + send({'method':'java/projectConfigurationsUpdate','params':{'identifiers':[{'uri':(d/'pom.xml').as_uri()} for d in [single,lib,app]]}}) + time.sleep(3) + state(single, 'single-reimport', True) + state(app, 'multi-reimport', True) + assert all(raw(d) == before[str(d)] for d in [single,lib,app]), 'reimport changed unowned entries' + # A saved API change must replace the immutable binary directory. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS, EMAIL }\n') + state(single, 'single-during-rebuild', True) + changed = build(single, 'single-changed')[0] + snapshot[0]['libraries'] = [library(changed, 'single-changed'), library(changed, 'single-test-changed', True)] + command(snapshot) + time.sleep(2) + changed_state = state(single, 'single-new-enum-member', True) + assert any(label.startswith('EMAIL') for label in changed_state['completion']) + # Failed Kotlin compilation keeps both main and test APIs attached. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { ??? }\n') + broken = build(single, 'single-broken', expected=False)[0] + assert any(g['status'] == 'failed' for g in broken['kotlinGoals']) + state(single, 'single-broken-retains-api', True) + state(single, 'single-broken-retains-test-api', True, True) + # A javac failure skips test Kotlin targets, so this whole snapshot is retained. + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Delivery { SMS }\n') + skipped = build(single, 'single-skipped-test', expected=False)[0] + assert any(g['execution'].endswith(':test-compile') and g['status'] == 'skipped' for g in skipped['kotlinGoals']) + assert list(pathlib.Path(skipped['output']).rglob('Delivery.class')) + state(single, 'single-skipped-test-retains-api', True) + # The reactor fixture has no test Kotlin goal. Publish despite downstream javac failure. + kt = lib/'src/main/java/probe/Channel.kt' + kt.write_text('package probe\nenum class Delivery { SMS }\n') + renamed = build(multi, 'multi-renamed') + renamed_lib = next(m for m in renamed if 'channel-lib' in m['key']) + new_library = library(renamed_lib, 'multi-renamed', name='Delivery') + assert not list(pathlib.Path(renamed_lib['output']).rglob('Channel.class')) + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [new_library] + command(snapshot) + state(app, 'multi-renamed', False) + kt.unlink() + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [] + command(snapshot) + state(app, 'multi-deleted', False) + kt.write_text('package probe\nenum class Channel { SMS }\n') + restored = next(m for m in build(multi, 'multi-restored') if 'channel-lib' in m['key']) + restored_library = library(restored, 'multi-restored') + snapshot[1]['libraries'] = snapshot[2]['libraries'] = [restored_library] + command(snapshot) + state(app, 'multi-restored', True) + (single/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + repaired = build(single, 'single-repaired')[0] + snapshot[0]['libraries'] = [library(repaired, 'single-repaired'), library(repaired, 'single-test-repaired', True)] + command(snapshot) + time.sleep(2) + state(single, 'single-repaired', True) + # No old API after explicit invalidation, and recovery is repeatable. + command([]) + time.sleep(2) + state(single, 'single-withdrawn', False) + state(app, 'multi-withdrawn', False) + state(single, 'test-withdrawn', False, True) + command(snapshot) + time.sleep(2) + state(single, 'single-recovered', True) + state(app, 'multi-recovered', True) + # Main sources must not gain visibility of test-only Kotlin libraries. + leak = single/'src/main/java/probe/MainLeak.java' + leak_source = test_source.replace('TestCaller', 'MainLeak') + leak.write_text(leak_source) + send({'method':'textDocument/didOpen','params':{'textDocument':{'uri':leak.as_uri(),'languageId':'java','version':1,'text':leak_source}}}) + deadline = time.monotonic() + 30 + while True: + time.sleep(0.25) + request('workspace/symbol', {'query':'MainLeak'}) + reports = [m['params'] for m in transcript if m.get('method')=='textDocument/publishDiagnostics' and m['params']['uri']==leak.as_uri()] + if reports: break + assert time.monotonic() < deadline, 'no diagnostics for main/test isolation' + evidence['main-test-isolation'] = reports[-1]['diagnostics'] + assert any(d.get('severity') == 1 for d in reports[-1]['diagnostics']), reports[-1] + # Compiler and JDT outputs must not share Kotlin classes. + assert not list(single.glob('target/classes/**/Channel.class')) + assert not list(lib.glob('target/classes/**/Channel.class')) + # -fae runs independent Kotlin modules and records upstream-skipped targets. + fail_reactor = work/'fail-at-end' + fail_reactor.mkdir() + (fail_reactor/'pom.xml').write_text(prefix + 'probefailure-reactor1pom' + properties + 'broken-javaindependentdownstream') + failure_parent = 'probefailure-reactor1' + for name in ['broken-java', 'independent', 'downstream']: + module = fail_reactor/name + (module/'src/main/java/probe').mkdir(parents=True) + dependency = 'probebroken-java1org.jetbrains.kotlinkotlin-stdlib2.0.10' if name == 'downstream' else stdlib + (module/'pom.xml').write_text(prefix + failure_parent + '' + name + '' + dependency + plugin + '') + if name == 'broken-java': + (module/'src/main/java/probe/Broken.java').write_text('package probe; public class Broken { Missing value; }') + else: + (module/'src/main/java/probe/Channel.kt').write_text('package probe\nenum class Channel { SMS }\n') + statuses = build(fail_reactor, 'fail-at-end-statuses', expected=False) + independent = next(m for m in statuses if ':independent:' in m['key']) + downstream = next(m for m in statuses if ':downstream:' in m['key']) + assert all(g['status'] == 'success' for g in independent['kotlinGoals']), independent + assert all(g['status'] == 'skipped' for g in downstream['kotlinGoals']), downstream + assert list(pathlib.Path(independent['output']).rglob('Channel.class')) + print(json.dumps(evidence, indent=2)) +finally: + (work/'evidence.json').write_text(json.dumps(evidence, indent=2)) + (work/'transcript.json').write_text(json.dumps(transcript, indent=2)) + proc.terminate() + proc.wait(timeout=20) + print('Evidence:', work, file=sys.stderr) diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 8204ccd5..7380fa33 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,9 @@ { + "Kotlin bridge requires a Maven build root": "Kotlin 桥接需要 Maven 构建根目录", + "Kotlin bridge bundle is unavailable": "Kotlin 桥接扩展不可用", + "Kotlin bridge command did not acknowledge its library snapshot": "Kotlin 桥接命令未确认类库更新", + "Maven compilation failed": "Maven 编译失败", + "Kotlin editor compilation failed": "Kotlin 编辑器编译失败", "Updating results…": "正在更新结果…", "Uploading files": "正在上传文件", "No files on the clipboard": "剪贴板中没有文件", diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index f67a384f..622079e9 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { create } from "zustand"; import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import type { NotificationService } from "./contributions"; @@ -189,5 +190,8 @@ export const notificationServiceImpl: NotificationService = { }; export function installNotifyEvents(): () => void { - return onIdeEvent("notify", (e) => void notify({ level: e.level, title: e.title, body: e.body ?? undefined })); + return onIdeEvent( + "notify", + (e) => void notify({ level: e.level, title: tx(e.title), body: e.body ? tx(e.body) : undefined }), + ); }