mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaeaec798c | ||
|
|
49f9c57bcc | ||
|
|
07609ef9c6 | ||
|
|
bf2427bd3a | ||
|
|
041e72106b | ||
|
|
aa2c24c648 | ||
|
|
3a87c697b2 | ||
|
|
87bf89d688 | ||
|
|
a3fb14fa86 | ||
|
|
f6d7facd82 | ||
|
|
ec5193f827 | ||
|
|
9bb44cbeb3 | ||
|
|
8d38aa4a9c | ||
|
|
683f8d8826 | ||
|
|
f19eacc336 | ||
|
|
d3e63d8ff0 | ||
|
|
5c0a5239fa | ||
|
|
f8d6f9df77 | ||
|
|
0c54266d03 | ||
|
|
1716f3e062 | ||
|
|
482241ebb9 | ||
|
|
7c72411dd7 | ||
|
|
376501a5fe | ||
|
|
21925b7f4b | ||
|
|
1e898367a5 | ||
|
|
7a58d9f253 | ||
|
|
ad80e903e3 | ||
|
|
9847fd2c01 | ||
|
|
fa26ba842c | ||
|
|
16033c481b |
No files matched your search
Generated
+40
-35
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,7 +4075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4090,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4099,7 +4099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4115,7 +4115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,7 +4169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4183,18 +4183,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4208,7 +4209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4253,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4271,7 +4272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4293,7 +4294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4309,7 +4310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4364,7 +4365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4373,6 +4374,7 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"lsp-types",
|
||||
"parking_lot",
|
||||
@@ -4388,7 +4390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4411,7 +4413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4429,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4442,7 +4444,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4459,7 +4461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4482,7 +4484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4501,7 +4503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4521,7 +4523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4534,7 +4536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4553,7 +4555,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4568,7 +4570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4581,7 +4583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4593,7 +4595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4614,7 +4616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4633,7 +4635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4655,7 +4657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4671,7 +4673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5806,6 +5808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
@@ -6141,7 +6144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"block2 0.6.2",
|
||||
@@ -6183,7 +6186,9 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-web-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.32"
|
||||
version = "0.1.33"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#build">Build</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
|
||||
both sides. Both are measured from the outside, the same way: see
|
||||
[`perf/compare`](perf/compare) to run it on your machine and projects.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
Native notebook execution tests are opt-in and need two distinct Python environments
|
||||
with `ipykernel` installed:
|
||||
|
||||
```sh
|
||||
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
|
||||
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
|
||||
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`plugins/lombok/lombok-1.18.48-openidea.1.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059).
|
||||
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
|
||||
notices are retained in [plugins/lombok/LICENSE](plugins/lombok/LICENSE) and inside the jar.
|
||||
Source tag, patch commits and build instructions: [plugins/lombok/README.md](plugins/lombok/README.md).
|
||||
|
||||
## JetBrains IntelliJ Platform icons
|
||||
|
||||
`src/ui/expui/**` contains SVG icons from the IntelliJ Platform "new UI" (expui)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Metadata fingerprints for successful Maven launch builds.
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LaunchFingerprint {
|
||||
version: u32,
|
||||
parameters: String,
|
||||
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
}
|
||||
|
||||
fn scan(
|
||||
path: &Path,
|
||||
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
ancestors: &mut HashSet<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let meta = match fs::metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
files.insert(path.to_path_buf(), None);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if meta.is_dir() {
|
||||
let canonical = dunce::canonicalize(path)?;
|
||||
if !ancestors.insert(canonical.clone()) {
|
||||
return Err(IdeError::internal("cyclic launch input directory"));
|
||||
}
|
||||
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
|
||||
files.insert(path.to_path_buf(), None);
|
||||
for entry in fs::read_dir(path)? {
|
||||
scan(&entry?.path(), files, ancestors, cancel)?;
|
||||
}
|
||||
ancestors.remove(&canonical);
|
||||
} else if meta.is_file() {
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
|
||||
} else {
|
||||
return Err(IdeError::internal("unsupported launch input file"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `None` means an input cannot be resolved safely: build normally without reuse.
|
||||
/// All filesystem work belongs on the caller's blocking thread.
|
||||
pub fn launch_fingerprint(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<LaunchFingerprint>> {
|
||||
let Some(m) = &model.maven else { return Ok(None) };
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if projects.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
key.starts_with("MAVEN_")
|
||||
|| key.starts_with("JAVA_")
|
||||
|| key.starts_with("JDK_")
|
||||
|| matches!(
|
||||
key.as_str(),
|
||||
"PATH" | "HOME" | "LANG" | "LC_ALL" | "_JAVA_OPTIONS" | "M2_HOME" | "MAVEN_HOME"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Explicit step values override the inherited environment just as they do in Command.
|
||||
for step in &plan.steps {
|
||||
environment.extend(step.env.iter().map(|(key, value)| (key.to_ascii_uppercase(), value.clone())));
|
||||
for dir in step.cwd.ancestors() {
|
||||
inputs.extend([dir.join(".mvn"), dir.join("mvnw"), dir.join("mvnw.cmd")]);
|
||||
if dir == root {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let program = process::program_path(&step.program, &step.env);
|
||||
inputs.push(program.clone());
|
||||
// Maven installed via a symlink still reads conf from its distribution.
|
||||
if let Ok(program) = dunce::canonicalize(program)
|
||||
&& let Some(home) = program.parent().and_then(Path::parent)
|
||||
{
|
||||
for conf in ["conf", "libexec/conf"] {
|
||||
inputs.extend([home.join(conf).join("settings.xml"), home.join(conf).join("toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if !environment.contains_key("JAVA_HOME") {
|
||||
let java = process::program_path(if cfg!(windows) { "java.exe" } else { "java" }, &step.env);
|
||||
inputs.push(java.clone());
|
||||
if let Ok(java) = dunce::canonicalize(java)
|
||||
&& let Some(home) = java.parent().and_then(Path::parent)
|
||||
{
|
||||
inputs.push(home.join("release"));
|
||||
}
|
||||
}
|
||||
for (i, arg) in step.args.iter().enumerate() {
|
||||
if matches!(arg.as_str(), "-s" | "--settings" | "-gs" | "--global-settings" | "-t" | "--toolchains")
|
||||
&& let Some(file) = step.args.get(i + 1)
|
||||
{
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
for prefix in ["--settings=", "--global-settings=", "--toolchains="] {
|
||||
if let Some(file) = arg.strip_prefix(prefix) {
|
||||
inputs.push(step.cwd.join(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
inputs.extend([home.join(".m2/settings.xml"), home.join(".m2/toolchains.xml")]);
|
||||
}
|
||||
for key in ["MAVEN_HOME", "M2_HOME"] {
|
||||
if let Some(home) = environment.get(key) {
|
||||
inputs.extend([Path::new(home).join("conf/settings.xml"), Path::new(home).join("conf/toolchains.xml")]);
|
||||
}
|
||||
}
|
||||
if let Some(home) = environment.get("JAVA_HOME") {
|
||||
inputs.extend([
|
||||
Path::new(home).join("release"),
|
||||
Path::new(home).join("lib/modules"),
|
||||
Path::new(home).join("lib/tools.jar"),
|
||||
Path::new(home).join(if cfg!(windows) { "bin/java.exe" } else { "bin/java" }),
|
||||
]);
|
||||
}
|
||||
let steps: Vec<_> =
|
||||
plan.steps.iter().map(|s| (process::program_path(&s.program, &s.env), &s.args, &s.cwd, &s.env)).collect();
|
||||
// Profiles and plugins can read arbitrary environment variables. Keep a signature
|
||||
// of the inherited environment without persisting unrelated credentials.
|
||||
let inherited: BTreeMap<_, _> = std::env::vars_os().collect();
|
||||
let mut inherited_hash = std::hash::DefaultHasher::new();
|
||||
inherited.hash(&mut inherited_hash);
|
||||
let parameters =
|
||||
serde_json::to_string(&(root, module, test, steps, environment, inherited_hash.finish(), projects))
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
}
|
||||
// Store project inputs relative to the project, including each file's relative path.
|
||||
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
|
||||
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, rel: &str, value: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, value).unwrap();
|
||||
}
|
||||
|
||||
fn fixture(root: &Path) -> BuildModel {
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version>
|
||||
<packaging>pom</packaging><modules><module>lib</module><module>app</module></modules></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>lib</artifactId><build><sourceDirectory>code</sourceDirectory><resources><resource><directory>conf</directory></resource></resources></build></project>"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"app/pom.xml",
|
||||
r#"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></parent>
|
||||
<artifactId>app</artifactId><dependencies><dependency><groupId>demo</groupId><artifactId>lib</artifactId><version>1</version></dependency></dependencies></project>"#,
|
||||
);
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "value");
|
||||
write(root, "app/src/main/resources/app.properties", "key=value");
|
||||
write(root, "lib/conf/custom.properties", "custom=value");
|
||||
write(root, "lib/code/Custom.java", "class Custom {}");
|
||||
BuildModel { maven: Some(maven::load(root)), ..Default::default() }
|
||||
}
|
||||
|
||||
fn fingerprint(root: &Path, model: &BuildModel) -> Option<LaunchFingerprint> {
|
||||
launch_fingerprint(root, model, &root.join("app"), false, None, &CancellationToken::new()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_inputs_match_but_source_resource_pom_add_delete_and_mtime_invalidate() {
|
||||
for rel in [
|
||||
"lib/src/main/kotlin/Channel.kt",
|
||||
"app/src/main/resources/app.properties",
|
||||
"lib/conf/custom.properties",
|
||||
"lib/code/Custom.java",
|
||||
"app/pom.xml",
|
||||
"lib/pom.xml",
|
||||
"pom.xml",
|
||||
".mvn/maven.config",
|
||||
"lib/src/main/groovy/New.groovy",
|
||||
"app/src/test/scala/Test.scala",
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
assert!(before.is_some());
|
||||
assert_eq!(before, fingerprint(root, &model), "unchanged {rel}");
|
||||
let serialized = serde_json::to_vec(&before).unwrap();
|
||||
assert_eq!(before, serde_json::from_slice(&serialized).unwrap());
|
||||
let value = if rel.ends_with("pom.xml") {
|
||||
format!("{}<!-- changed -->", fs::read_to_string(root.join(rel)).unwrap())
|
||||
} else {
|
||||
"changed input".to_owned()
|
||||
};
|
||||
write(root, rel, &value);
|
||||
let changed = fingerprint(root, &model);
|
||||
assert_ne!(before, changed, "changed/added {rel}");
|
||||
let file = fs::OpenOptions::new().write(true).open(root.join(rel)).unwrap();
|
||||
file.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42)).unwrap();
|
||||
let touched = fingerprint(root, &model);
|
||||
assert_ne!(changed, touched, "mtime-only {rel}");
|
||||
fs::remove_file(root.join(rel)).unwrap();
|
||||
assert_ne!(touched, fingerprint(root, &model), "deleted {rel}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let mut model = fixture(root);
|
||||
let before = fingerprint(root, &model);
|
||||
write(root, "lib/target/classes/Channel.class", "JDT stub");
|
||||
write(root, "app/target/classes/Main.class", "JDT stub");
|
||||
assert_eq!(before, fingerprint(root, &model));
|
||||
let mut previous = before.clone();
|
||||
for option in ["-Pdev", "-Pprod", "-DskipTests", "-o"] {
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec![option.into()];
|
||||
let current = fingerprint(root, &model);
|
||||
assert_ne!(previous, current, "{option}");
|
||||
previous = current;
|
||||
}
|
||||
model.maven.as_mut().unwrap().projects[0].options.clear();
|
||||
model.maven.as_mut().unwrap().projects[0]
|
||||
.env
|
||||
.insert("JAVA_HOME".into(), root.join("jdk").to_string_lossy().into_owned());
|
||||
let jdk = fingerprint(root, &model);
|
||||
assert_ne!(before, jdk);
|
||||
write(root, "jdk/release", "JAVA_VERSION=21");
|
||||
assert_ne!(jdk, fingerprint(root, &model));
|
||||
model.maven.as_mut().unwrap().projects[0].env.clear();
|
||||
let test = launch_fingerprint(root, &model, &root.join("app"), true, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(before, Some(test));
|
||||
write(root, "mvnw", "wrapper changed");
|
||||
assert_ne!(before, fingerprint(root, &model));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_roots_and_cancel_never_claim_up_to_date() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
write(
|
||||
root,
|
||||
"lib/pom.xml",
|
||||
"<project><artifactId>lib</artifactId><build><sourceDirectory>${unknown}/src</sourceDirectory></build></project>",
|
||||
);
|
||||
assert!(
|
||||
launch_fingerprint(root, &model, &root.join("app"), false, None, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(launch_fingerprint(root, &model, &root.join("app"), false, None, &cancel).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn external_parent_and_selected_settings_files_invalidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project");
|
||||
let repo = dir.path().join("repository");
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version></project>",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"pom.xml",
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><relativePath/></parent><artifactId>app</artifactId></project>",
|
||||
);
|
||||
let mut model = crate::load(&root, &root.join("cache"), Some(repo.clone()));
|
||||
let settings = dir.path().join("custom-settings.xml");
|
||||
fs::write(&settings, "<settings/>").unwrap();
|
||||
model.maven.as_mut().unwrap().projects[0].options = vec!["-s".into(), settings.to_string_lossy().into_owned()];
|
||||
let get =
|
||||
|| launch_fingerprint(&root, &model, &root, false, Some(repo.clone()), &CancellationToken::new()).unwrap();
|
||||
let before = get();
|
||||
assert!(before.is_some());
|
||||
write(
|
||||
&repo,
|
||||
"demo/parent/1/parent-1.pom",
|
||||
"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1</version><!-- changed --></project>",
|
||||
);
|
||||
let parent = get();
|
||||
assert_ne!(before, parent);
|
||||
fs::write(settings, "<settings><activeProfiles><activeProfile>dev</activeProfile></activeProfiles></settings>")
|
||||
.unwrap();
|
||||
assert_ne!(parent, get());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
//! background with cancellation and parses the compiler output into messages
|
||||
//! for the Build tool window, the Problems view and the editor.
|
||||
|
||||
mod fingerprint;
|
||||
mod parse;
|
||||
|
||||
pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
|
||||
@@ -237,12 +240,7 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<
|
||||
if !m.projects.iter().any(|p| p.dir == rel) {
|
||||
return Err(IdeError::not_found("no linked Maven project found for module"));
|
||||
}
|
||||
let top = tops
|
||||
.iter()
|
||||
.filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir)))
|
||||
.max_by_key(|t| t.dir.len())
|
||||
.copied()
|
||||
.unwrap_or(tops[0]);
|
||||
let top = maven_reactor(m, &rel).ok_or_else(|| IdeError::not_found("no linked Maven project found for module"))?;
|
||||
let top_dir = subdir(root, &top.dir);
|
||||
if module == top_dir {
|
||||
return Ok(vec![step(top, &[])]);
|
||||
@@ -611,6 +609,90 @@ pub fn has_non_java_sources(module: &Path) -> bool {
|
||||
walk(&module.join("src"), 12)
|
||||
}
|
||||
|
||||
fn reactor_projects<'a>(m: &'a maven::MavenModel, top: &'a maven::MavenProject) -> Vec<&'a maven::MavenProject> {
|
||||
let mut projects = Vec::new();
|
||||
let mut pending = vec![top];
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(p) = pending.pop() {
|
||||
if !seen.insert(&p.pom) {
|
||||
continue;
|
||||
}
|
||||
projects.push(p);
|
||||
pending.extend(m.projects.iter().filter(|child| p.modules.contains(&child.pom)));
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
fn maven_reactor<'a>(m: &'a maven::MavenModel, module: &str) -> Option<&'a maven::MavenProject> {
|
||||
m.projects.iter().filter(|p| !p.aggregated).find(|top| reactor_projects(m, top).iter().any(|p| p.dir == module))
|
||||
}
|
||||
|
||||
/// Launch module first, followed by its upstream modules in the same Maven reactor.
|
||||
/// External artifacts and unrelated linked reactors are never rebuilt for this launch.
|
||||
fn maven_launch_projects<'a>(
|
||||
root: &Path,
|
||||
m: &'a maven::MavenModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
) -> Vec<&'a maven::MavenProject> {
|
||||
let rel = rel_str(root, module);
|
||||
let Some(top) = maven_reactor(m, &rel) else { return Vec::new() };
|
||||
let reactor = reactor_projects(m, top);
|
||||
let Some(start) = reactor.iter().find(|p| p.dir == rel) else { return Vec::new() };
|
||||
let mut pending = vec![*start];
|
||||
let mut seen = HashSet::new();
|
||||
let mut projects = Vec::new();
|
||||
while let Some(p) = pending.pop() {
|
||||
if !seen.insert(&p.pom) {
|
||||
continue;
|
||||
}
|
||||
projects.push(p);
|
||||
for dep in &p.dependencies {
|
||||
if !dep.resolved() || (dep.scope == "test" && (!test || p.pom != start.pom)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(upstream) = reactor.iter().find(|u| {
|
||||
u.group_id == dep.group_id
|
||||
&& u.artifact_id == dep.artifact_id
|
||||
&& dep.version.as_ref().is_none_or(|v| *v == u.version)
|
||||
}) {
|
||||
pending.push(*upstream);
|
||||
}
|
||||
}
|
||||
}
|
||||
projects
|
||||
}
|
||||
|
||||
/// Whether a launch requires the external JVM compiler. Maven includes upstream
|
||||
/// reactor dependencies; adapter selection remains a separate, module-local decision.
|
||||
pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Path, test: bool) -> bool {
|
||||
if let Some(m) = &model.maven {
|
||||
let projects = maven_launch_projects(root, m, module, test);
|
||||
if !projects.is_empty() {
|
||||
return projects.iter().any(|p| has_non_java_sources(&subdir(root, &p.dir)));
|
||||
}
|
||||
}
|
||||
has_non_java_sources(module)
|
||||
}
|
||||
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
|
||||
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
|
||||
// JDT may have left newer error stubs in Maven's output directories.
|
||||
BuildKind::Rebuild
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
let mut plan = plan(root, model, kind, Some(module))?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
if let Some(goal) = step.args.last_mut() {
|
||||
*goal = "compile".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// Compiles the module at `module` with its build system before a JVM launch and
|
||||
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
|
||||
pub fn build_for_launch(
|
||||
@@ -620,7 +702,7 @@ pub fn build_for_launch(
|
||||
test: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = plan(root, model, BuildKind::Build, Some(module))?;
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
@@ -635,6 +717,14 @@ pub fn build_for_launch(
|
||||
.collect();
|
||||
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
|
||||
}
|
||||
if plan.system == BuildSystem::Maven
|
||||
&& let Some(m) = &model.maven
|
||||
{
|
||||
return Ok(maven_launch_projects(root, m, module, test)
|
||||
.iter()
|
||||
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
|
||||
.collect());
|
||||
}
|
||||
Ok(launch_class_dirs(plan.system, module, test))
|
||||
}
|
||||
|
||||
@@ -821,6 +911,142 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn reactor_model() -> BuildModel {
|
||||
let project = |dir: &str, deps: &[&str]| maven::MavenProject {
|
||||
pom: format!("{dir}/pom.xml"),
|
||||
dir: dir.into(),
|
||||
group_id: "probe".into(),
|
||||
artifact_id: dir.into(),
|
||||
version: "1".into(),
|
||||
aggregated: true,
|
||||
dependencies: deps
|
||||
.iter()
|
||||
.map(|d| maven::MavenDependency {
|
||||
group_id: "probe".into(),
|
||||
artifact_id: (*d).into(),
|
||||
version: Some("1".into()),
|
||||
scope: "compile".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut projects = vec![
|
||||
project("app", &["middle"]),
|
||||
project("middle", &["lib"]),
|
||||
project("lib", &["app"]),
|
||||
project("unrelated", &[]),
|
||||
];
|
||||
projects.insert(
|
||||
0,
|
||||
maven::MavenProject {
|
||||
pom: "pom.xml".into(),
|
||||
artifact_id: "reactor".into(),
|
||||
command: "mvnw".into(),
|
||||
modules: projects.iter().map(|p| p.pom.clone()).collect(),
|
||||
options: vec!["-s".into(), "settings.xml".into()],
|
||||
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
BuildModel { maven: Some(maven::MavenModel { projects, ..Default::default() }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_launch_detection_follows_only_reactor_dependencies() {
|
||||
for extension in ["kt", "groovy", "scala"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let mut model = reactor_model();
|
||||
write(root, "app/src/main/java/Application.java", "class Application {}");
|
||||
write(root, &format!("unrelated/src/main/java/Other.{extension}"), "");
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
write(root, &format!("lib/src/main/java/Channel.{extension}"), "");
|
||||
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
assert_eq!(
|
||||
maven_launch_projects(root, model.maven.as_ref().unwrap(), &root.join("app"), false).len(),
|
||||
3,
|
||||
"cycles terminate"
|
||||
);
|
||||
let m = model.maven.as_mut().unwrap();
|
||||
m.projects[2].dependencies[0].scope = "test".into();
|
||||
assert!(
|
||||
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
|
||||
"upstream test dependencies are not transitive"
|
||||
);
|
||||
let m = model.maven.as_mut().unwrap();
|
||||
m.projects[1].dependencies[0].scope = "test".into();
|
||||
m.projects[2].dependencies[0].scope = "compile".into();
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), false));
|
||||
assert!(launch_has_non_java_sources(root, &model, &root.join("app"), true));
|
||||
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("external".into());
|
||||
assert!(!launch_has_non_java_sources(root, &model, &root.join("app"), true));
|
||||
model.maven.as_mut().unwrap().projects[2].dependencies[0].version = Some("1".into());
|
||||
model.maven.as_mut().unwrap().projects[3].aggregated = false;
|
||||
model.maven.as_mut().unwrap().projects[0].modules.retain(|p| p != "lib/pom.xml");
|
||||
assert!(
|
||||
!launch_has_non_java_sources(root, &model, &root.join("app"), true),
|
||||
"another linked reactor is external"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_launch_steps_use_reactor_settings_and_launch_scope() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
let model = reactor_model();
|
||||
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
|
||||
let step = &plan.steps[0];
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(
|
||||
step.args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_launch_returns_upstream_outputs_and_blocks_failure_or_cancellation() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
write(
|
||||
root,
|
||||
"mvnw",
|
||||
"#!/bin/sh\nmkdir -p app/target/classes middle/target/classes lib/target/classes app/target/test-classes\n",
|
||||
);
|
||||
let wrapper = root.join("mvnw");
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = reactor_model();
|
||||
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(
|
||||
dirs,
|
||||
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
|
||||
.map(|p| root.join(p))
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"mvnw",
|
||||
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
|
||||
);
|
||||
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
|
||||
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_java_launch_class_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
dependencies: Vec<Dependency>,
|
||||
has_kotlin: bool,
|
||||
kotlin_goals: Vec<KotlinGoal>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
classifier: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Library>,
|
||||
}
|
||||
|
||||
pub struct Outcome {
|
||||
pub projects: Vec<ProjectLibraries>,
|
||||
pub messages: Vec<BuildMessage>,
|
||||
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<Outcome> {
|
||||
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<Module> = 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<BuildMessage> {
|
||||
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<PathBuf>,
|
||||
test: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn consumers(modules: &[Module], libraries: &BTreeMap<String, ModuleLibraries>) -> Vec<ProjectLibraries> {
|
||||
modules
|
||||
.iter()
|
||||
.map(|consumer| {
|
||||
let mut visible = Vec::<Library>::new();
|
||||
let mut add = |path: &Option<PathBuf>, 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<bool> {
|
||||
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<bool> {
|
||||
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<usize> {
|
||||
Some(u16::from_be_bytes(self.take(2)?.try_into().ok()?) as usize)
|
||||
}
|
||||
fn u4(&mut self) -> Option<usize> {
|
||||
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<Module> = 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::<Vec<Module>>(
|
||||
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<Module> = 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<Module> = 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -601,6 +601,72 @@ impl Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and source/resource trees consumed by a launch build. Unresolved custom
|
||||
/// roots disable reuse rather than silently omitting an input.
|
||||
pub(crate) fn launch_inputs(
|
||||
root: &Path,
|
||||
projects: &[&MavenProject],
|
||||
local_repo: Option<PathBuf>,
|
||||
) -> Option<Vec<PathBuf>> {
|
||||
fn roots(el: &Element, parent: &str, dir: &Path, out: &mut Vec<PathBuf>) -> Option<()> {
|
||||
let input =
|
||||
matches!(el.name.as_str(), "sourceDirectory" | "testSourceDirectory" | "sourceDir" | "testSourceDir")
|
||||
|| (matches!(parent, "sources" | "sourceDirs" | "testSources" | "testSourceDirs")
|
||||
&& matches!(el.name.as_str(), "source" | "sourceDir" | "testSourceDir"))
|
||||
|| (matches!(parent, "resource" | "testResource" | "source" | "testSource") && el.name == "directory")
|
||||
|| (parent == "filters" && el.name == "filter");
|
||||
// Profile activation may depend on the presence of an otherwise unrelated file.
|
||||
let activation = parent == "file" && matches!(el.name.as_str(), "exists" | "missing");
|
||||
if input || activation {
|
||||
// Other properties can be overridden by -D, profiles or settings.
|
||||
// Without Maven's effective roots, conservatively disable reuse for them.
|
||||
let value = el
|
||||
.text
|
||||
.trim()
|
||||
.replace("${project.basedir}", &dir.to_string_lossy())
|
||||
.replace("${basedir}", &dir.to_string_lossy());
|
||||
if value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
if !value.is_empty() {
|
||||
out.push(dir.join(value));
|
||||
}
|
||||
}
|
||||
for child in &el.children {
|
||||
roots(child, &el.name, dir, out)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
|
||||
let mut out = Vec::new();
|
||||
for project in projects {
|
||||
let dir = root.join(&project.dir);
|
||||
// Include all conventional language/resource roots, including nonstandard files under src.
|
||||
out.push(dir.join("src"));
|
||||
let pom = root.join(&project.pom);
|
||||
resolver.context(&pom, 0);
|
||||
let mut path = pom;
|
||||
let mut seen = HashSet::new();
|
||||
loop {
|
||||
let canonical = dunce::canonicalize(&path).ok()?;
|
||||
if !seen.insert(canonical) || seen.len() > MAX_PARENT_DEPTH {
|
||||
return None;
|
||||
}
|
||||
out.push(path.clone());
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
roots(&xml::parse(&text)?, "", &dir, &mut out)?;
|
||||
let raw = resolver.pom(&path)?;
|
||||
let Some(parent) = raw.parent else { break };
|
||||
path = resolver.parent_path(path.parent()?, &parent)?;
|
||||
}
|
||||
}
|
||||
// Also track imported BOMs read while resolving inheritance.
|
||||
out.extend(resolver.poms.into_keys());
|
||||
out.sort();
|
||||
out.dedup();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- plugins
|
||||
|
||||
/// `maven-clean-plugin` → `clean`, `spring-boot-maven-plugin` → `spring-boot`.
|
||||
|
||||
@@ -37,7 +37,7 @@ fn kill_tree(child: &mut Child) {
|
||||
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
|
||||
/// search path — the login shell's `PATH` plus version-manager shims — since the
|
||||
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
|
||||
fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
let path = std::path::Path::new(program);
|
||||
if !path.is_relative() || path.components().count() > 1 {
|
||||
return path.to_path_buf();
|
||||
|
||||
@@ -371,6 +371,12 @@ pub struct TextAttributesOverride {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageCodeStyle {
|
||||
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter: Option<String>,
|
||||
/// Eclipse formatter XML; relative paths start at the project root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub formatter_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub use_tab: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -12,6 +12,15 @@ pub type IdeResult<T> = Result<T, IdeError>;
|
||||
pub struct IdeError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
/// Structured detail for errors the UI acts on; `null` for plain errors.
|
||||
#[specta(optional)]
|
||||
pub data: Option<ErrorData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ErrorData {
|
||||
Tool { tool: String, missing: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -24,6 +33,7 @@ pub enum ErrorKind {
|
||||
/// Document version mismatch; the UI must resync the full text.
|
||||
OutOfSync,
|
||||
Cancelled,
|
||||
TimedOut,
|
||||
/// A required external tool (language server, git, debugger) is unavailable.
|
||||
Unavailable,
|
||||
Io,
|
||||
@@ -33,7 +43,21 @@ pub enum ErrorKind {
|
||||
|
||||
impl IdeError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self { kind, message: message.into() }
|
||||
Self { kind, message: message.into(), data: None }
|
||||
}
|
||||
pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
|
||||
if self.data.is_none() {
|
||||
self.data = Some(ErrorData::Tool { tool: tool.into(), missing: false });
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn missing_tool(tool: impl Into<String>) -> Self {
|
||||
let tool = tool.into();
|
||||
Self {
|
||||
kind: ErrorKind::NotFound,
|
||||
message: format!("{tool} is not installed"),
|
||||
data: Some(ErrorData::Tool { tool, missing: true }),
|
||||
}
|
||||
}
|
||||
pub fn not_found(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, m)
|
||||
@@ -65,3 +89,30 @@ impl From<std::io::Error> for IdeError {
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_errors_round_trip_without_changing_diagnostics() {
|
||||
let message = "[error] file.css: Unexpected token\n 1 | }\n";
|
||||
let error = IdeError::invalid(message).with_tool("prettier");
|
||||
let decoded: IdeError = serde_json::from_str(&serde_json::to_string(&error).unwrap()).unwrap();
|
||||
assert_eq!(decoded.message, message);
|
||||
assert_eq!(decoded.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
let missing = IdeError::missing_tool("prettier").with_tool("prettier");
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_errors_need_no_data() {
|
||||
let error: IdeError = serde_json::from_str(r#"{"kind":"unavailable","message":"no server"}"#).unwrap();
|
||||
assert!(error.data.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(error).unwrap(),
|
||||
serde_json::json!({"kind":"unavailable","message":"no server","data":null})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,13 +215,21 @@ fn fix_kind(
|
||||
}
|
||||
|
||||
/// `vmArgs` of a launch by java-debug, which decodes the JVM's output as `encoding`
|
||||
/// (its launch attribute, UTF-8 when unset) and sets `file.encoding` itself.
|
||||
pub fn java_debug_vm_args(vm_args: &str, encoding: Option<&str>, windows: bool) -> Option<String> {
|
||||
/// (its launch attribute, UTF-8 when unset). java-debug sets `file.encoding` only
|
||||
/// when that attribute is nonblank; use the same defaults and user overrides as [`fix`].
|
||||
pub fn java_debug_vm_args(
|
||||
vm_args: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
inherited: impl Fn(&str) -> Option<String>,
|
||||
encoding: Option<&str>,
|
||||
windows: bool,
|
||||
) -> Option<String> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
|
||||
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
|
||||
if added.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
let added = added.join(" ");
|
||||
Some(match vm_args.trim() {
|
||||
"" => added,
|
||||
own => format!("{own} {added}"),
|
||||
@@ -446,12 +454,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_write_what_java_debug_decodes() {
|
||||
assert_eq!(java_debug_vm_args("", None, true).as_deref(), Some(TOOL_UTF8));
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", Some(""), true), Some(format!("-Xmx1g {TOOL_UTF8}")));
|
||||
assert_eq!(java_debug_vm_args("", Some("GBK"), true).unwrap().split(' ').next(), Some("-Dstdout.encoding=GBK"));
|
||||
assert_eq!(java_debug_vm_args("-Dstdout.encoding=GBK", None, true), None);
|
||||
assert_eq!(java_debug_vm_args("-Xmx1g", None, false), None);
|
||||
assert!(java_debug_vm_args("", Some("GBK"), false).is_some());
|
||||
let debug =
|
||||
|args: &str, encoding, windows| java_debug_vm_args(args, &BTreeMap::new(), |_| None, encoding, windows);
|
||||
for encoding in [None, Some(""), Some(" "), Some("UTF-8"), Some("utf8")] {
|
||||
let streams = encoding.filter(|e| !e.trim().is_empty()).unwrap_or("UTF-8");
|
||||
let utf8 = format!("{} -Dfile.encoding=UTF-8", options(STD_PROPERTIES, streams).join(" "));
|
||||
assert_eq!(debug("", encoding, true), Some(utf8.clone()));
|
||||
assert_eq!(debug("-Xmx1g", encoding, true), Some(format!("-Xmx1g {utf8}")));
|
||||
assert_eq!(debug("-Xmx1g", encoding, false), None);
|
||||
}
|
||||
for windows in [true, false] {
|
||||
assert_eq!(debug("", Some("GBK"), windows), Some(options(STD_PROPERTIES, "GBK").join(" ")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_preserves_user_options_independently() {
|
||||
let debug = |args: &str| java_debug_vm_args(args, &BTreeMap::new(), |_| None, None, true);
|
||||
assert_eq!(debug("-Dfile.encoding=GBK"), Some(format!("-Dfile.encoding=GBK {TOOL_UTF8}")));
|
||||
for property in STD_PROPERTIES {
|
||||
let args = format!("-D{property}=GBK");
|
||||
assert_eq!(debug(&args), Some(format!("{args} -Dfile.encoding=UTF-8")));
|
||||
assert_eq!(debug(&format!("{args} -Dfile.encoding=GBK")), None);
|
||||
}
|
||||
for key in ["JDK_JAVA_OPTIONS", TOOL_OPTIONS] {
|
||||
for (own, expected) in [
|
||||
("-Dfile.encoding=GBK", Some(TOOL_UTF8.to_owned())),
|
||||
("-Dstdout.encoding=GBK", Some("-Dfile.encoding=UTF-8".to_owned())),
|
||||
("-Dstdout.encoding=GBK -Dfile.encoding=GBK", None),
|
||||
] {
|
||||
assert_eq!(java_debug_vm_args("", &vars(&[(key, own)]), |_| None, None, true), expected);
|
||||
assert_eq!(
|
||||
java_debug_vm_args("", &BTreeMap::new(), |k| (k == key).then(|| own.to_owned()), None, true),
|
||||
expected
|
||||
);
|
||||
}
|
||||
// A launch environment value overrides the inherited one, as in Run.
|
||||
assert_eq!(
|
||||
java_debug_vm_args(
|
||||
"",
|
||||
&vars(&[(key, "")]),
|
||||
|k| (k == key).then(|| "-Dfile.encoding=GBK".into()),
|
||||
None,
|
||||
true
|
||||
),
|
||||
Some(format!("{TOOL_UTF8} -Dfile.encoding=UTF-8"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn filtered(chunks: &[&str]) -> String {
|
||||
|
||||
@@ -276,6 +276,8 @@ pub struct AppearanceSettings {
|
||||
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
|
||||
pub use_custom_font: bool,
|
||||
pub ui_font_family: String,
|
||||
/// Font tried when `ui_font_family` lacks a glyph; empty = none.
|
||||
pub ui_font_fallback: String,
|
||||
pub contrast_scrollbars: bool,
|
||||
/// Adjust colours for red-green vision deficiency.
|
||||
pub red_green_vision: bool,
|
||||
@@ -292,6 +294,7 @@ pub struct AppearanceSettings {
|
||||
pub editor_antialiasing: String,
|
||||
pub show_tool_window_bars: bool,
|
||||
pub show_tool_window_names: bool,
|
||||
pub show_tool_window_badges: bool,
|
||||
pub widescreen_tool_windows: bool,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
@@ -325,6 +328,7 @@ impl Default for AppearanceSettings {
|
||||
zoom: 100,
|
||||
use_custom_font: false,
|
||||
ui_font_family: "Inter".into(),
|
||||
ui_font_fallback: String::new(),
|
||||
contrast_scrollbars: false,
|
||||
red_green_vision: false,
|
||||
different_tool_window_background: false,
|
||||
@@ -338,6 +342,7 @@ impl Default for AppearanceSettings {
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
show_tool_window_names: false,
|
||||
show_tool_window_badges: true,
|
||||
widescreen_tool_windows: false,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
@@ -503,9 +508,50 @@ pub struct LanguagesSettings {
|
||||
pub rust: RustSettings,
|
||||
pub go: GoSettings,
|
||||
pub typescript: TypeScriptSettings,
|
||||
pub prettier: PrettierSettings,
|
||||
pub java: JavaSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PrettierSettings {
|
||||
/// Empty path: project node_modules, managed installation, then PATH.
|
||||
pub path: String,
|
||||
pub languages: Vec<String>,
|
||||
/// Prefer Prettier even when a language server offers formatting.
|
||||
pub prefer: bool,
|
||||
pub run_on_save: bool,
|
||||
}
|
||||
|
||||
impl Default for PrettierSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"html",
|
||||
"graphql",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"json",
|
||||
"jsonc",
|
||||
"vue",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
prefer: false,
|
||||
run_on_save: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PythonSettings {
|
||||
@@ -719,6 +765,35 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ui_font_fallback_defaults_empty_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
|
||||
assert!(old.appearance.ui_font_fallback.is_empty());
|
||||
for family in ["", "Songti SC", "Maple Mono NF CN"] {
|
||||
let mut settings = Settings::default();
|
||||
settings.appearance.ui_font_fallback = family.into();
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["appearance"]["uiFontFallback"], family);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.appearance.ui_font_fallback, family);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_window_badges_default_on_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"showToolWindowNames":true}}"#).unwrap();
|
||||
assert!(old.appearance.show_tool_window_badges);
|
||||
assert!(Settings::default().appearance.show_tool_window_badges);
|
||||
for enabled in [false, true] {
|
||||
let mut settings = Settings::default();
|
||||
settings.appearance.show_tool_window_badges = enabled;
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["appearance"]["showToolWindowBadges"], enabled);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.appearance.show_tool_window_badges, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
|
||||
@@ -905,6 +905,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
|
||||
@@ -7,10 +7,11 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
||||
+239
-4
@@ -1,8 +1,7 @@
|
||||
//! The font families installed on this machine, for the font pickers in
|
||||
//! Settings (editor, typography fallback, terminal, UI font).
|
||||
//!
|
||||
//! Each platform asks the font system the WebView itself resolves CSS
|
||||
//! `font-family` names against, so every listed name renders: CoreText on
|
||||
//! Each platform asks the native font system for installed families: CoreText on
|
||||
//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs.
|
||||
//! The list is read on first use and cached until explicitly refreshed; the
|
||||
//! query can take a few hundred milliseconds, so callers run it off the UI and
|
||||
@@ -84,14 +83,164 @@ pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Binary font bundle: little-endian u32 JSON-header length, a face metadata
|
||||
/// array, then raw font bytes. Paths never cross IPC; only CoreText-enumerated
|
||||
/// files under user font directories can be read, selected by family name.
|
||||
/// Includes only the nearest regular/bold faces and their optional italics.
|
||||
pub fn user_font_data(family: &str) -> std::io::Result<Vec<u8>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let entries = imp::user_faces();
|
||||
bundle_family(family, &entries)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = family;
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "User font loading is macOS only"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
#[derive(Debug)]
|
||||
struct UserFace {
|
||||
family: String,
|
||||
path: std::path::PathBuf,
|
||||
weight: u16,
|
||||
italic: bool,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn user_font_path(path: &std::path::Path, roots: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
|
||||
let path = path.canonicalize().ok()?;
|
||||
roots.iter().any(|root| path.starts_with(root)).then_some(path)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn family_faces<'a>(family: &str, entries: &'a [UserFace]) -> Vec<&'a UserFace> {
|
||||
let mut selected: Vec<&UserFace> = Vec::new();
|
||||
for italic in [false, true] {
|
||||
for weight in [400u16, 700] {
|
||||
let closest = entries
|
||||
.iter()
|
||||
.filter(|e| e.family.eq_ignore_ascii_case(family.trim()) && e.italic == italic)
|
||||
.min_by_key(|e| (e.weight.abs_diff(weight), e.weight, &e.path));
|
||||
if let Some(face) = closest
|
||||
&& !selected.iter().any(|f| std::ptr::eq(*f, face))
|
||||
{
|
||||
selected.push(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn bundle_family(family: &str, entries: &[UserFace]) -> std::io::Result<Vec<u8>> {
|
||||
#[derive(Serialize)]
|
||||
struct Face {
|
||||
weight: u16,
|
||||
style: &'static str,
|
||||
offset: usize,
|
||||
length: usize,
|
||||
}
|
||||
let mut faces = Vec::new();
|
||||
let mut data = Vec::new();
|
||||
let mut files = BTreeMap::new();
|
||||
// Bound large CJK families to regular/bold and their nearest italic faces.
|
||||
for entry in family_faces(family, entries) {
|
||||
// Reject a file replaced by a symlink after enumeration.
|
||||
if entry.path.canonicalize()? != entry.path {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Font file changed"));
|
||||
}
|
||||
let (offset, length) = if let Some(range) = files.get(&entry.path) {
|
||||
*range
|
||||
} else {
|
||||
let bytes = std::fs::read(&entry.path)?;
|
||||
let range = (data.len(), bytes.len());
|
||||
data.extend(bytes);
|
||||
files.insert(entry.path.clone(), range);
|
||||
range
|
||||
};
|
||||
faces.push(Face {
|
||||
weight: entry.weight,
|
||||
style: if entry.italic { "italic" } else { "normal" },
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "No installed user font family"));
|
||||
}
|
||||
let header = serde_json::to_vec(&faces)?;
|
||||
let mut out = (header.len() as u32).to_le_bytes().to_vec();
|
||||
out.extend(header);
|
||||
out.extend(data);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod imp {
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType, CFURL};
|
||||
use objc2_core_text::{
|
||||
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
|
||||
kCTFontTraitsAttribute,
|
||||
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
|
||||
};
|
||||
|
||||
pub(crate) fn user_faces() -> Vec<super::UserFace> {
|
||||
let mut roots = vec![std::path::PathBuf::from("/Library/Fonts")];
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
roots.push(std::path::PathBuf::from(home).join("Library/Fonts"));
|
||||
}
|
||||
let roots: Vec<_> = roots.into_iter().filter_map(|r| r.canonicalize().ok()).collect();
|
||||
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
|
||||
unsafe {
|
||||
let collection = CTFontCollection::from_available_fonts(None);
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
descriptors
|
||||
.cast_unchecked::<CTFontDescriptor>()
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
let family = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
|
||||
let path = d.attribute(kCTFontURLAttribute)?.downcast::<CFURL>().ok()?.to_file_path()?;
|
||||
let path = super::user_font_path(&path, &roots)?;
|
||||
let traits = d.attribute(kCTFontTraitsAttribute)?.downcast::<CFDictionary>().ok()?;
|
||||
let traits = traits.cast_unchecked::<CFString, CFType>();
|
||||
let symbolic = traits
|
||||
.get(kCTFontSymbolicTrait)
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_i64())
|
||||
.unwrap_or(0) as u32;
|
||||
let weight = traits
|
||||
.get(kCTFontWeightTrait)
|
||||
.and_then(|n| n.downcast::<CFNumber>().ok())
|
||||
.and_then(|n| n.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
// CoreText's normalized weight values corresponding to CSS's nine standard weights.
|
||||
let weight = [
|
||||
(-0.8, 100),
|
||||
(-0.6, 200),
|
||||
(-0.4, 300),
|
||||
(0.0, 400),
|
||||
(0.23, 500),
|
||||
(0.3, 600),
|
||||
(0.4, 700),
|
||||
(0.56, 800),
|
||||
(0.62, 900),
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|(a, _), (b, _)| (weight - a).abs().total_cmp(&(weight - b).abs()))?
|
||||
.1;
|
||||
let italic = symbolic & CTFontSymbolicTraits::TraitItalic.0 != 0;
|
||||
if !seen.insert((family.clone(), path.clone(), weight, italic)) {
|
||||
return None;
|
||||
}
|
||||
Some(super::UserFace { family, path, weight, italic })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every face CoreText makes available to this process, as (family, fixed pitch).
|
||||
pub(crate) fn list(_refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: CoreText's documented types: the matching descriptors array
|
||||
@@ -210,6 +359,92 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn selects_only_regular_bold_and_matching_italics_from_dense_families() {
|
||||
let entries: Vec<_> = [false, true]
|
||||
.into_iter()
|
||||
.flat_map(|italic| {
|
||||
(100..=900).step_by(100).map(move |weight| UserFace {
|
||||
family: "CJK".into(),
|
||||
path: format!("{weight}-{italic}.ttf").into(),
|
||||
weight,
|
||||
italic,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let selected = family_faces(" cjk ", &entries);
|
||||
assert_eq!(
|
||||
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
|
||||
[(400, false), (700, false), (400, true), (700, true),]
|
||||
);
|
||||
assert!(family_faces("unknown", &entries).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_nearest_available_weights_once_with_optional_italics() {
|
||||
let entries: Vec<_> = [(300, false), (500, false), (800, false), (500, true)]
|
||||
.into_iter()
|
||||
.map(|(weight, italic)| UserFace {
|
||||
family: "Sparse".into(),
|
||||
path: format!("{weight}-{italic}.ttf").into(),
|
||||
weight,
|
||||
italic,
|
||||
})
|
||||
.collect();
|
||||
let selected = family_faces("Sparse", &entries);
|
||||
assert_eq!(
|
||||
selected.iter().map(|f| (f.weight, f.italic)).collect::<Vec<_>>(),
|
||||
[(300, false), (800, false), (500, true),]
|
||||
);
|
||||
assert_eq!(family_faces("Sparse", &entries[..1]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_only_reads_enumerated_family_faces() {
|
||||
let root = std::env::current_dir().unwrap().join(format!("../../.font-bundle-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let regular = root.join("regular.ttf");
|
||||
let bold = root.join("bold.ttf");
|
||||
std::fs::write(®ular, [1, 2, 3]).unwrap();
|
||||
std::fs::write(&bold, [4, 5]).unwrap();
|
||||
let entries = [
|
||||
UserFace { family: "Test Nerd Font".into(), path: regular.clone(), weight: 400, italic: false },
|
||||
UserFace { family: "Test Nerd Font".into(), path: bold, weight: 700, italic: false },
|
||||
// A non-selected face is never opened, even if its file has disappeared.
|
||||
UserFace { family: "Test Nerd Font".into(), path: root.join("thin.ttf"), weight: 100, italic: false },
|
||||
];
|
||||
let result = bundle_family("test nerd font", &entries).unwrap();
|
||||
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
|
||||
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
|
||||
assert_eq!(faces[0]["weight"], 400);
|
||||
assert_eq!(faces[1]["weight"], 700);
|
||||
assert_eq!(faces.as_array().unwrap().len(), 2);
|
||||
assert_eq!(&result[4 + len..], &[1, 2, 3, 4, 5]);
|
||||
// Existing files are not inputs: neither paths nor unenumerated families grant access.
|
||||
assert!(bundle_family(regular.to_str().unwrap(), &entries).is_err());
|
||||
assert!(bundle_family("Unenumerated", &entries).is_err());
|
||||
assert!(user_font_path(®ular, &[]).is_none());
|
||||
assert!(user_font_path(®ular, std::slice::from_ref(&root)).is_some());
|
||||
// Multiple selected descriptors in a collection still transfer the file only once.
|
||||
let shared = [
|
||||
UserFace { family: "Collection".into(), path: regular.clone(), weight: 400, italic: false },
|
||||
UserFace { family: "Collection".into(), path: regular, weight: 700, italic: false },
|
||||
];
|
||||
let result = bundle_family("Collection", &shared).unwrap();
|
||||
let len = u32::from_le_bytes(result[..4].try_into().unwrap()) as usize;
|
||||
let faces: serde_json::Value = serde_json::from_slice(&result[4..4 + len]).unwrap();
|
||||
assert_eq!(faces[0]["offset"], faces[1]["offset"]);
|
||||
assert_eq!(&result[4 + len..], &[1, 2, 3]);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = root.join("outside.ttf");
|
||||
std::os::unix::fs::symlink(std::env::current_exe().unwrap(), &link).unwrap();
|
||||
assert!(user_font_path(&link, std::slice::from_ref(&root)).is_none());
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn fam(name: &str, monospaced: bool) -> FontFamily {
|
||||
FontFamily { name: name.into(), monospaced }
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Select the document's formatting provider before executing it on this host.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::settings::PrettierSettings;
|
||||
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_lsp::types::FormatOptions;
|
||||
use ide_tools::{Tools, formatter};
|
||||
|
||||
fn external_selected(config: Option<&FormatterConfig>, language: &str, settings: &PrettierSettings, lsp: bool) -> bool {
|
||||
config.is_some_and(|c| c.tool == "prettier")
|
||||
&& settings.languages.iter().any(|l| l == language)
|
||||
&& (settings.prefer || !lsp)
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let prettier = &settings.languages.prettier;
|
||||
let language = project.languages.get(&snap.language);
|
||||
let config = language.as_ref().and_then(|l| l.formatter.as_ref());
|
||||
let candidate =
|
||||
config.is_some_and(|c| c.tool == "prettier") && prettier.languages.iter().any(|l| l == snap.language.as_str());
|
||||
let lsp = if candidate && !prettier.prefer { project.lsp.can_format(doc).await? } else { false };
|
||||
if !external_selected(config, snap.language.as_str(), prettier, lsp) {
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return project.lsp.format(doc, range, options).await;
|
||||
}
|
||||
if options.on_save && !prettier.run_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
host.require_trust(window)?;
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let config = config.unwrap().clone();
|
||||
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let whole_selection = range.is_some() && !config.range;
|
||||
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
|
||||
let text = ide_text::Document::new(&snap.text);
|
||||
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
|
||||
let mut command = tools.formatter_command(&config.tool, &snap.path, custom.as_deref())?;
|
||||
command.current_dir(snap.path.parent().ok_or_else(|| IdeError::invalid("Prettier: file has no directory"))?);
|
||||
command.args(formatter::arguments(&config, &snap.path, options.tab_size, options.insert_spaces, offsets));
|
||||
let formatted = formatter::run(command, &snap.text, &Default::default())?;
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
if edits.is_ok() && whole_selection {
|
||||
project.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Info,
|
||||
title: "Prettier".into(),
|
||||
body: Some(
|
||||
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
edits.map_err(|e| e.with_tool("prettier"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn language_enablement_and_preference_preserve_lsp_formatters() {
|
||||
let languages = ide_language::LanguageRegistry::load(None).unwrap();
|
||||
let mut settings = PrettierSettings::default();
|
||||
for lang in languages.all() {
|
||||
for lsp in [false, true] {
|
||||
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
|
||||
assert_eq!(
|
||||
external_selected(lang.formatter.as_ref(), lang.id.as_str(), &settings, lsp),
|
||||
configured && !lsp,
|
||||
"{}",
|
||||
lang.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let ts = languages.get(&ide_core::LanguageId::new("typescript")).unwrap();
|
||||
settings.prefer = true;
|
||||
assert!(external_selected(ts.formatter.as_ref(), "typescript", &settings, true));
|
||||
settings.languages.clear();
|
||||
assert!(!external_selected(ts.formatter.as_ref(), "typescript", &settings, false));
|
||||
assert!(!external_selected(None, "rust", &settings, false));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct KotlinBridge {
|
||||
root: PathBuf,
|
||||
cache: PathBuf,
|
||||
bundle: Option<PathBuf>,
|
||||
settings: Arc<SettingsStore>,
|
||||
lsp: Arc<LspManager>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
lifetime: CancellationToken,
|
||||
revision: Mutex<Revision>,
|
||||
changed: Notify,
|
||||
options: Mutex<MavenOptions>,
|
||||
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl KotlinBridge {
|
||||
pub(crate) fn new(
|
||||
root: PathBuf,
|
||||
cache: PathBuf,
|
||||
data: &Path,
|
||||
settings: Arc<SettingsStore>,
|
||||
lsp: Arc<LspManager>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
) -> Arc<Self> {
|
||||
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<Self>, 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<String>) {
|
||||
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<Item = &'a PathBuf>) {
|
||||
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<Self>) {
|
||||
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::<Vec<_>>()
|
||||
.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<PathBuf>) {
|
||||
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<PathBuf>, 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));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod project;
|
||||
@@ -148,9 +150,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 +166,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<Arc<notebook::NotebookService>>) {
|
||||
fn shutdown_project_workers_blocking(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
) {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
for service in ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
@@ -166,6 +181,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 +204,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 +219,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -96,7 +96,13 @@ fn match_java_debug_encoding(mut config: LaunchConfig, windows: bool) -> LaunchC
|
||||
};
|
||||
let vm_args = extra.get("vmArgs").and_then(|v| v.as_str()).unwrap_or_default();
|
||||
let encoding = extra.get("encoding").and_then(|v| v.as_str());
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(
|
||||
vm_args,
|
||||
&config.env,
|
||||
|key| std::env::var(key).ok(),
|
||||
encoding,
|
||||
windows,
|
||||
) {
|
||||
extra.insert("vmArgs".into(), vm_args.into());
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
}
|
||||
@@ -452,7 +458,7 @@ mod tests {
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -468,7 +474,7 @@ mod tests {
|
||||
let fixed = match_java_debug_encoding(launch("java-debug", "launch", extra.clone()), true);
|
||||
assert_eq!(
|
||||
vm_args(&fixed),
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8 -Dfile.encoding=UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
|
||||
@@ -481,4 +487,13 @@ mod tests {
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(launch(adapter, request, extra.clone()), windows)), "-Xmx1g");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_respect_environment_encoding() {
|
||||
for key in ["JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS"] {
|
||||
let mut config = launch("java-debug", "launch", serde_json::json!({"vmArgs": "-Dstdout.encoding=GBK"}));
|
||||
config.env.insert(key.into(), "-Dfile.encoding=GBK".into());
|
||||
assert_eq!(vm_args(&match_java_debug_encoding(config, true)), "-Dstdout.encoding=GBK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,73 @@
|
||||
use super::Channel;
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_vfs::FileEntry;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult<ide_vfs::EntryKind> {
|
||||
host.blocking(move || {
|
||||
let meta = std::fs::metadata(path)?;
|
||||
Ok(if meta.is_dir() { ide_vfs::EntryKind::Dir } else { ide_vfs::EntryKind::File })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn compare_directories(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
left: PathBuf,
|
||||
right: PathBuf,
|
||||
request: RequestId,
|
||||
on_progress: Channel<ide_vfs::DirectoryCompareProgress>,
|
||||
) -> IdeResult<Vec<ide_vfs::DirectoryCompareEntry>> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = host
|
||||
.blocking(move || {
|
||||
let last = std::cell::Cell::new(std::time::Instant::now() - std::time::Duration::from_secs(1));
|
||||
ide_vfs::compare_directories(&left, &right, Some(&project.root), &project.exclude_globs(), &cancel, |p| {
|
||||
// Bound IPC traffic even when hashing multi-gigabyte files.
|
||||
if last.get().elapsed() >= std::time::Duration::from_millis(80) || p.total == Some(p.compared) {
|
||||
let _ = on_progress.send(p);
|
||||
last.set(std::time::Instant::now());
|
||||
}
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn copy_merge(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
source_root: PathBuf,
|
||||
destination_root: PathBuf,
|
||||
path: PathBuf,
|
||||
overwrite: bool,
|
||||
) -> IdeResult<()> {
|
||||
if path.as_os_str().is_empty() || path.components().any(|c| !matches!(c, std::path::Component::Normal(_))) {
|
||||
return Err(ide_core::IdeError::invalid("expected a relative comparison entry path"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let from = source_root.join(&path);
|
||||
let to = destination_root.join(&path);
|
||||
if overwrite {
|
||||
project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
ide_vfs::copy_merge(
|
||||
&from,
|
||||
&to,
|
||||
overwrite,
|
||||
Some(&project.root),
|
||||
&project.exclude_globs(),
|
||||
(&source_root, &destination_root),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Files of a deleted folder snapshotted in Local History.
|
||||
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
|
||||
pub(crate) mod cache;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cancellable with `cancel_request(request)`.
|
||||
@@ -39,20 +41,46 @@ async fn resolve_launch(
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
|
||||
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
|
||||
let (model, external, delegated) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
let module = module.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
project.java_launch_snapshots.lock().initialize();
|
||||
let model = load(&project.root, &project.build_cache_dir(), &settings);
|
||||
let non_java = build && compile::launch_has_non_java_sources(&project.root, &model, &module, test);
|
||||
let delegated = build
|
||||
&& model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| project.root.join(&p.dir) == module))
|
||||
&& (non_java || cache::has_record(&project.root, &project.build_cache_dir(), &module));
|
||||
let external = non_java || delegated;
|
||||
(model, external, delegated)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
compile::build_for_launch(
|
||||
let _external_build = external_build;
|
||||
let built = if delegated {
|
||||
let outputs = project.java_launch_snapshots.lock().build(
|
||||
&project.root,
|
||||
&load(&project.root, &project.build_cache_dir(), &settings),
|
||||
&model,
|
||||
&module,
|
||||
test,
|
||||
ide_sdk::apply::maven_local_repository(&project.root, &settings),
|
||||
&cancel,
|
||||
)?
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
info.build = Some(JavaBuildStatus::Succeeded);
|
||||
info.errors.clear();
|
||||
info.error_count = 0;
|
||||
copies
|
||||
} else if external {
|
||||
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -103,6 +131,26 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
|
||||
let mut copies = Vec::new();
|
||||
for (index, source) in built.iter().enumerate() {
|
||||
let dest = snapshot.join(index.to_string());
|
||||
let original = source.to_string_lossy();
|
||||
info.class_paths.retain(|p| p != original.as_ref());
|
||||
let mut modular = false;
|
||||
for path in &mut info.module_paths {
|
||||
if path == original.as_ref() {
|
||||
*path = dest.to_string_lossy().into_owned();
|
||||
modular = true;
|
||||
}
|
||||
}
|
||||
if !modular {
|
||||
copies.push(dest);
|
||||
}
|
||||
}
|
||||
copies
|
||||
}
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
@@ -132,3 +180,40 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let app = root.path().join("app/target/classes");
|
||||
let lib = root.path().join("lib/target/classes");
|
||||
for dir in [&app, &lib] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("Channel.class"), "Maven bytecode").unwrap();
|
||||
}
|
||||
let mut info = JavaLaunchInfo {
|
||||
class_paths: vec![app.to_string_lossy().into_owned(), "stdlib.jar".into()],
|
||||
module_paths: vec![lib.to_string_lossy().into_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
let snapshot = tempfile::tempdir().unwrap();
|
||||
for (i, source) in [&app, &lib].iter().enumerate() {
|
||||
ide_vfs::copy(source, &snapshot.path().join(i.to_string())).unwrap();
|
||||
}
|
||||
let paths = use_snapshot(&mut info, &[app.clone(), lib.clone()], snapshot.path());
|
||||
assert_eq!(paths, [snapshot.path().join("0")]);
|
||||
assert_eq!(info.class_paths, ["stdlib.jar"]);
|
||||
assert_eq!(info.module_paths, [snapshot.path().join("1").to_string_lossy().into_owned()]);
|
||||
std::fs::write(app.join("Channel.class"), "JDT error stub").unwrap();
|
||||
std::fs::remove_dir_all(lib).unwrap();
|
||||
for index in ["0", "1"] {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(snapshot.path().join(index).join("Channel.class")).unwrap(),
|
||||
"Maven bytecode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
|
||||
use ide_build::compile::{self, LaunchFingerprint};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct Outputs {
|
||||
fingerprint: Option<LaunchFingerprint>,
|
||||
pub snapshot: PathBuf,
|
||||
pub originals: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
fn reusable(&self, fingerprint: &Option<LaunchFingerprint>, snapshots: &Path) -> bool {
|
||||
fingerprint.is_some()
|
||||
&& self.fingerprint == *fingerprint
|
||||
&& self.snapshot.parent() == Some(snapshots)
|
||||
&& !self.originals.is_empty()
|
||||
&& (0..self.originals.len()).all(|i| self.snapshot.join(i.to_string()).is_dir())
|
||||
}
|
||||
}
|
||||
|
||||
fn record_path(root: &Path, cache: &Path, module: &Path, test: bool) -> Option<PathBuf> {
|
||||
Some(cache.join("java-launch/records").join(module.strip_prefix(root).ok()?).join(if test {
|
||||
"test.json"
|
||||
} else {
|
||||
"main.json"
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
|
||||
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
|
||||
}
|
||||
|
||||
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
|
||||
pub(crate) struct Session {
|
||||
cache: PathBuf,
|
||||
initialized: bool,
|
||||
unreferenced: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn new(cache: PathBuf) -> Self {
|
||||
Self { cache, initialized: false, unreferenced: Vec::new() }
|
||||
}
|
||||
|
||||
pub(super) fn initialize(&mut self) {
|
||||
if !self.initialized {
|
||||
self.initialized = true;
|
||||
if let Err(error) = sweep(&self.cache) {
|
||||
tracing::warn!(%error, "Cannot clean previous Java launch snapshots");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
model: &ide_build::BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outputs> {
|
||||
self.initialize();
|
||||
let cache = &self.cache;
|
||||
let record =
|
||||
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
let fingerprint = || match compile::launch_fingerprint(root, model, module, test, local_repo.clone(), cancel) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(e) if cancel.is_cancelled() => Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "Cannot fingerprint Maven launch; rebuilding");
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
let before = fingerprint()?;
|
||||
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
|
||||
if let Some(outputs) = previous.as_ref()
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
{
|
||||
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
|
||||
return Ok(previous.unwrap());
|
||||
}
|
||||
// Retain a non-reusable record even on failure, so removing the final mixed
|
||||
// source still delegates to Maven to clean its old classes on the next launch.
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
fs::write(&record, b"null")?;
|
||||
if let Some(previous) = previous {
|
||||
self.unreferenced.push(previous.snapshot);
|
||||
}
|
||||
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
|
||||
fs::create_dir_all(&snapshots)?;
|
||||
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
|
||||
// Retain every new snapshot even if copying or publishing its record fails.
|
||||
self.unreferenced.push(snapshot.clone());
|
||||
for (i, source) in originals.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
ide_vfs::copy(source, &snapshot.join(i.to_string()))?;
|
||||
}
|
||||
let after = fingerprint()?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
|
||||
if before.is_some() && before == after {
|
||||
outputs.fingerprint = before;
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
serde_json::to_writer(file.as_file_mut(), &outputs).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
|
||||
self.unreferenced.retain(|path| path != &outputs.snapshot);
|
||||
}
|
||||
Ok(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
// Recheck all records: another module or launch mode may reference a path.
|
||||
if let Ok(referenced) = referenced_snapshots(&self.cache) {
|
||||
let snapshots = self.cache.join("java-launch/snapshots");
|
||||
for path in &self.unreferenced {
|
||||
remove_unreferenced(path, &snapshots, &referenced);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn referenced_snapshots(cache: &Path) -> std::io::Result<HashSet<PathBuf>> {
|
||||
let mut referenced = HashSet::new();
|
||||
let records = cache.join("java-launch/records");
|
||||
if !records.try_exists()? {
|
||||
return Ok(referenced);
|
||||
}
|
||||
let mut pending = vec![records];
|
||||
while let Some(dir) = pending.pop() {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_dir() {
|
||||
pending.push(entry.path());
|
||||
} else if kind.is_file() && entry.path().extension().is_some_and(|ext| ext == "json") {
|
||||
let outputs: Option<Outputs> = serde_json::from_slice(&fs::read(entry.path())?)?;
|
||||
if let Some(outputs) = outputs
|
||||
&& let Ok(path) = fs::canonicalize(outputs.snapshot)
|
||||
{
|
||||
referenced.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(referenced)
|
||||
}
|
||||
|
||||
fn remove_unreferenced(path: &Path, snapshots: &Path, referenced: &HashSet<PathBuf>) {
|
||||
// Only real launch-* directories directly inside snapshots, never symlinks.
|
||||
if path.parent() != Some(snapshots)
|
||||
|| !path.file_name().is_some_and(|name| name.to_string_lossy().starts_with("launch-"))
|
||||
|| !fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_dir())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let (Ok(path), Ok(snapshots)) = (fs::canonicalize(path), fs::canonicalize(snapshots))
|
||||
&& path.parent() == Some(snapshots.as_path())
|
||||
&& !referenced.contains(&path)
|
||||
&& let Err(error) = fs::remove_dir_all(&path)
|
||||
{
|
||||
tracing::warn!(%error, path = %path.display(), "Cannot remove Java launch snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
fn sweep(cache: &Path) -> std::io::Result<()> {
|
||||
let referenced = referenced_snapshots(cache)?;
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
if snapshots.try_exists()? {
|
||||
for entry in fs::read_dir(&snapshots)? {
|
||||
remove_unreferenced(&entry?.path(), &snapshots, &referenced);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn fixture(root: &Path) -> ide_build::BuildModel {
|
||||
fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let script = root.join("mvnw");
|
||||
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
|
||||
ide_build::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let mut build = || session.build(root, &model, root, false, None, &cancel);
|
||||
let first = build().unwrap();
|
||||
let snapshot = first.snapshot.clone();
|
||||
drop(first);
|
||||
// Dropping an Outputs value leaves the persisted snapshot reusable.
|
||||
let second = build().unwrap();
|
||||
assert_eq!(snapshot, second.snapshot);
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 1);
|
||||
fs::write(root.join("target/classes/Main.class"), "JDT stub").unwrap();
|
||||
assert_eq!(snapshot, build().unwrap().snapshot);
|
||||
assert_eq!(fs::read_to_string(snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let updated = build().unwrap();
|
||||
assert_ne!(snapshot, updated.snapshot);
|
||||
assert_eq!(fs::read_to_string(updated.snapshot.join("0/Main.class")).unwrap(), "updated");
|
||||
assert!(snapshot.join("0/Main.class").is_file(), "previous JVMs may still load it");
|
||||
fs::remove_dir_all(updated.snapshot.join("0")).unwrap();
|
||||
let rebuilt = build().unwrap();
|
||||
assert_ne!(rebuilt.snapshot, updated.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
|
||||
assert!(build().is_err());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
fs::remove_file(root.join("src/main/kotlin/broken")).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, build().unwrap().snapshot, "a failed build invalidates success");
|
||||
cancel.cancel();
|
||||
assert!(build().is_err());
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
|
||||
}
|
||||
#[test]
|
||||
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
// Another mode can keep a superseded snapshot alive.
|
||||
let test_record = cache.join("java-launch/records/nested/test.json");
|
||||
fs::create_dir_all(test_record.parent().unwrap()).unwrap();
|
||||
fs::write(&test_record, serde_json::to_vec(&first).unwrap()).unwrap();
|
||||
drop(session);
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
let mut session = Session::new(cache.clone());
|
||||
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_eq!(reused.snapshot, second.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
|
||||
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert!(second.snapshot.is_dir());
|
||||
drop(session);
|
||||
assert!(!second.snapshot.exists());
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(third.snapshot.is_dir());
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_sweep_only_removes_unreferenced_direct_snapshot_directories() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cache = dir.path().join("cache");
|
||||
let snapshots = cache.join("java-launch/snapshots");
|
||||
let retained = snapshots.join("launch-retained");
|
||||
let orphan = snapshots.join("launch-orphan");
|
||||
let other = snapshots.join("other");
|
||||
let nested = other.join("launch-nested");
|
||||
let outside = dir.path().join("launch-outside");
|
||||
for path in [&retained, &orphan, &nested, &outside] {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
}
|
||||
fs::write(outside.join("classes"), "keep").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, snapshots.join("launch-link")).unwrap();
|
||||
fs::write(snapshots.join("launch-file"), "keep").unwrap();
|
||||
let record = cache.join("java-launch/records/deep/module/test.json");
|
||||
fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
|
||||
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
|
||||
let mut session = Session::new(cache);
|
||||
session.initialize();
|
||||
assert!(!orphan.exists());
|
||||
assert!(retained.is_dir());
|
||||
assert!(nested.is_dir());
|
||||
assert!(outside.join("classes").is_file());
|
||||
assert!(snapshots.join("launch-link").is_symlink());
|
||||
assert!(snapshots.join("launch-file").is_file());
|
||||
// Initialization must not sweep snapshots still in use by this session.
|
||||
fs::create_dir_all(&orphan).unwrap();
|
||||
session.initialize();
|
||||
assert!(orphan.is_dir());
|
||||
session.unreferenced.extend([outside.clone(), snapshots.join("../../launch-outside"), nested]);
|
||||
drop(session);
|
||||
assert!(outside.join("classes").is_file());
|
||||
assert!(other.join("launch-nested").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_record_publish_retains_snapshot_until_session_drop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
let mut text = fs::read_to_string(&script).unwrap();
|
||||
text.push_str("rm cache/java-launch/records/main.json\nmkdir cache/java-launch/records/main.json\n");
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
|
||||
assert_eq!(session.unreferenced.len(), 1);
|
||||
let snapshot = session.unreferenced[0].clone();
|
||||
assert!(snapshot.join("0/Main.class").is_file());
|
||||
drop(session);
|
||||
assert!(!snapshot.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_during_build_do_not_publish_a_success_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let script = root.join("mvnw");
|
||||
let mut text = fs::read_to_string(&script).unwrap();
|
||||
text.push_str("echo edited-during-build >> src/main/kotlin/Main.kt\n");
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
|
||||
assert!(outputs.fingerprint.is_none());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
assert!(outputs.snapshot.join("0/Main.class").is_file());
|
||||
drop(session);
|
||||
assert!(!outputs.snapshot.exists());
|
||||
}
|
||||
#[test]
|
||||
fn deleting_the_last_mixed_source_keeps_maven_delegation_after_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
|
||||
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
|
||||
assert!(has_record(root, &cache, root));
|
||||
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
|
||||
assert!(has_record(root, &cache, root));
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
|
||||
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_ne!(first.snapshot, restored.snapshot);
|
||||
}
|
||||
}
|
||||
@@ -83,12 +83,18 @@ pub async fn lsp_implementations(
|
||||
result
|
||||
}
|
||||
|
||||
/// Contents of a virtual document (library class, decompiled code) that a
|
||||
/// language server returned a non-`file:` URI for. Cached per project.
|
||||
/// Contents of a read-only language-server document, including JDT library
|
||||
/// classes and disk class files. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
/// Opens compiled classes through JDT, with project source taking priority on disk.
|
||||
pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult<String> {
|
||||
let archive = ide_vfs::split_archive_path(&path);
|
||||
host.project(window)?.lsp.java_class_file(&path, archive).await
|
||||
}
|
||||
|
||||
/// References of the symbol at `pos`. `project_wide` also searches project files
|
||||
/// outside the language server's analysis scope; bulk scans over many symbols
|
||||
/// leave it off. Cancel with `cancel_request(request)`.
|
||||
@@ -208,7 +214,7 @@ pub async fn lsp_format(
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
host.project(window)?.lsp.format(doc, range, options).await
|
||||
crate::formatter::format(host, window, doc, range, options).await
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
@@ -263,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.
|
||||
@@ -367,6 +379,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_implementations", |c| async move {
|
||||
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_resolve_class_file",
|
||||
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("lsp_references", |c| async move {
|
||||
lsp_references(
|
||||
|
||||
@@ -172,11 +172,16 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
if ["languages", "tools", "languageServers", "projectStructure"]
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")))
|
||||
{
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
|
||||
if code_style || toolchain {
|
||||
for project in affected() {
|
||||
if toolchain && let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
let lsp = project.lsp.clone();
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
|
||||
@@ -16,11 +16,15 @@ pub fn spawn(
|
||||
options.args.clear();
|
||||
options.env = global.env;
|
||||
}
|
||||
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
|
||||
let activation =
|
||||
host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| {
|
||||
ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())
|
||||
});
|
||||
let (shell, args) = ide_terminal::resolve_shell(options.shell.as_deref(), &options.args);
|
||||
options.shell = Some(shell.clone());
|
||||
options.args = args;
|
||||
// Python › "Activate virtualenv": typed into the same shell the PTY starts.
|
||||
let activation = host
|
||||
.project(window)
|
||||
.ok()
|
||||
.filter(|p| p.is_trusted())
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
|
||||
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
|
||||
if let Some(line) = activation {
|
||||
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
|
||||
|
||||
@@ -100,11 +100,11 @@ pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec<JdkVendor> {
|
||||
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
|
||||
pub async fn jdk_download_releases(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
_window: &str,
|
||||
vendor: String,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JdkReleases> {
|
||||
let tools = tools(host, window)?;
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
@@ -123,14 +123,14 @@ pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn jdk_download(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
_window: &str,
|
||||
vendor: String,
|
||||
version: u32,
|
||||
location: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let tools = tools(host, window)?;
|
||||
let tools = Tools::new(&host.data_dir);
|
||||
let location = location.filter(|p| !p.as_os_str().is_empty());
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
|
||||
@@ -42,6 +42,8 @@ pub struct Project {
|
||||
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
|
||||
/// Per-project cache directory (build tool sync results…).
|
||||
pub cache_dir: PathBuf,
|
||||
/// Superseded launch snapshots retained until this project session ends.
|
||||
pub(crate) java_launch_snapshots: Mutex<crate::ops::java::cache::Session>,
|
||||
/// Database tool window: data sources and their connections.
|
||||
pub db: Arc<ide_db::DbManager>,
|
||||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||||
@@ -50,6 +52,7 @@ pub struct Project {
|
||||
pub agents: Arc<ide_agent::AgentManager>,
|
||||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -126,6 +129,8 @@ impl Host {
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
let config_dir = self.config_dir.clone();
|
||||
let sink = sink.clone();
|
||||
let formatter_problem = std::sync::Mutex::new(None);
|
||||
lsp.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| {
|
||||
w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count())
|
||||
@@ -139,6 +144,27 @@ impl Host {
|
||||
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
|
||||
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
|
||||
if cfg.name == "jdtls" {
|
||||
let problem = ide_lsp::jdtls::configure_formatter(
|
||||
cfg,
|
||||
effective.editor.code_style.languages.get("java"),
|
||||
root,
|
||||
&data_dir,
|
||||
effective.language_servers.get("jdtls"),
|
||||
)
|
||||
.err()
|
||||
.map(|e| e.to_string());
|
||||
let mut previous = formatter_problem.lock().unwrap();
|
||||
if problem != *previous {
|
||||
if let Some(body) = &problem {
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Warning,
|
||||
title: "Java formatter configuration unavailable".into(),
|
||||
body: Some(body.clone()),
|
||||
});
|
||||
}
|
||||
*previous = problem;
|
||||
}
|
||||
drop(previous);
|
||||
ide_lsp::jdtls::configure_source_paths(
|
||||
cfg,
|
||||
ide_workspace::roots::unmanaged_java_source_paths(
|
||||
@@ -150,6 +176,7 @@ impl Host {
|
||||
let bundles: Vec<PathBuf> = 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);
|
||||
@@ -226,11 +253,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();
|
||||
@@ -251,6 +289,7 @@ impl Host {
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = 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);
|
||||
@@ -330,7 +369,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();
|
||||
@@ -366,10 +405,14 @@ impl Host {
|
||||
syntax,
|
||||
detected_roots,
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: Some(kotlin_bridge),
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
@@ -388,6 +431,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());
|
||||
}
|
||||
@@ -481,10 +527,14 @@ impl Host {
|
||||
syntax,
|
||||
detected_roots: Arc::default(),
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
|
||||
self.data_dir.join("cache").join(&project_key).join("build"),
|
||||
)),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
@@ -778,9 +828,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));
|
||||
}
|
||||
}
|
||||
@@ -912,7 +968,9 @@ fn content_root_watcher(
|
||||
index: &Arc<ProjectIndex>,
|
||||
documents: &Arc<Documents>,
|
||||
history: &Arc<LocalHistory>,
|
||||
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
) -> Option<Watcher> {
|
||||
let kotlin_bridge = kotlin_bridge.cloned();
|
||||
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
|
||||
Watcher::start(
|
||||
dir,
|
||||
@@ -921,6 +979,9 @@ fn content_root_watcher(
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = 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);
|
||||
}),
|
||||
|
||||
@@ -179,6 +179,7 @@ impl Dispatcher {
|
||||
})
|
||||
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
|
||||
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
@@ -190,6 +191,28 @@ impl Dispatcher {
|
||||
})
|
||||
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_copy_merge", |c| async move {
|
||||
ops::fs::copy_merge(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("sourceRoot")?,
|
||||
c.arg("destinationRoot")?,
|
||||
c.arg("path")?,
|
||||
c.arg("overwrite")?,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.add("fs_compare_directories", |c| async move {
|
||||
ops::fs::compare_directories(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("left")?,
|
||||
c.arg("right")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -175,6 +178,30 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
"app_info"
|
||||
| "app_build_info"
|
||||
| "app_log_location"
|
||||
| "app_check_update"
|
||||
| "app_startup_update_check"
|
||||
| "app_install_update"
|
||||
| "caches_schedule_invalidation"
|
||||
| "clear_caches"
|
||||
| "clipboard_file_list"
|
||||
| "clipboard_has_files"
|
||||
| "clipboard_set_file_list"
|
||||
| "jdk_download_vendors"
|
||||
| "jdk_download_releases"
|
||||
| "jdk_download_location"
|
||||
| "jdk_download"
|
||||
| "run_system_env"
|
||||
| "run_gradle_jvm_args_script"
|
||||
| "proxy_check_connection"
|
||||
| "docker_settings"
|
||||
| "docker_save_settings"
|
||||
| "docker_set_registry_password"
|
||||
| "docker_run_env"
|
||||
| "db_console_path"
|
||||
| "db_export_file"
|
||||
| "vcs_write_patch"
|
||||
| "app_metrics"
|
||||
| "app_quit"
|
||||
| "app_cancel_quit"
|
||||
@@ -235,6 +262,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "agent_registry_remove"
|
||||
| "coverage_import"
|
||||
| "system_font_families"
|
||||
| "user_font_data"
|
||||
| "plugin_subscribe"
|
||||
| "plugin_list"
|
||||
| "plugin_set_active_file"
|
||||
|
||||
@@ -104,7 +104,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
|
||||
"main",
|
||||
opened.id,
|
||||
opened.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] },
|
||||
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: r"\u4E16\u754C".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
|
||||
@@ -129,7 +129,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
|
||||
assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
// Partial commits identify hunks in displayed line coordinates as well.
|
||||
let partial_text = displayed.replace("target", "世界").replace("logical", "changed");
|
||||
let partial_text = displayed.replace("target", r"\u4E16\u754C").replace("logical", "changed");
|
||||
project.documents.replace_all(opened.id, &partial_text).unwrap();
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
let hunks = ide_vcs::diff_hunks(displayed, &partial_text);
|
||||
@@ -169,7 +169,7 @@ async fn properties_search_git_and_document_coordinates_agree() {
|
||||
"main",
|
||||
RequestId(520),
|
||||
query,
|
||||
"世界".into(),
|
||||
r"\u4E16\u754C".into(),
|
||||
false,
|
||||
vec![ide_search::ReplaceTarget {
|
||||
path: closed_path.clone().into(),
|
||||
@@ -181,5 +181,6 @@ async fn properties_search_git_and_document_coordinates_agree() {
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C");
|
||||
assert!(project.documents.by_path(&closed_path).is_none());
|
||||
assert_eq!(project.text_of(&closed_path).unwrap(), "value=你好😀世界");
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -94,6 +94,39 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
|
||||
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
|
||||
|
||||
// Directory comparisons, streamed progress and confirmed copies share the remote filesystem.
|
||||
let left = project.join("compare-left");
|
||||
let right = project.join("compare-right");
|
||||
std::fs::create_dir_all(&left).unwrap();
|
||||
std::fs::create_dir_all(&right).unwrap();
|
||||
std::fs::write(left.join("中文.bin"), [0, 255, 1]).unwrap();
|
||||
std::fs::write(right.join("中文.bin"), [0, 254, 1]).unwrap();
|
||||
assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left})).await.unwrap(), "dir");
|
||||
assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left.join("中文.bin")})).await.unwrap(), "file");
|
||||
let compare_args =
|
||||
|request| json!({"left": left, "right": right, "request": request, "onProgress": {"$channel": 520}});
|
||||
let rows = invoke(&peer, "fs_compare_directories", compare_args(520)).await.unwrap();
|
||||
assert_eq!(rows[0]["status"], "different");
|
||||
assert!(client.channels.lock().iter().any(|c| c.id == 520 && c.data["compared"] == 1));
|
||||
let copy_args =
|
||||
|overwrite| json!({"sourceRoot": left, "destinationRoot": right, "path": "中文.bin", "overwrite": overwrite});
|
||||
let error = invoke(&peer, "fs_copy_merge", copy_args(false)).await.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists);
|
||||
invoke(&peer, "fs_copy_merge", copy_args(true)).await.unwrap();
|
||||
let rows = invoke(&peer, "fs_compare_directories", compare_args(521)).await.unwrap();
|
||||
assert_eq!(rows[0]["status"], "equal");
|
||||
invoke(&peer, "cancel_request", json!({"id": 522})).await.unwrap();
|
||||
let error = invoke(&peer, "fs_compare_directories", compare_args(522)).await.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled);
|
||||
let error = invoke(
|
||||
&peer,
|
||||
"fs_copy_merge",
|
||||
json!({"sourceRoot": left, "destinationRoot": right, "path": "../escape", "overwrite": true}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
|
||||
// JVM icons use the same read-only protocol as local windows.
|
||||
let java = project.join("src/Foo.java");
|
||||
let kotlin = project.join("src/Singleton.kt");
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Own test process: the inherited shell deliberately differs from the login shell.
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn terminal_activation_uses_the_resolved_default_shell() {
|
||||
// SAFETY: no runtime or worker threads have been started.
|
||||
unsafe { std::env::set_var("SHELL", "/not-the-terminal/docker") };
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
let bin = root.join(".venv/bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap();
|
||||
std::fs::write(bin.join("python"), "").unwrap();
|
||||
for script in ["activate", "activate.fish", "activate.csh", "activate.nu", "Activate.ps1"] {
|
||||
std::fs::write(bin.join(script), "echo activated > activation.marker\n").unwrap();
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
|
||||
runtime.block_on(async {
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
for shell in [None, Some("".to_owned()), Some(" \t ".to_owned())] {
|
||||
let terminal = ops::term::spawn(
|
||||
&host,
|
||||
"main",
|
||||
ide_terminal::TerminalOptions {
|
||||
cwd: root.clone().into(),
|
||||
shell,
|
||||
args: vec![],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shell_integration: false,
|
||||
},
|
||||
Arc::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !std::fs::read_to_string(root.join("activation.marker")).is_ok_and(|s| s.trim() == "activated") {
|
||||
assert!(Instant::now() < deadline, "default terminal shell must run the activation script");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
host.terminals.kill(terminal.id).unwrap();
|
||||
std::fs::remove_file(root.join("activation.marker")).unwrap();
|
||||
}
|
||||
host.shutdown().await;
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,94 @@ fn host(base: &Path, environment: bool) -> Arc<Host> {
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn application_commands_work_in_restricted_projects_and_without_a_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
assert!(host.project("welcome").is_err());
|
||||
assert!(ide_host::ops::project::roots(&host, "welcome").is_err());
|
||||
assert!(!host.open_project("main", &root).unwrap().is_trusted());
|
||||
for command in [
|
||||
"app_info",
|
||||
"app_build_info",
|
||||
"app_log_location",
|
||||
"app_check_update",
|
||||
"app_startup_update_check",
|
||||
"app_install_update",
|
||||
"caches_schedule_invalidation",
|
||||
"clear_caches",
|
||||
"clipboard_file_list",
|
||||
"clipboard_has_files",
|
||||
"clipboard_set_file_list",
|
||||
"jdk_download_vendors",
|
||||
"jdk_download_releases",
|
||||
"jdk_download_location",
|
||||
"jdk_download",
|
||||
"run_system_env",
|
||||
"run_gradle_jvm_args_script",
|
||||
"proxy_check_connection",
|
||||
"docker_settings",
|
||||
"docker_save_settings",
|
||||
"docker_set_registry_password",
|
||||
"docker_run_env",
|
||||
"db_console_path",
|
||||
"db_export_file",
|
||||
"vcs_write_patch",
|
||||
] {
|
||||
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
for window in ["main", "welcome"] {
|
||||
assert!(host.authorize_command(window, command).is_ok(), "{window}: {command}");
|
||||
}
|
||||
}
|
||||
for command in [
|
||||
"run_start",
|
||||
"build_sync",
|
||||
"debug_start",
|
||||
"sdk_detect",
|
||||
"tools_list",
|
||||
"tools_install",
|
||||
"tools_uninstall",
|
||||
"plugin_install_dir",
|
||||
"plugin_reload",
|
||||
"vcs_git_version",
|
||||
"docker_compose_op",
|
||||
"db_execute",
|
||||
"unknown_command",
|
||||
] {
|
||||
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
|
||||
for window in ["main", "welcome"] {
|
||||
assert!(host.authorize_command(window, command).is_err(), "{window}: {command}");
|
||||
}
|
||||
}
|
||||
// These requests validate the vendor before any network access, independently of project trust.
|
||||
for window in ["main", "welcome"] {
|
||||
let error = ide_host::ops::tools::jdk_download_releases(
|
||||
&host,
|
||||
window,
|
||||
"unknown-vendor".into(),
|
||||
ide_core::RequestId(901),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
let error = ide_host::ops::tools::jdk_download(
|
||||
&host,
|
||||
window,
|
||||
"unknown-vendor".into(),
|
||||
25,
|
||||
None,
|
||||
ide_core::RequestId(902),
|
||||
ide_host::ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
}
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -53,6 +53,19 @@ pub struct LanguageConfig {
|
||||
pub language_servers: Vec<LanguageServerConfig>,
|
||||
#[serde(default)]
|
||||
pub debugger: Option<DebuggerConfig>,
|
||||
/// External formatter used when no language server can format, or selected in settings.
|
||||
#[serde(default)]
|
||||
pub formatter: Option<FormatterConfig>,
|
||||
}
|
||||
|
||||
/// An external formatting tool and its language-specific parser.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct FormatterConfig {
|
||||
pub tool: String,
|
||||
pub parser: String,
|
||||
#[serde(default)]
|
||||
pub range: bool,
|
||||
}
|
||||
|
||||
/// A CodeMirror mode for the files of a framework's projects.
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-language.workspace = true
|
||||
|
||||
@@ -17,6 +17,9 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SERVER: &str = "jdtls";
|
||||
|
||||
mod formatter;
|
||||
pub use formatter::configure_formatter;
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
/// A workspace build of a large multi-module project can take far longer than
|
||||
@@ -189,6 +192,24 @@ pub struct JavaCompileError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Holds off JDT writers while an external compiler builds and snapshots launch outputs.
|
||||
/// Restores current settings even when preparation fails or is cancelled.
|
||||
pub struct JavaExternalBuild {
|
||||
server: Arc<Server>,
|
||||
run: Running,
|
||||
_lock: tokio::sync::OwnedMutexGuard<()>,
|
||||
}
|
||||
|
||||
impl Drop for JavaExternalBuild {
|
||||
fn drop(&mut self) {
|
||||
self.server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
let settings = crate::handler::all_settings(&self.server.effective_config());
|
||||
if let Err(e) = self.run.peer.notify("workspace/didChangeConfiguration", json!({ "settings": settings })) {
|
||||
tracing::debug!("jdtls: restoring autobuild: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error diagnostics sorted by file and position, capped at `limit`, and their total.
|
||||
pub fn compile_errors(
|
||||
diagnostics: Vec<(PathBuf, lsp_types::Diagnostic)>,
|
||||
@@ -378,7 +399,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,
|
||||
@@ -433,9 +455,28 @@ impl LspManager {
|
||||
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
|
||||
}
|
||||
|
||||
/// Pause autobuild before resolving the launch classpath. That query takes the
|
||||
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
|
||||
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
|
||||
pub async fn java_external_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaExternalBuild> {
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
let lock = cancellable_wait(cancel, server.java_build_lock.clone().lock_owned()).await?;
|
||||
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let guard = JavaExternalBuild { server, run, _lock: lock };
|
||||
let settings = crate::handler::all_settings(&guard.server.effective_config());
|
||||
guard
|
||||
.run
|
||||
.peer
|
||||
.notify("workspace/didChangeConfiguration", json!({ "settings": settings }))
|
||||
.map_err(|e| crate::rpc_err(SERVER, "workspace/didChangeConfiguration", e))?;
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
|
||||
/// class files are up to date before a launch.
|
||||
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
|
||||
let (server, _) = self.jdtls(path, cancel).await?;
|
||||
let _lock = cancellable_wait(cancel, server.java_build_lock.lock()).await?;
|
||||
let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
// The first build can finish the Maven import before ServiceReady is handled.
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
@@ -532,6 +573,59 @@ impl LspManager {
|
||||
collect()
|
||||
}
|
||||
|
||||
/// Resolve a disk/archive class to project source or the server's binary document.
|
||||
/// Archive URIs must come from JDT's Java model: fabricated handles cannot map debug lines.
|
||||
pub async fn java_class_file(&self, path: &Path, archive: Option<(PathBuf, String)>) -> IdeResult<String> {
|
||||
let cancel = CancellationToken::new();
|
||||
self.jdtls(&self.project_root, &cancel).await?;
|
||||
if let Some((jar, entry)) = archive {
|
||||
let entry = entry
|
||||
.strip_prefix("META-INF/versions/")
|
||||
.and_then(|s| s.split_once('/').map(|(_, s)| s))
|
||||
.unwrap_or(&entry);
|
||||
let class = entry.strip_suffix(".class").ok_or_else(|| IdeError::not_found("expected a class file"))?;
|
||||
let outer = class.split('$').next().unwrap_or(class);
|
||||
let (package, name) = outer.rsplit_once('/').unwrap_or(("", outer));
|
||||
let query = outer.replace('/', ".");
|
||||
let symbols = self.workspace_symbols(&query, cancel).await?;
|
||||
let package = package.replace('/', ".");
|
||||
let name = name.to_owned();
|
||||
let outer = outer.to_owned();
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
symbols
|
||||
.into_iter()
|
||||
.filter_map(|s| s.location.uri)
|
||||
.find(|uri| crate::virtual_doc::matches_class(uri, &jar, &package, &name))
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found(format!("jdtls could not resolve {} in {}", outer, jar.display()))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
let uri = crate::uri::path_to_uri_string(path);
|
||||
// classFileContents accepts file URIs, but java-debug's line mapping only accepts JDT handles.
|
||||
let doc = self.virtual_document(&uri).await?;
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").split('$').next().unwrap_or("");
|
||||
let package = doc
|
||||
.text
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("package ").and_then(|p| p.strip_suffix(';')))
|
||||
.unwrap_or("");
|
||||
let relative = PathBuf::from(package.replace('.', "/")).join(format!("{name}.java"));
|
||||
let key = "org.eclipse.jdt.ls.core.sourcePaths";
|
||||
let settings = self
|
||||
.jdtls_project_command(path, "java.project.getSettings", vec![json!(uri), json!([key])], &cancel)
|
||||
.await?;
|
||||
let roots: Vec<PathBuf> =
|
||||
settings[key].as_array().into_iter().flatten().filter_map(Value::as_str).map(PathBuf::from).collect();
|
||||
let source =
|
||||
tokio::task::spawn_blocking(move || roots.into_iter().map(|r| r.join(&relative)).find(|p| p.is_file()))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok(source.map(|s| s.to_string_lossy().into_owned()).unwrap_or(uri))
|
||||
}
|
||||
|
||||
/// Starts (or reuses) java-debug's DAP server inside jdtls and returns its port.
|
||||
pub async fn java_debug_server(&self, path: &Path) -> IdeResult<u16> {
|
||||
let v = self.jdtls_command(path, "vscode.java.startDebugSession", Vec::new()).await.map_err(|e| {
|
||||
@@ -542,7 +636,7 @@ impl LspManager {
|
||||
}
|
||||
})?;
|
||||
// Hot code replace after each (auto-)build; errors only cost the feature.
|
||||
let settings = json!({ "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
|
||||
let settings = json!({ "debugSupportOnDecompiledSource": "on", "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
|
||||
"showLogicalStructure": true, "showToString": true, "maxStringLength": 0,
|
||||
// java-debug NPEs on a missing level (java.util.logging names).
|
||||
"logLevel": "WARNING" });
|
||||
@@ -574,6 +668,7 @@ mod tests {
|
||||
assert_eq!(settings["java"]["project"]["sourcePaths"], json!(["src/main/java", "tests"]));
|
||||
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["lib/**/*.jar"]));
|
||||
assert_eq!(settings["java"]["autobuild"]["enabled"], true);
|
||||
assert_eq!(settings["java"]["contentProvider"]["preferred"], "fernflowerContentProvider");
|
||||
assert!(settings.get("bundles").is_none());
|
||||
assert!(settings.get("settings").is_none(), "didChangeConfiguration takes java settings directly");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::editor_settings::LanguageCodeStyle;
|
||||
use ide_core::settings::LanguageServerSettings;
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
|
||||
const XML: &str = include_str!("intellij.xml");
|
||||
|
||||
/// Protocol settings only: switching profiles reaches a running jdtls through
|
||||
/// didChangeConfiguration. Explicit language-server settings are applied later.
|
||||
pub fn configure_formatter(
|
||||
cfg: &mut LanguageServerConfig,
|
||||
style: Option<&LanguageCodeStyle>,
|
||||
project_root: &Path,
|
||||
data_dir: &Path,
|
||||
overrides: Option<&LanguageServerSettings>,
|
||||
) -> IdeResult<()> {
|
||||
if cfg.name != "jdtls" {
|
||||
return Ok(());
|
||||
}
|
||||
// jdtls retains omitted preferences. Send empty values when returning to
|
||||
// Default, while preserving any profile from the base server configuration.
|
||||
let inherited = crate::handler::all_settings(cfg);
|
||||
let defaults = json!({
|
||||
"url": inherited.pointer("/java/format/settings/url").cloned().unwrap_or(json!("")),
|
||||
"profile": inherited.pointer("/java/format/settings/profile").cloned().unwrap_or(json!("")),
|
||||
});
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
|
||||
cfg.settings = Some(settings);
|
||||
let Some(style) = style else { return Ok(()) };
|
||||
let (path, profile) = match style.formatter.as_deref() {
|
||||
Some("intellij") => {
|
||||
let path = data_dir.join("lsp/jdtls/intellij.xml");
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) {
|
||||
ide_vfs::write_atomic(&path, XML.as_bytes())?;
|
||||
}
|
||||
(path, PROFILE)
|
||||
}
|
||||
Some("custom") => {
|
||||
let value = style.formatter_path.as_deref().unwrap_or_default().trim();
|
||||
if value.is_empty() {
|
||||
return Err(ide_core::IdeError::invalid("Choose an Eclipse formatter XML file."));
|
||||
}
|
||||
let path = project_root.join(value);
|
||||
// Fail without poisoning the server launch; code intelligence remains available.
|
||||
std::fs::File::open(&path).map_err(|error| {
|
||||
let path = path.display();
|
||||
ide_core::IdeError::invalid(format!("Cannot read Java formatter XML {path}: {error}"))
|
||||
})?;
|
||||
if !path.is_file() {
|
||||
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
|
||||
}
|
||||
(path, "")
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
settings["java"]["format"]["settings"] = formatter_overrides(
|
||||
json!({
|
||||
"url": crate::uri::path_to_uri_string(&path),
|
||||
"profile": profile,
|
||||
}),
|
||||
overrides,
|
||||
);
|
||||
cfg.settings = Some(settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn formatter_overrides(mut format: serde_json::Value, overrides: Option<&LanguageServerSettings>) -> serde_json::Value {
|
||||
if let Some(overrides) = overrides {
|
||||
// all_settings merges protocol settings over initializationOptions.
|
||||
// Mirror explicit formatter keys so generated protocol values cannot
|
||||
// accidentally mask an advanced initializationOptions override.
|
||||
for layer in [
|
||||
overrides.initialization_options.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
|
||||
overrides.settings.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
|
||||
overrides.settings.as_ref().and_then(|v| v.pointer("/java/format/settings")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for key in ["url", "profile"] {
|
||||
if let Some(value) = layer.get(key) {
|
||||
format[key] = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
format
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(name: &str) -> LanguageServerConfig {
|
||||
serde_json::from_value(json!({"name": name, "command": name})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_resolve_paths_preserve_settings_and_leave_other_servers_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("project with 空格");
|
||||
let data = dir.path().join("data");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let custom = root.join("style.xml");
|
||||
std::fs::write(&custom, XML).unwrap();
|
||||
for name in ["jdtls", "rust-analyzer"] {
|
||||
for mode in ["default", "intellij", "custom", "unknown"] {
|
||||
let mut cfg = LanguageServerConfig {
|
||||
name: name.into(),
|
||||
settings: Some(json!({"java": {"autobuild": {"enabled": false}}})),
|
||||
..config(name)
|
||||
};
|
||||
let initial = cfg.clone();
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some(mode.into()),
|
||||
formatter_path: Some("style.xml".into()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
|
||||
if name != "jdtls" {
|
||||
assert_eq!(cfg, initial);
|
||||
continue;
|
||||
}
|
||||
let settings = crate::handler::all_settings(&cfg);
|
||||
if !["intellij", "custom"].contains(&mode) {
|
||||
assert_eq!(settings["java"]["format"]["settings"], json!({"url": "", "profile": ""}));
|
||||
continue;
|
||||
}
|
||||
let path = if mode == "custom" { custom.clone() } else { data.join("lsp/jdtls/intellij.xml") };
|
||||
assert_eq!(settings["java"]["format"]["settings"]["url"], crate::uri::path_to_uri_string(&path));
|
||||
assert_eq!(
|
||||
settings["java"]["format"]["settings"]["profile"],
|
||||
if mode == "custom" { "" } else { PROFILE }
|
||||
);
|
||||
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), XML);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for path in ["", "missing.xml", "."] {
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some("custom".into()),
|
||||
formatter_path: Some(path.into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), None).is_err());
|
||||
assert!(cfg.launch_error.is_none());
|
||||
assert_eq!(crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_keeps_explicit_server_configuration_and_custom_accepts_absolute_paths() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("custom.xml");
|
||||
std::fs::write(&path, XML).unwrap();
|
||||
let mut cfg = config("jdtls");
|
||||
cfg.initialization_options = Some(
|
||||
toml::Value::try_from(json!({
|
||||
"settings": {"java": {"format": {"settings": {"url": "existing.xml", "profile": "Existing"}}}}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
configure_formatter(&mut cfg, None, dir.path(), dir.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
|
||||
json!({"url": "existing.xml", "profile": "Existing"})
|
||||
);
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some("custom".into()),
|
||||
formatter_path: Some(path.to_string_lossy().into_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), &dir.path().join("different-root"), dir.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"],
|
||||
crate::uri::path_to_uri_string(&path)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn advanced_formatter_options_win_for_each_profile_and_protocol_layer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("custom.xml"), XML).unwrap();
|
||||
for mode in ["default", "intellij", "custom"] {
|
||||
for layer in ["initialization", "wrapped", "direct"] {
|
||||
let mut overrides = LanguageServerSettings {
|
||||
initialization_options: Some(json!({"settings": {"java": {"format": {"settings": {
|
||||
"url": "advanced.xml", "profile": "Advanced"
|
||||
}}}}})),
|
||||
..Default::default()
|
||||
};
|
||||
if layer != "initialization" {
|
||||
let value = json!({"java": {"format": {"settings": {"profile": "Protocol"}}}});
|
||||
overrides.settings = Some(if layer == "wrapped" { json!({"settings": value}) } else { value });
|
||||
}
|
||||
let mut cfg = config("jdtls");
|
||||
let style = LanguageCodeStyle {
|
||||
formatter: Some(mode.into()),
|
||||
formatter_path: Some("custom.xml".into()),
|
||||
..Default::default()
|
||||
};
|
||||
configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), Some(&overrides)).unwrap();
|
||||
assert_eq!(
|
||||
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
|
||||
json!({
|
||||
"url": "advanced.xml",
|
||||
"profile": if layer == "initialization" { "Advanced" } else { "Protocol" },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
IntelliJ IDEA Java defaults, approximated using Eclipse JDT formatter options.
|
||||
Source revision: JetBrains/intellij-community 8fe3709d185cd13053d17b76d9690aef674d4268
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CommonCodeStyleSettings.java
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleSettings.java
|
||||
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleDefaults.java
|
||||
java/java-frontback-impl/src/com/intellij/psi/codeStyle/JavaCodeStyleSettings.java
|
||||
JavaLanguageCodeStyleSettingsProvider.customizeDefaults does not override these values.
|
||||
https://www.jetbrains.com/help/idea/code-style-java.html describes the corresponding controls.
|
||||
Each option below names its IDEA setting; JDT has no exact equivalent for every rule.
|
||||
A formatter profile does not sort/group imports (Organize Imports is a separate action).
|
||||
Preserve IDEA KEEP_LINE_BREAKS=true using Wrap where necessary (WRAP_COMPACT) and
|
||||
join_wrapped_lines=false (Never join already wrapped lines). JDT NO_SPLIT joins some
|
||||
manual parameter/call/chain breaks even when joining is disabled. Preserving manual
|
||||
breaks takes priority: wrappable code exceeding 120 columns will also wrap, a known
|
||||
difference from IDEA defaults (DO_NOT_WRAP / WRAP_LONG_LINES=false).
|
||||
Alignment values: 16 = WRAP_COMPACT + INDENT_DEFAULT; 18 = WRAP_COMPACT + INDENT_ON_COLUMN.
|
||||
The comment width 999999 approximates WRAP_COMMENTS=false without JDT integer overflow.
|
||||
JDT has a shared blank-line preservation limit and different empty-body/Javadoc handling.
|
||||
-->
|
||||
<profiles version="23">
|
||||
<profile kind="CodeFormatterProfile" name="OpenIDEA IntelliJ IDEA" version="23">
|
||||
<!-- IndentOptions.USE_TAB_CHARACTER = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
|
||||
<!-- IndentOptions.TAB_SIZE = 4 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
|
||||
<!-- IndentOptions.INDENT_SIZE = 4 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
|
||||
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 (two 4-space units) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
|
||||
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
|
||||
<!-- CodeStyleSettings.RIGHT_MARGIN = 120; wrap where necessary to preserve manual line breaks (see known difference above) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
|
||||
<!-- KEEP_LINE_BREAKS = true; Never join already wrapped lines -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="false"/>
|
||||
<!-- KEEP_BLANK_LINES_IN_DECLARATIONS / IN_CODE / BEFORE_RBRACE = 2 (JDT has one shared limit) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="2"/>
|
||||
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="18"/>
|
||||
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="18"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
|
||||
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="16"/>
|
||||
<!-- METHOD_CALL_CHAIN_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
|
||||
<!-- ARRAY_INITIALIZER_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
|
||||
<!-- ASSIGNMENT_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="16"/>
|
||||
<!-- TERNARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="16"/>
|
||||
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
|
||||
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
|
||||
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
|
||||
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
|
||||
<!-- RESOURCE_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/>
|
||||
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
|
||||
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
|
||||
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
|
||||
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="do not insert"/>
|
||||
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="do not insert"/>
|
||||
<!-- BLANK_LINES_BEFORE_PACKAGE = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
|
||||
<!-- BLANK_LINES_AFTER_PACKAGE = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
|
||||
<!-- BLANK_LINES_BEFORE_IMPORTS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
|
||||
<!-- BLANK_LINES_AFTER_IMPORTS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_FIELD / BLANK_LINES_AROUND_FIELD_IN_INTERFACE = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD_IN_INTERFACE = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/>
|
||||
<!-- BLANK_LINES_AROUND_CLASS = 1 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
|
||||
<!-- BLANK_LINES_AFTER_CLASS_HEADER = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
|
||||
<!-- BLANK_LINES_BEFORE_CLASS_END = 0 (minimum) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/>
|
||||
<!-- BLANK_LINES_AROUND_METHOD / AROUND_CLASS = 1; JDT chunks separate member kinds, not consecutive fields -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
|
||||
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
|
||||
<!-- INDENT_CASE_FROM_SWITCH = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="true"/>
|
||||
<!-- INDENT_BREAK_FROM_CASE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
|
||||
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="insert"/>
|
||||
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE / RBRACE_ON_NEXT_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="true"/>
|
||||
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
|
||||
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
|
||||
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
|
||||
<!-- WRAP_COMMENTS = false; KEEP_FIRST_COLUMN_COMMENT = true (JDT comment formatting disabled to preserve text) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="false"/>
|
||||
<!-- WRAP_COMMENTS = false (JDT comment formatting disabled to preserve text) -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
|
||||
<!-- JavaCodeStyleSettings.ENABLE_JAVADOC_FORMATTING = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
|
||||
<!-- WRAP_COMMENTS = false; JDT has a separate comment width, so disable its width-based wrapping -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="999999"/>
|
||||
<!-- JavaCodeStyleSettings.JD_INDENT_ON_CONTINUATION = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
|
||||
<!-- JavaCodeStyleSettings.JD_PARAM_DESCRIPTION_ON_NEW_LINE = false -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="false"/>
|
||||
<!-- CodeStyleSettings.FORMATTER_TAGS_ENABLED = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="true"/>
|
||||
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
|
||||
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="true"/>
|
||||
</profile>
|
||||
</profiles>
|
||||
@@ -1886,6 +1886,12 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks initialized capabilities, including dynamic registrations, before choosing a formatter.
|
||||
pub async fn can_format(&self, doc: DocId) -> IdeResult<bool> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
Ok(!self.ready_targets(&snap.servers, |c| c.has("documentFormattingProvider")).await.is_empty())
|
||||
}
|
||||
|
||||
pub async fn format(&self, doc: DocId, range: Option<Range>, options: FormatOptions) -> IdeResult<Vec<TextEdit>> {
|
||||
let (method, cap, feature) = match range {
|
||||
None => ("textDocument/formatting", "documentFormattingProvider", "formatting"),
|
||||
@@ -2166,6 +2172,7 @@ impl LspManager {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let scheme = virtual_doc::scheme(uri).to_owned();
|
||||
let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class"));
|
||||
let origin = self.origin_server(uri);
|
||||
let mut servers: Vec<Arc<Server>> = origin.iter().cloned().collect();
|
||||
servers.extend(self.servers.lock().iter().filter(|s| Some(s.id) != origin.as_ref().map(|o| o.id)).cloned());
|
||||
@@ -2174,7 +2181,7 @@ impl LspManager {
|
||||
for t in &targets {
|
||||
let languages = t.server.languages();
|
||||
let serves_scheme = t.caps().content_schemes().contains(&scheme);
|
||||
let result: IdeResult<Option<String>> = if serves_scheme {
|
||||
let result: IdeResult<Option<String>> = if serves_scheme && !class_file {
|
||||
self.call::<_, Option<Value>>(
|
||||
t,
|
||||
"workspace/textDocumentContent",
|
||||
@@ -2184,7 +2191,7 @@ impl LspManager {
|
||||
)
|
||||
.await
|
||||
.map(|v| v.and_then(|v| v.get("text").and_then(Value::as_str).map(str::to_owned)))
|
||||
} else if scheme == "jdt" && languages.iter().any(|l| l.as_str() == "java") {
|
||||
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
|
||||
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
|
||||
.await
|
||||
} else {
|
||||
|
||||
@@ -99,6 +99,8 @@ pub struct Server {
|
||||
/// Woken when the last `$/progress` task ends.
|
||||
idle_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
|
||||
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
|
||||
pub external_diagnostics: std::sync::atomic::AtomicBool,
|
||||
pub external_refresh_queued: std::sync::atomic::AtomicBool,
|
||||
@@ -126,6 +128,8 @@ impl Server {
|
||||
published_notify: Notify::new(),
|
||||
idle_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
|
||||
external_project: tokio::sync::Mutex::new(Default::default()),
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
@@ -134,7 +138,13 @@ impl Server {
|
||||
|
||||
/// Configuration of the current (or last) instance; the base config before the first launch.
|
||||
pub fn effective_config(&self) -> LanguageServerConfig {
|
||||
self.effective.lock().clone().unwrap_or_else(|| self.config.clone())
|
||||
let mut config = self.effective.lock().clone().unwrap_or_else(|| self.config.clone());
|
||||
if self.java_autobuild_suspended.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
let mut settings = crate::handler::all_settings(&config);
|
||||
settings["java"]["autobuild"]["enabled"] = serde_json::json!(false);
|
||||
config.settings = Some(settings);
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
pub fn set_effective_config(&self, config: LanguageServerConfig) {
|
||||
@@ -553,6 +563,19 @@ mod tests {
|
||||
assert_eq!(s.record_crash(), Some(Duration::from_millis(500)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_build_suspends_only_the_effective_autobuild_setting() {
|
||||
let mut config: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
|
||||
config.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": true}, "other": "kept"}}));
|
||||
let server = Server::new(config.clone(), PathBuf::from("/p"), LanguageId::new("java"));
|
||||
server.java_autobuild_suspended.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let settings = crate::handler::all_settings(&server.effective_config());
|
||||
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
|
||||
assert_eq!(settings["java"]["other"], "kept");
|
||||
server.java_autobuild_suspended.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(server.effective_config(), config);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_start_reports_its_status_before_waiters_wake() {
|
||||
// The race behind "jdtls is not ready (Starting)": a request woken by the
|
||||
|
||||
@@ -219,6 +219,9 @@ pub struct CodeActionQuery {
|
||||
pub struct FormatOptions {
|
||||
pub tab_size: u32,
|
||||
pub insert_spaces: bool,
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub on_save: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -335,7 +338,7 @@ pub struct VirtualDocument {
|
||||
pub title: String,
|
||||
/// Origin shown in the tooltip / banner, e.g. the jar path.
|
||||
pub description: Option<String>,
|
||||
/// Generated by a decompiler / disassembler rather than real sources.
|
||||
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
|
||||
pub decompiled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
//! Virtual documents: read-only contents a language server provides for a
|
||||
//! non-`file:` URI (library classes from `jdt://`, `workspace/textDocumentContent`).
|
||||
//! URI (library classes from `jdt://`, disk classes from `file://`, or
|
||||
//! `workspace/textDocumentContent`).
|
||||
|
||||
use percent_encoding::percent_decode_str;
|
||||
|
||||
/// Markers decompilers / disassemblers put at the top of generated code.
|
||||
const GENERATED_MARKERS: [&str; 5] = [
|
||||
"generated by the fernflower decompiler",
|
||||
"failed to get sources",
|
||||
"decompiled with cfr",
|
||||
"decompiled with procyon",
|
||||
"generated by the disassembler",
|
||||
];
|
||||
|
||||
/// Whether `text` is decompiled / disassembled code rather than real source.
|
||||
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
|
||||
pub fn is_decompiled(text: &str) -> bool {
|
||||
let head: String = text.chars().take(600).collect::<String>().to_ascii_lowercase();
|
||||
GENERATED_MARKERS.iter().any(|m| head.contains(m))
|
||||
let head: String = text.chars().take(600).collect();
|
||||
head.lines()
|
||||
.take(12)
|
||||
.take_while(|line| {
|
||||
let line = line.trim();
|
||||
line.is_empty() || line.starts_with("//") || line.starts_with("/*") || line.starts_with('*')
|
||||
})
|
||||
.any(|line| {
|
||||
let line = line.trim().trim_start_matches('/').trim_start_matches('*').trim().to_ascii_lowercase();
|
||||
[
|
||||
"source code is unavailable, and was generated by the fernflower decompiler.",
|
||||
"source code is decompiled from a .class file using fernflower decompiler",
|
||||
"decompiled with cfr",
|
||||
"decompiled with procyon",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| line.starts_with(marker))
|
||||
})
|
||||
}
|
||||
|
||||
/// `scheme` of a URI (`jdt` for `jdt://…`).
|
||||
@@ -34,7 +42,16 @@ fn segments(uri: &str) -> Vec<String> {
|
||||
/// Editor tab title: the last path segment; a class file whose real source
|
||||
/// was found is shown as `.java` (IDEA: `String.java` vs `String.class`).
|
||||
pub fn title(uri: &str, decompiled: bool) -> String {
|
||||
let name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
|
||||
let mut name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
|
||||
// Recent jdtls versions use a .java path even for decompiled class handles.
|
||||
if decompiled
|
||||
&& scheme(uri) == "jdt"
|
||||
&& name.ends_with(".java")
|
||||
&& uri.split_once('?').is_some_and(|(_, q)| percent_decode_str(q).decode_utf8_lossy().contains(".class"))
|
||||
{
|
||||
name.truncate(name.len() - 5);
|
||||
name.push_str(".class");
|
||||
}
|
||||
match name.strip_suffix(".class") {
|
||||
Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"),
|
||||
_ => name,
|
||||
@@ -68,6 +85,19 @@ pub fn description(uri: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check both the package and actual archive, so duplicate type names never pick another jar.
|
||||
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
|
||||
let segs = segments(uri);
|
||||
scheme(uri) == "jdt"
|
||||
&& (package.is_empty() && segs.len() == 3 || segs.get(2).is_some_and(|s| s == package))
|
||||
&& segs.last().is_some_and(|s| s == &format!("{name}.class") || s == &format!("{name}.java"))
|
||||
&& description(uri).is_some_and(|p| {
|
||||
let path = std::path::Path::new(&p);
|
||||
path == jar
|
||||
|| dunce::canonicalize(path).ok().zip(dunce::canonicalize(jar).ok()).is_some_and(|(a, b)| a == b)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -84,6 +114,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn jdt_description() {
|
||||
assert!(matches_class(
|
||||
JDT,
|
||||
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
|
||||
"org.springframework.boot",
|
||||
"SpringApplication"
|
||||
));
|
||||
assert!(!matches_class(
|
||||
JDT,
|
||||
std::path::Path::new("/elsewhere/spring-boot-4.1.1.jar"),
|
||||
"org.springframework.boot",
|
||||
"SpringApplication"
|
||||
));
|
||||
assert!(!matches_class(
|
||||
JDT,
|
||||
std::path::Path::new("/Users/me/.m2/repository/spring-boot-4.1.1.jar"),
|
||||
"other.package",
|
||||
"SpringApplication"
|
||||
));
|
||||
assert!(matches_class(
|
||||
"jdt://contents/a.jar//C.java?=p/%5C/m2%5C/a.jar%3C%28C.class",
|
||||
std::path::Path::new("/m2/a.jar"),
|
||||
"",
|
||||
"C"
|
||||
));
|
||||
assert_eq!(description(JDT).as_deref(), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar"));
|
||||
assert_eq!(
|
||||
description(
|
||||
@@ -98,10 +152,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_generated_code() {
|
||||
assert!(is_decompiled(
|
||||
"// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage a;"
|
||||
));
|
||||
assert!(!is_decompiled("package a;\n// generated by the fernflower decompiler"));
|
||||
assert!(!is_decompiled("// Documentation about code generated by the Fernflower decompiler.\npackage a;"));
|
||||
let recent = "jdt://contents/a.jar/p/C.java?=p/a.jar%3Cp%28C.class";
|
||||
assert_eq!(title(recent, true), "C.class");
|
||||
assert_eq!(title(recent, false), "C.java");
|
||||
assert!(is_decompiled(
|
||||
"// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage a;"
|
||||
));
|
||||
assert!(is_decompiled(
|
||||
assert!(!is_decompiled(
|
||||
"// Failed to get sources. Instead, stub sources have been generated by the disassembler."
|
||||
));
|
||||
assert!(!is_decompiled("/*\n * Copyright 2012-2024 the original author or authors.\n */\npackage a;"));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package demo;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
@Deprecated @SuppressWarnings("unused") public class FormatterSample {
|
||||
@Deprecated @SuppressWarnings("unused") private String first="first";
|
||||
private String second="second";
|
||||
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
|
||||
@Deprecated @SuppressWarnings("unused") public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName){if(firstParameterWithAVeryLongName==null){return "none";}else{try{return List.of(firstParameterWithAVeryLongName,secondParameterWithAVeryLongName,thirdParameterWithAVeryLongName).stream().map(value->value.trim()).filter(value->!value.isEmpty()).findFirst().orElse("none");}catch(RuntimeException problem){throw problem;}finally{System.out.println("done");}}}
|
||||
public void multiline(String firstParameter,
|
||||
String secondParameter,
|
||||
String thirdParameter){
|
||||
combine(firstParameter,
|
||||
secondParameter,
|
||||
thirdParameter);
|
||||
List.of(firstParameter, secondParameter)
|
||||
.stream()
|
||||
.map(value->value.trim())
|
||||
.forEach(System.out::println);
|
||||
}
|
||||
public void retained(String firstParameter, // retain declaration break
|
||||
String secondParameter){combine(firstParameter, // retain call break
|
||||
secondParameter, "third");}
|
||||
public void arrays(){int[] values=new int[]{1,2,3};int[] empty=new int[]{};Function<String,String> clean=value->{return value.trim();};Runnable run=()->{System.out.println(values.length);};}
|
||||
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package demo;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
@Deprecated
|
||||
@SuppressWarnings("unused")
|
||||
public class FormatterSample {
|
||||
@Deprecated
|
||||
@SuppressWarnings("unused")
|
||||
private String first = "first";
|
||||
private String second = "second";
|
||||
|
||||
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
|
||||
@Deprecated
|
||||
@SuppressWarnings("unused")
|
||||
public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName,
|
||||
String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName) {
|
||||
if (firstParameterWithAVeryLongName == null) {
|
||||
return "none";
|
||||
} else {
|
||||
try {
|
||||
return List
|
||||
.of(firstParameterWithAVeryLongName, secondParameterWithAVeryLongName,
|
||||
thirdParameterWithAVeryLongName)
|
||||
.stream().map(value -> value.trim()).filter(value -> !value.isEmpty()).findFirst()
|
||||
.orElse("none");
|
||||
} catch (RuntimeException problem) {
|
||||
throw problem;
|
||||
} finally {
|
||||
System.out.println("done");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void multiline(String firstParameter,
|
||||
String secondParameter,
|
||||
String thirdParameter) {
|
||||
combine(firstParameter,
|
||||
secondParameter,
|
||||
thirdParameter);
|
||||
List.of(firstParameter, secondParameter)
|
||||
.stream()
|
||||
.map(value -> value.trim())
|
||||
.forEach(System.out::println);
|
||||
}
|
||||
|
||||
public void retained(String firstParameter, // retain declaration break
|
||||
String secondParameter) {
|
||||
combine(firstParameter, // retain call break
|
||||
secondParameter, "third");
|
||||
}
|
||||
|
||||
public void arrays() {
|
||||
int[] values = new int[]{1, 2, 3};
|
||||
int[] empty = new int[]{};
|
||||
Function<String, String> clean = value -> {
|
||||
return value.trim();
|
||||
};
|
||||
Runnable run = () -> {
|
||||
System.out.println(values.length);
|
||||
};
|
||||
}
|
||||
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
|
||||
}
|
||||
@@ -252,6 +252,7 @@ fn harness_configured(
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
@@ -1016,6 +1017,70 @@ async fn settings_changes_reach_servers_without_restarts() {
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() {
|
||||
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
|
||||
cfg.initialization_options = Some(
|
||||
toml::Value::try_from(json!({
|
||||
"settings": {"java": {"autobuild": {"enabled": false}}}
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
});
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
std::fs::write(h.root.join("custom.xml"), "<profiles/>").unwrap();
|
||||
let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle {
|
||||
formatter: Some("intellij".into()),
|
||||
formatter_path: Some("custom.xml".into()),
|
||||
..Default::default()
|
||||
}));
|
||||
h.mgr.set_config_hook(Box::new({
|
||||
let style = style.clone();
|
||||
let root = h.root.clone();
|
||||
let data = data.path().to_path_buf();
|
||||
move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap()
|
||||
}));
|
||||
h.open("Main.java", "public class Main {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let state = h.launcher.state("jdtls");
|
||||
let init = state.requested("initialize")[0].clone();
|
||||
assert_eq!(
|
||||
init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"],
|
||||
"OpenIDEA IntelliJ IDEA"
|
||||
);
|
||||
for mode in ["custom", "default", "intellij", "default"] {
|
||||
style.lock().formatter = Some(mode.into());
|
||||
h.mgr.reconfigure().await;
|
||||
let expected = match mode {
|
||||
"custom" => uri_of(&h.root.join("custom.xml")),
|
||||
"intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")),
|
||||
_ => String::new(),
|
||||
};
|
||||
eventually(|| {
|
||||
state
|
||||
.notified("workspace/didChangeConfiguration")
|
||||
.last()
|
||||
.is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected)
|
||||
})
|
||||
.await;
|
||||
let result: Value = state
|
||||
.peer()
|
||||
.request(
|
||||
"workspace/configuration",
|
||||
json!({
|
||||
"items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}]
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result[0]["url"], expected);
|
||||
assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" });
|
||||
assert_eq!(result[1], false);
|
||||
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_source_paths_reach_initialization_live_configuration_and_builds() {
|
||||
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
|
||||
@@ -1248,12 +1313,17 @@ async fn navigation_rename_format_and_decorations() {
|
||||
assert!(matches!(&edit.files[0], FileEdit::Edit { edits, .. } if edits.len() == 2));
|
||||
assert_eq!(s.requested("textDocument/rename")[0]["newName"], "bar");
|
||||
|
||||
assert!(h.mgr.can_format(doc).await.unwrap());
|
||||
s.respond("textDocument/formatting", json!([{ "range": r(0,10,0,11), "newText": "\n}" }]));
|
||||
let edits = h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true }).await.unwrap();
|
||||
let edits =
|
||||
h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false }).await.unwrap();
|
||||
assert_eq!(edits[0].range, rng(0, 10, 0, 11));
|
||||
assert_eq!(s.requested("textDocument/formatting")[0]["options"], json!({ "tabSize": 2, "insertSpaces": true }));
|
||||
assert!(
|
||||
h.mgr.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true }).await.is_err()
|
||||
h.mgr
|
||||
.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true, on_save: false })
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] }));
|
||||
@@ -1617,6 +1687,61 @@ async fn jdt_class_file_contents() {
|
||||
assert_eq!(vd.title, "Lib.class");
|
||||
assert_eq!(vd.language_id.as_str(), "java");
|
||||
assert_eq!(vd.description.as_deref(), Some("/m2/lib-1.0.jar"));
|
||||
let source_uri = jdt.replace("Lib.class", "WithSources.class");
|
||||
s.respond(
|
||||
"java/classFileContents",
|
||||
json!("/* Copyright library author */\npackage org.example;\npublic class WithSources {}"),
|
||||
);
|
||||
let source = h.mgr.virtual_document(&source_uri).await.unwrap();
|
||||
assert!(!source.decompiled);
|
||||
assert_eq!(source.title, "WithSources.java");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn class_file_opening_routes_sources_and_binary_documents() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["workspaceSymbolProvider"] = json!(true);
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
h.open("App.java", "class App {}\n");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("jdtls");
|
||||
let jdt = "jdt://contents/lib.jar/p/Outer.java?=demo/%5C/m2%5C/lib.jar%3Cp%28Outer.class";
|
||||
s.respond("workspace/symbol", json!([
|
||||
{"name":"Outer", "kind":5, "location":{"uri":jdt,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}}
|
||||
]));
|
||||
let archive = Some((PathBuf::from("/m2/lib.jar"), "p/Outer$1.class".into()));
|
||||
assert_eq!(h.mgr.java_class_file(Path::new("/m2/lib.jar!/p/Outer$1.class"), archive).await.unwrap(), jdt);
|
||||
assert!(
|
||||
h.mgr
|
||||
.java_class_file(
|
||||
Path::new("/other/lib.jar!/p/Outer.class"),
|
||||
Some(("/other/lib.jar".into(), "p/Outer.class".into()))
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let sources = h.root.join("src/main/java");
|
||||
std::fs::create_dir_all(sources.join("p")).unwrap();
|
||||
let source = sources.join("p/Outer.java");
|
||||
std::fs::write(&source, "package p; class Outer {} ").unwrap();
|
||||
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
|
||||
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
|
||||
for class in ["Outer", "Outer$Nested", "Outer$1"] {
|
||||
let path = h.root.join(format!("target/classes/p/{class}.class"));
|
||||
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
|
||||
// Compare as paths: the manager joins the relative source path with std::path, so
|
||||
// separators can differ from the literal "p/Outer.java" used to build `source` on Windows.
|
||||
assert_eq!(Path::new(&resolved), source.as_path());
|
||||
}
|
||||
let path = h.root.join("build/classes/java/main/p/Missing.class");
|
||||
let uri = uri_of(&path);
|
||||
assert_eq!(h.mgr.java_class_file(&path, None).await.unwrap(), uri);
|
||||
let vd = h.mgr.virtual_document(&uri).await.unwrap();
|
||||
assert!(vd.decompiled);
|
||||
assert_eq!(vd.language_id.as_str(), "java");
|
||||
assert_eq!(vd.title, "Missing.class");
|
||||
assert!(s.requested("java/classFileContents").iter().any(|p| p["uri"] == uri));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -2682,3 +2807,32 @@ async fn interactive_capability_is_scoped_to_gopls() {
|
||||
assert!(form.is_none());
|
||||
assert!(s.requested("command/resolve").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn formatting_provider_selection_tracks_dynamic_registration() {
|
||||
let h = harness(&[("mock", json!({ "textDocumentSync": 1, "documentRangeFormattingProvider": true }))]);
|
||||
let (doc, _) = h.open("a.mk", "hello\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
assert!(!h.mgr.can_format(doc).await.unwrap(), "a range provider alone cannot format the whole document");
|
||||
let peer = h.launcher.state("mock").peer();
|
||||
peer.request::<_, Value>(
|
||||
"client/registerCapability",
|
||||
json!({"registrations": [
|
||||
{"id": "formatter", "method": "textDocument/formatting"}
|
||||
]}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(h.mgr.can_format(doc).await.unwrap());
|
||||
peer.request::<_, Value>(
|
||||
"client/unregisterCapability",
|
||||
json!({"unregisterations": [
|
||||
{"id": "formatter", "method": "textDocument/formatting"}
|
||||
]}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!h.mgr.can_format(doc).await.unwrap());
|
||||
}
|
||||
@@ -55,12 +55,60 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: vec![server],
|
||||
debugger: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires jdtls on PATH and a compatible JDK"]
|
||||
async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let source = include_str!("fixtures/FormatterSample.input.java");
|
||||
let expected = include_str!("fixtures/FormatterSample.intellij.java");
|
||||
let path = root.join("src/demo/FormatterSample.java");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap();
|
||||
let server = &mut java.language_servers[0];
|
||||
if let Ok(bin) = std::env::var("OPENIDEA_TEST_JDTLS") {
|
||||
server.command = bin;
|
||||
}
|
||||
server.args.extend(["-data".into(), root.join("jdtls-workspace").to_string_lossy().into_owned()]);
|
||||
server.settings = Some(serde_json::json!({"java": {"autobuild": {"enabled": false}}}));
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java]));
|
||||
mgr.set_config_hook(Box::new({
|
||||
let root = root.clone();
|
||||
move |cfg, _| {
|
||||
ide_lsp::jdtls::configure_source_paths(cfg, Some(vec!["src".into()]));
|
||||
let style = ide_core::editor_settings::LanguageCodeStyle {
|
||||
formatter: Some("intellij".into()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_lsp::jdtls::configure_formatter(cfg, Some(&style), &root, &root.join("data"), None).unwrap();
|
||||
}
|
||||
}));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source);
|
||||
retry("jdtls ready", Duration::from_secs(120), || {
|
||||
let status = last_status(&events, "jdtls");
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
|
||||
panic!("jdtls failed: {msg:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
let edits =
|
||||
mgr.format(doc, None, ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false }).await;
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
let mut document = ide_text::Document::new(source);
|
||||
document.apply_edits(&edits.unwrap()).unwrap();
|
||||
assert_eq!(document.text(), expected);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn missing_binary_is_reported_with_install_hint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -735,8 +735,8 @@ fn sh_quote(s: &str) -> String {
|
||||
}
|
||||
|
||||
/// Command typed into a new terminal to activate the project virtualenv
|
||||
/// (PyCharm's Tools › Terminal › "Activate virtualenv"), for the given shell.
|
||||
pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Option<String> {
|
||||
/// (Tools › Terminal › "Activate virtualenv"). `shell` must be the resolved PTY shell.
|
||||
pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option<String> {
|
||||
if !s.languages.python.activate_virtualenv {
|
||||
return None;
|
||||
}
|
||||
@@ -744,12 +744,8 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Op
|
||||
if std::env::var_os("VIRTUAL_ENV").is_some_and(|v| Path::new(&v) == venv) {
|
||||
return None;
|
||||
}
|
||||
let shell = shell
|
||||
.map(str::to_owned)
|
||||
.or_else(|| std::env::var("SHELL").ok())
|
||||
.or_else(|| std::env::var("COMSPEC").ok())
|
||||
.unwrap_or_default();
|
||||
let name = Path::new(&shell).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell);
|
||||
let name = Path::new(name).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
|
||||
let script = |file: &str| venv.join(bin_dir()).join(file);
|
||||
let line = match name.as_str() {
|
||||
"fish" => format!("source {}", sh_quote(&script("activate.fish").to_string_lossy())),
|
||||
|
||||
@@ -717,6 +717,20 @@ mod tests {
|
||||
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
|
||||
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
|
||||
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
|
||||
let patched = Path::new("/managed/lombok-1.18.48-openidea.1.jar");
|
||||
let mut fixed = jdtls();
|
||||
configure_jdtls(
|
||||
&mut fixed,
|
||||
Path::new("/p"),
|
||||
Path::new("/data"),
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
JDTLS_MIN_JAVA,
|
||||
&[],
|
||||
Some(patched),
|
||||
);
|
||||
assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official agent");
|
||||
let before = cfg.clone();
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
|
||||
assert_eq!(cfg, before, "idempotent");
|
||||
@@ -934,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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.1.1.jar");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! result is cached until the manifests (or the relevant settings) change.
|
||||
//!
|
||||
//! Archives are listed as browsable roots (`<archive>!`, see `ide_vfs`): the
|
||||
//! JDK's `src.zip` and the Maven / Gradle dependency jars (sources jars preferred).
|
||||
//! JDK's `src.zip` and the Maven / Gradle dependency jars (source attachments resolved by JDT).
|
||||
|
||||
use crate::exe;
|
||||
use crate::exec::{query, query_in, which};
|
||||
@@ -248,7 +248,7 @@ fn gradle_user_home() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// One `Maven: g:a:v` / `Gradle: g:a:v` group per dependency whose jar is in a
|
||||
/// local repository, its root being the jar (the `-sources.jar` when downloaded).
|
||||
/// local repository, its root being the binary jar so Java classes open through JDT's source provider.
|
||||
pub(crate) fn dependency_libraries(
|
||||
deps: &[BuildDependency],
|
||||
maven_repo: Option<&Path>,
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn maven_jar(repo: &Path, d: &BuildDependency) -> Option<PathBuf> {
|
||||
return None;
|
||||
}
|
||||
let dir = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version);
|
||||
["-sources.jar", ".jar"]
|
||||
[".jar", "-sources.jar"]
|
||||
.iter()
|
||||
.map(|suffix| dir.join(format!("{}-{}{suffix}", d.artifact, d.version)))
|
||||
.find(|p| p.is_file())
|
||||
@@ -300,7 +300,7 @@ pub(crate) fn gradle_jar(home: &Path, d: &BuildDependency) -> Option<PathBuf> {
|
||||
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
|
||||
let mut hashes: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect();
|
||||
hashes.sort();
|
||||
["-sources.jar", ".jar"].iter().find_map(|suffix| {
|
||||
[".jar", "-sources.jar"].iter().find_map(|suffix| {
|
||||
let file = format!("{}-{}{suffix}", d.artifact, d.version);
|
||||
hashes.iter().map(|h| h.join(&file)).find(|p| p.is_file())
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Lombok for jdtls: ECJ only generates Lombok members when the language
|
||||
//! server's JVM runs `-javaagent:lombok.jar`, so a project using Lombok gets the
|
||||
//! newest jar available, managed (installed by the IDE) or in the Maven / Gradle caches.
|
||||
//! app-provided jar, or falls back to the newest release in the Maven / Gradle caches.
|
||||
|
||||
use crate::home_dir;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -18,7 +18,7 @@ pub enum LombokAgent {
|
||||
Missing,
|
||||
Found {
|
||||
jar: PathBuf,
|
||||
/// `None` for the managed jar (the latest release when installed).
|
||||
/// `None` for the app-provided jar (its version is shown by the tool registry).
|
||||
version: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -64,7 +64,7 @@ pub fn agent_in_args<'a>(args: impl IntoIterator<Item = &'a str>) -> Option<Path
|
||||
}
|
||||
|
||||
/// The Lombok agent for the project at `root`: the user's own (`own`, from the JVM
|
||||
/// options) wins, then `managed` (the IDE-installed jar, always attached: it is inert
|
||||
/// options) wins, then `managed` (the app-provided jar, always attached: it is inert
|
||||
/// without Lombok on the classpath), then, for a project using Lombok, the build caches
|
||||
/// (`maven_repo`, the configured Maven local repository, else the default one).
|
||||
pub fn lombok_agent(
|
||||
@@ -499,6 +499,23 @@ mod tests {
|
||||
assert_eq!(managed.problem(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_patched_agent_wins_over_project_caches_but_respects_explicit_overrides() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("pom.xml"), LOMBOK_DEP).unwrap();
|
||||
let repo = tmp.path().join("m2");
|
||||
let cache = repo.join("org/projectlombok/lombok/1.18.48");
|
||||
fs::create_dir_all(&cache).unwrap();
|
||||
fs::write(cache.join("lombok-1.18.48.jar"), b"official agent").unwrap();
|
||||
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.1.jar");
|
||||
let selected = lombok_agent(tmp.path(), None, Some(patched.clone()), Some(repo.clone()));
|
||||
assert_eq!(selected.jar(), Some(patched.as_path()));
|
||||
assert_eq!(selected.problem(), None);
|
||||
let own = PathBuf::from("/custom/lombok.jar");
|
||||
let selected = lombok_agent(tmp.path(), Some(own.clone()), Some(patched), Some(repo));
|
||||
assert_eq!(selected.jar(), Some(own.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_newest_cached_release() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
+39
-13
@@ -319,14 +319,40 @@ fn terminal_activation_for_project_venv() {
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None);
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None);
|
||||
fake_venv(tmp.path(), ".venv");
|
||||
let line = terminal_activation(tmp.path(), &s, Some("/bin/zsh")).unwrap();
|
||||
let line = terminal_activation(tmp.path(), &s, "/bin/zsh").unwrap();
|
||||
assert!(line.starts_with("source '") && line.ends_with("/activate'"), "{line}");
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, Some("/usr/bin/fish")), None, "no activate.fish");
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, Some("docker")), None, "not a shell");
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, "/usr/bin/fish"), None, "no activate.fish");
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, "docker"), None, "not a shell");
|
||||
s.languages.python.activate_virtualenv = false;
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None);
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_activation_for_windows_shell_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.python.interpreter = fake_venv(tmp.path(), ".venv with spaces 'quoted'").to_string_lossy().into_owned();
|
||||
let bin = tmp.path().join(".venv with spaces 'quoted'").join(bin_dir());
|
||||
for (shell, script, powershell) in [
|
||||
(r"C:\Program Files\PowerShell\7\pwsh.exe", "Activate.ps1", true),
|
||||
(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "Activate.ps1", true),
|
||||
(r"C:\Windows\System32\cmd.exe", "activate.bat", false),
|
||||
] {
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, shell), None, "missing {script}");
|
||||
let path = bin.join(script);
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let expected = if powershell {
|
||||
format!("& '{}'", path.to_string_lossy().replace('\'', "''"))
|
||||
} else {
|
||||
format!("\"{}\"", path.display())
|
||||
};
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, shell), Some(expected), "{shell}");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, ""), None);
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, " "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -514,12 +540,12 @@ fn build_dependencies_resolve_to_local_jars() {
|
||||
std::fs::write(&p, "").unwrap();
|
||||
p
|
||||
};
|
||||
touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
|
||||
let guava_src = touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
|
||||
let guava = touch(m2.join("com/google/guava/guava/33.0/guava-33.0.jar"));
|
||||
touch(m2.join("com/google/guava/guava/33.0/guava-33.0-sources.jar"));
|
||||
let junit = touch(m2.join("junit/junit/4.13/junit-4.13.jar"));
|
||||
let files = gradle.join("caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9");
|
||||
touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
|
||||
let slf4j_src = touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
|
||||
let slf4j = touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
|
||||
touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
|
||||
let dep = |gradle: bool, g: &str, a: &str, v: &str| BuildDependency {
|
||||
gradle,
|
||||
group: g.into(),
|
||||
@@ -540,14 +566,14 @@ fn build_dependencies_resolve_to_local_jars() {
|
||||
assert_eq!(
|
||||
shown,
|
||||
[
|
||||
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0-sources.jar"),
|
||||
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0.jar"),
|
||||
("Maven: junit:junit:4.13", "maven", "junit-4.13.jar"),
|
||||
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9-sources.jar"),
|
||||
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9.jar"),
|
||||
]
|
||||
);
|
||||
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava_src)));
|
||||
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava)));
|
||||
assert_eq!(groups[1].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&junit)));
|
||||
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j_src)));
|
||||
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j)));
|
||||
// A Gradle build also finds jars installed in the Maven local repository (`mavenLocal()`).
|
||||
let groups = dependency_libraries(&[dep(true, "junit", "junit", "4.13")], Some(&m2), Some(&gradle));
|
||||
assert_eq!(groups[0].name, "Gradle: junit:junit:4.13");
|
||||
|
||||
@@ -497,7 +497,7 @@ pub fn available_shells() -> Vec<String> {
|
||||
|
||||
/// Picks the shell binary and its arguments. A default (or explicitly chosen but
|
||||
/// argument-less) POSIX shell is started as a login shell, like Terminal.app/IDEA.
|
||||
fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
|
||||
pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
|
||||
let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell);
|
||||
if !args.is_empty() {
|
||||
return (shell, args.to_vec());
|
||||
@@ -808,8 +808,12 @@ mod tests {
|
||||
assert_eq!((shell.as_str(), args), ("/bin/zsh", vec!["-l".to_owned()]));
|
||||
let (_, args) = resolve_shell(Some("/usr/bin/python3"), &[]);
|
||||
assert!(args.is_empty());
|
||||
let (_, args) = resolve_shell(None, &[]);
|
||||
assert_eq!(args, vec!["-l".to_owned()]);
|
||||
for configured in [None, Some(""), Some(" \t ")] {
|
||||
let (shell, args) = resolve_shell(configured, &[]);
|
||||
assert_eq!(shell, default_shell_path());
|
||||
assert_eq!(args, vec!["-l".to_owned()]);
|
||||
}
|
||||
assert_eq!(resolve_shell(Some(" /bin/bash "), &["-i".into()]), ("/bin/bash".into(), vec!["-i".into()]));
|
||||
assert_eq!(shell_title("C:\\Program Files\\PowerShell\\7\\pwsh.exe"), "pwsh");
|
||||
let env = terminal_env(&BTreeMap::from([("TERM".into(), "dumb".into())]));
|
||||
assert_eq!(env["TERM"], "dumb");
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Versioned app-owned plugin jars, shared by tool detection and language-server launches.
|
||||
|
||||
use crate::{Method, ToolSpec};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn install(root: &Path, spec: &ToolSpec) -> std::io::Result<PathBuf> {
|
||||
let Method::Bundled { filename, bytes, .. } = spec.method else { unreachable!("bundled tool") };
|
||||
let dir = root.join(spec.name).join("bin");
|
||||
// The agent path participates in the JDT LS workspace key: upgrades must
|
||||
// discard incremental state compiled with the previous agent.
|
||||
let path = dir.join(filename);
|
||||
if std::fs::read(&path).is_ok_and(|old| old == bytes) {
|
||||
return Ok(path);
|
||||
}
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
|
||||
let temporary = dir.join(format!(".{filename}-{}-{nonce}.tmp", std::process::id()));
|
||||
let result = std::fs::write(&temporary, bytes).and_then(|_| std::fs::rename(&temporary, &path));
|
||||
if let Err(error) = result {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
// Another window may have installed the same bytes concurrently.
|
||||
if !std::fs::read(&path).is_ok_and(|old| old == bytes) {
|
||||
tracing::warn!(tool = spec.name, %error, "Bundled plugin unavailable");
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! External formatting: resolve a tool, pass unsaved text on stdin, return text without writing files.
|
||||
|
||||
use crate::Tools;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_language::FormatterConfig;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Walk from the file's directory upward, including workspace parents (monorepos).
|
||||
pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
|
||||
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
|
||||
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
|
||||
let bin = json["bin"]
|
||||
.as_str()
|
||||
.or_else(|| json["bin"]["prettier"].as_str())
|
||||
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
|
||||
let path = package.join(bin);
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::unavailable(format!("Prettier: CLI does not exist: {}", path.display())));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
/// Explicit executable/script, then the nearest project package, then managed tools and PATH.
|
||||
pub fn formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
self.resolve_formatter_command(tool, file, custom).map_err(|e| e.with_tool(tool))
|
||||
}
|
||||
|
||||
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
if tool != "prettier" {
|
||||
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
|
||||
}
|
||||
let (path, node) = if let Some(custom) = custom.filter(|p| p.is_dir()) {
|
||||
(package_bin(custom)?, true)
|
||||
} else if let Some(custom) = custom {
|
||||
(custom.to_path_buf(), matches!(custom.extension().and_then(|s| s.to_str()), Some("js" | "cjs" | "mjs")))
|
||||
} else if let Some(manifest) = find_up(file, Path::new("node_modules/prettier/package.json")) {
|
||||
(package_bin(manifest.parent().unwrap())?, true)
|
||||
} else if self.root.join("node_modules/prettier/package.json").is_file() {
|
||||
(package_bin(&self.root.join("node_modules/prettier"))?, true)
|
||||
} else {
|
||||
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
|
||||
};
|
||||
if node {
|
||||
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
|
||||
let mut cmd = self.command(node);
|
||||
cmd.arg(path);
|
||||
Ok(cmd)
|
||||
} else {
|
||||
Ok(self.command(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
|
||||
pub fn arguments(
|
||||
config: &FormatterConfig,
|
||||
file: &Path,
|
||||
tab_size: u32,
|
||||
insert_spaces: bool,
|
||||
range: Option<(u32, u32)>,
|
||||
) -> Vec<OsString> {
|
||||
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
|
||||
"mdx"
|
||||
} else {
|
||||
&config.parser
|
||||
};
|
||||
let mut args: Vec<OsString> = [
|
||||
"--parser",
|
||||
parser,
|
||||
"--config-precedence",
|
||||
"file-override",
|
||||
"--tab-width",
|
||||
&tab_size.max(1).to_string(),
|
||||
"--use-tabs",
|
||||
if insert_spaces { "false" } else { "true" },
|
||||
]
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
|
||||
if config.range
|
||||
&& let Some((start, end)) = range
|
||||
{
|
||||
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
|
||||
}
|
||||
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
|
||||
for name in [".prettierignore", ".gitignore"] {
|
||||
if let Some(path) = find_up(file, Path::new(name)) {
|
||||
args.extend(["--ignore-path".into(), path.into_os_string()]);
|
||||
}
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
pub fn run(mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool("prettier"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_formatter_is_structured_but_invalid_custom_path_is_not_an_install_prompt() {
|
||||
use ide_core::error::{ErrorData, ErrorKind};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tools = Tools::with_paths(dir.path().join("tools"), OsString::new(), crate::platform::Platform::current());
|
||||
let file = dir.path().join("file.md");
|
||||
let missing = tools.formatter_command("prettier", &file, None).unwrap_err();
|
||||
assert_eq!(missing.kind, ErrorKind::NotFound);
|
||||
assert_eq!(missing.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: true }));
|
||||
let custom = tools.formatter_command("prettier", &file, Some(&dir.path().join("absent-prettier"))).unwrap();
|
||||
let failure = run(custom, "text", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stderr_is_preserved_verbatim_even_when_longer_than_four_thousand_characters() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "i=0; while [ $i -lt 1000 ]; do printf 'error\\n' >&2; i=$((i+1)); done; exit 1"]);
|
||||
let failure = run(command, "", &Default::default()).unwrap_err();
|
||||
assert_eq!(failure.message, "error\n".repeat(1000));
|
||||
assert_eq!(failure.data, Some(ide_core::error::ErrorData::Tool { tool: "prettier".into(), missing: false }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_nearest_package_and_ignore_above_nested_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("packages/web/src")).unwrap();
|
||||
std::fs::write(root.join(".prettierignore"), "ignored.md").unwrap();
|
||||
std::fs::create_dir_all(root.join("node_modules/prettier")).unwrap();
|
||||
std::fs::write(root.join("node_modules/prettier/package.json"), "{}").unwrap();
|
||||
let file = root.join("packages/web/src/readme.md");
|
||||
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join(".prettierignore")));
|
||||
std::fs::write(root.join("packages/web/.prettierignore"), "src/ignored.md").unwrap();
|
||||
assert_eq!(find_up(&file, Path::new(".prettierignore")), Some(root.join("packages/web/.prettierignore")));
|
||||
assert_eq!(
|
||||
find_up(&file, Path::new("node_modules/prettier/package.json")),
|
||||
Some(root.join("node_modules/prettier/package.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_use_stdin_config_precedence_and_only_supported_ranges() {
|
||||
for (parser, supports_range) in [
|
||||
("markdown", false),
|
||||
("css", false),
|
||||
("yaml", false),
|
||||
("typescript", true),
|
||||
("graphql", true),
|
||||
("vue", true),
|
||||
] {
|
||||
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
|
||||
let args = arguments(&config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
|
||||
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
|
||||
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--tab-width", "4"]));
|
||||
assert!(args.windows(2).any(|a| a == ["--use-tabs", "true"]));
|
||||
assert_eq!(args.contains(&"--range-start".into()), supports_range);
|
||||
assert!(!args.contains(&"--write".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
//! [`tools_bin_dirs`] on the process `PATH` at startup.
|
||||
|
||||
pub mod archive;
|
||||
mod bundled;
|
||||
pub mod formatter;
|
||||
pub mod jdk;
|
||||
pub mod platform;
|
||||
mod process;
|
||||
@@ -146,7 +148,7 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
|
||||
Method::PipVenv { .. } => {
|
||||
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
|
||||
}
|
||||
Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
|
||||
Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +324,9 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
|
||||
}
|
||||
};
|
||||
for l in langs {
|
||||
if let Some(f) = &l.formatter {
|
||||
add(&f.tool, &f.tool, ToolKind::Formatter, None, &l.name);
|
||||
}
|
||||
for s in &l.language_servers {
|
||||
add(&s.name, &s.command, ToolKind::LanguageServer, s.install_hint.as_ref(), &l.name);
|
||||
}
|
||||
@@ -389,9 +394,12 @@ fn is_bundle(command: &str) -> bool {
|
||||
command.ends_with(".jar")
|
||||
}
|
||||
|
||||
/// Path of an installed plugin bundle (`<tools>/<name>/bin/<jar>`), if present.
|
||||
/// Path of an app-provided or installed plugin jar. Embedded jars are materialized on demand.
|
||||
pub fn managed_bundle(app_data: &Path, name: &str) -> Option<PathBuf> {
|
||||
let spec = registry::find(name)?;
|
||||
if matches!(spec.method, Method::Bundled { .. }) {
|
||||
return bundled::install(&tools_dir(app_data), spec).ok();
|
||||
}
|
||||
let jar = spec.commands.first().filter(|c| is_bundle(c))?;
|
||||
let path = tools_dir(app_data).join(spec.name).join("bin").join(jar);
|
||||
path.is_file().then_some(path)
|
||||
@@ -542,6 +550,7 @@ impl Tools {
|
||||
fn method_missing(&self, method: Method) -> Option<String> {
|
||||
let need = |ok: bool, msg: &str| (!ok).then(|| msg.to_string());
|
||||
match method {
|
||||
Method::Bundled { .. } => None,
|
||||
Method::Rustup { .. } => need(self.which("rustup").is_some(), "rustup is required (https://rustup.rs)"),
|
||||
Method::Npm { .. } => need(
|
||||
self.which_any(&["npm", "pnpm"]).is_some(),
|
||||
@@ -604,7 +613,8 @@ impl Tools {
|
||||
};
|
||||
let (status, path, version) = self.detect(spec);
|
||||
let unavailable_reason = self.unavailable_reason(spec);
|
||||
let can_uninstall = status == ToolStatus::Installed;
|
||||
let bundled = matches!(spec.method, Method::Bundled { .. });
|
||||
let can_uninstall = status == ToolStatus::Installed && !bundled;
|
||||
Some(ToolInfo {
|
||||
name: spec.name.to_string(),
|
||||
display_name: spec.display.to_string(),
|
||||
@@ -615,7 +625,7 @@ impl Tools {
|
||||
path: path.map(Into::into),
|
||||
version,
|
||||
install_method: Some(self.install_method(spec).label().to_string()),
|
||||
can_install: unavailable_reason.is_none(),
|
||||
can_install: unavailable_reason.is_none() && !bundled,
|
||||
unavailable_reason,
|
||||
can_uninstall,
|
||||
install_hint,
|
||||
@@ -654,6 +664,12 @@ impl Tools {
|
||||
}
|
||||
|
||||
fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option<PathBuf>, Option<String>) {
|
||||
if let Method::Bundled { version, .. } = spec.method {
|
||||
return match bundled::install(&self.root, spec) {
|
||||
Ok(path) => (ToolStatus::Installed, Some(path), Some(version.to_owned())),
|
||||
Err(_) => (ToolStatus::Missing, None, None),
|
||||
};
|
||||
}
|
||||
let main = spec.commands[0];
|
||||
let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main));
|
||||
if let Some(path) = managed {
|
||||
@@ -782,6 +798,9 @@ impl Tools {
|
||||
let method = self.install_method(spec);
|
||||
emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) });
|
||||
match method {
|
||||
Method::Bundled { .. } => {
|
||||
bundled::install(&self.root, spec)?;
|
||||
}
|
||||
Method::Rustup { component } => {
|
||||
let mut c = self.command(self.which("rustup").expect("checked"));
|
||||
c.args(["component", "add", component]);
|
||||
@@ -1188,6 +1207,8 @@ impl Tools {
|
||||
let cancel = CancellationToken::new();
|
||||
let quiet = |_: InstallEvent| {};
|
||||
match self.install_method(spec) {
|
||||
// App-owned resources remain available for every project/window.
|
||||
Method::Bundled { .. } => {}
|
||||
Method::Rustup { component } => {
|
||||
let rustup = self.which("rustup").ok_or_else(|| IdeError::unavailable("rustup not found"))?;
|
||||
let mut c = self.command(rustup);
|
||||
|
||||
@@ -348,6 +348,89 @@ pub fn progress_event(bytes: u64, size: Option<u64>) -> InstallEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a formatter with separate pipes so diagnostics can never become document text.
|
||||
pub(crate) fn run_input(
|
||||
cmd: &mut Command,
|
||||
input: &str,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
use std::io::Write;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut child = own_group(cmd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let input = input.to_owned();
|
||||
// All pipes drain concurrently, including stdin: large documents cannot deadlock or bypass the timeout.
|
||||
enum Pipe {
|
||||
Written(std::io::Result<()>),
|
||||
Read(bool, std::io::Result<Vec<u8>>),
|
||||
}
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = writer.send(Pipe::Written(stdin.write_all(input.as_bytes())));
|
||||
});
|
||||
for (is_stderr, mut pipe) in [
|
||||
(false, Box::new(child.stdout.take().unwrap()) as Box<dyn Read + Send>),
|
||||
(true, Box::new(child.stderr.take().unwrap()) as Box<dyn Read + Send>),
|
||||
] {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let _ = tx.send(Pipe::Read(is_stderr, pipe.read_to_end(&mut bytes).map(|_| bytes)));
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
let started = Instant::now();
|
||||
let (mut status, mut output, mut errors, mut written) = (None, None, None, None);
|
||||
let result = loop {
|
||||
if cancel.is_cancelled() {
|
||||
break Err(IdeError::cancelled());
|
||||
}
|
||||
if started.elapsed() >= timeout {
|
||||
break Err(IdeError::new(ErrorKind::TimedOut, "Formatting timed out"));
|
||||
}
|
||||
if status.is_none() {
|
||||
match child.try_wait() {
|
||||
Ok(s) => status = s,
|
||||
Err(e) => break Err(e.into()),
|
||||
}
|
||||
}
|
||||
if status.is_some() && output.is_some() && errors.is_some() && written.is_some() {
|
||||
break Ok(());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(Pipe::Written(result)) => written = Some(result),
|
||||
Ok(Pipe::Read(true, result)) => errors = Some(result),
|
||||
Ok(Pipe::Read(false, result)) => output = Some(result),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) if output.is_some() && errors.is_some() && written.is_some() => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break Err(IdeError::internal("formatter pipe reader failed")),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
};
|
||||
if result.is_err() {
|
||||
kill(&mut child);
|
||||
}
|
||||
result?;
|
||||
let output = output.unwrap()?;
|
||||
let errors = errors.unwrap()?;
|
||||
let status = status.unwrap();
|
||||
if !status.success() {
|
||||
let diagnostics: String = String::from_utf8_lossy(&errors).into_owned();
|
||||
return Err(IdeError::invalid(if diagnostics.trim().is_empty() {
|
||||
format!("Formatter exited with {status}")
|
||||
} else {
|
||||
diagnostics
|
||||
}));
|
||||
}
|
||||
written.unwrap()?;
|
||||
String::from_utf8(output).map_err(|e| IdeError::invalid(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -464,3 +547,37 @@ mod tests {
|
||||
assert_eq!(run_quiet(cmd, Duration::from_secs(5)), Some((true, "v1.2.3\n".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod input_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn separate_diagnostics_and_never_return_failed_output() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "cat; printf warning >&2"]);
|
||||
assert_eq!(run_input(&mut command, "a😀\n", Duration::from_secs(2), &Default::default()).unwrap(), "a😀\n");
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "printf broken; printf 'invalid syntax' >&2; exit 1"]);
|
||||
let e = run_input(&mut command, "", Duration::from_secs(2), &Default::default()).unwrap_err();
|
||||
assert_eq!(e.message, "invalid syntax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_kills_formatter_and_cancellation_prevents_start() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "sleep 10 &"]);
|
||||
let start = Instant::now();
|
||||
assert_eq!(
|
||||
run_input(&mut command, "", Duration::from_millis(50), &Default::default()).unwrap_err().kind,
|
||||
ErrorKind::TimedOut
|
||||
);
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
run_input(&mut Command::new("does-not-exist"), "", Duration::from_secs(2), &cancel).unwrap_err().kind,
|
||||
ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub enum ToolKind {
|
||||
Debugger,
|
||||
/// Loaded into another tool's process, e.g. a jdtls agent.
|
||||
Plugin,
|
||||
Formatter,
|
||||
}
|
||||
|
||||
/// Where release archives come from.
|
||||
@@ -31,6 +32,8 @@ pub enum ReleaseSource {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Method {
|
||||
/// An app-owned plugin jar, materialized without a network download.
|
||||
Bundled { filename: &'static str, version: &'static str, bytes: &'static [u8] },
|
||||
/// `rustup component add <component>`.
|
||||
Rustup { component: &'static str },
|
||||
/// npm / pnpm packages installed into `<tools>/node_modules`; the first is the primary one.
|
||||
@@ -56,6 +59,7 @@ pub enum Method {
|
||||
impl Method {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Method::Bundled { .. } => "Bundled",
|
||||
Method::Rustup { .. } => "rustup",
|
||||
Method::Npm { .. } => "npm",
|
||||
Method::Go { .. } => "go install",
|
||||
@@ -102,7 +106,7 @@ impl ToolSpec {
|
||||
|
||||
use Arch::{Arm64, X64};
|
||||
use Os::{Linux, Mac, Windows};
|
||||
use ToolKind::{Debugger, LanguageServer, Plugin};
|
||||
use ToolKind::{Debugger, Formatter, LanguageServer, Plugin};
|
||||
|
||||
const V: Option<(&str, &[&str])> = None;
|
||||
const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")];
|
||||
@@ -119,6 +123,17 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str]
|
||||
pub const TYPESCRIPT: &str = "typescript@6";
|
||||
|
||||
pub static TOOLS: &[ToolSpec] = &[
|
||||
ToolSpec {
|
||||
name: "prettier",
|
||||
display: "Prettier",
|
||||
kind: Formatter,
|
||||
commands: &["prettier"],
|
||||
method: Method::Npm { packages: &["prettier"] },
|
||||
fallback: None,
|
||||
version: ver("prettier"),
|
||||
needs: NODE,
|
||||
homepage: "https://prettier.io",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "rust-analyzer",
|
||||
display: "rust-analyzer",
|
||||
@@ -578,15 +593,13 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
display: "Lombok (jdtls agent)",
|
||||
kind: Plugin,
|
||||
commands: &["lombok.jar"],
|
||||
// Newer than the Lombok a project pins: old ones fail inside current ECJ.
|
||||
method: Method::Release {
|
||||
source: ReleaseSource::MavenCentral { group: "org.projectlombok", artifact: "lombok" },
|
||||
assets: &[
|
||||
any_arch(Mac, "lombok-*.jar"),
|
||||
any_arch(Linux, "lombok-*.jar"),
|
||||
any_arch(Windows, "lombok-*.jar"),
|
||||
],
|
||||
locate: &["lombok.jar"],
|
||||
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup.
|
||||
// Return to Maven Central once an official release includes lombok#4059.
|
||||
// Source commit, build instructions and checksum: plugins/lombok/README.md.
|
||||
method: Method::Bundled {
|
||||
filename: "lombok-1.18.48-openidea.1.jar",
|
||||
version: "1.18.48-openidea.1",
|
||||
bytes: include_bytes!("../../../plugins/lombok/lombok-1.18.48-openidea.1.jar"),
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
@@ -942,6 +955,11 @@ mod tests {
|
||||
known.insert((d.adapter.clone(), d.command.clone()));
|
||||
}
|
||||
}
|
||||
for l in &langs {
|
||||
if let Some(f) = &l.formatter {
|
||||
known.insert((f.tool.clone(), f.tool.clone()));
|
||||
}
|
||||
}
|
||||
for t in TOOLS.iter().filter(|t| t.kind != Plugin) {
|
||||
assert!(
|
||||
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
|
||||
|
||||
@@ -434,7 +434,6 @@ mod tests {
|
||||
assert_eq!(d.version, "1.18.48");
|
||||
assert_eq!(d.name, "lombok-1.18.48.jar");
|
||||
assert_eq!(d.url, "https://repo1.maven.org/maven2/org/projectlombok/lombok/1.18.48/lombok-1.18.48.jar");
|
||||
assert!(select_asset(&[d.name.as_str()], find("lombok").unwrap().asset_rules(), LINUX).is_some());
|
||||
assert!(maven_pick("g", "a", "<metadata/>").is_err());
|
||||
assert!(maven_pick("g", "a", "<release>../x</release>").is_err());
|
||||
}
|
||||
|
||||
@@ -43,6 +43,61 @@ fn find<'a>(list: &'a [ToolInfo], name: &str) -> &'a ToolInfo {
|
||||
list.iter().find(|t| t.name == name).unwrap_or_else(|| panic!("{name} not listed"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads() {
|
||||
for os in [Os::Mac, Os::Linux, Os::Windows] {
|
||||
for arch in [platform::Arch::Arm64, platform::Arch::X64] {
|
||||
let env = Env::new();
|
||||
let legacy = env.root.join("lombok/bin/lombok.jar");
|
||||
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
|
||||
fs::write(&legacy, b"old official agent").unwrap();
|
||||
fs::write(env.root.join("lombok").join(MANIFEST), r#"{"version":"1.18.48"}"#).unwrap();
|
||||
let tools = Tools::with_paths(env.root.clone(), OsString::new(), Platform { os, arch });
|
||||
let info = tools.info("lombok", &[]).unwrap();
|
||||
let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed);
|
||||
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.1"));
|
||||
assert_eq!(info.path, Some(path.clone().into()));
|
||||
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.1.jar");
|
||||
assert!(!info.can_install && !info.can_uninstall);
|
||||
assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None);
|
||||
assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_lombok_is_available_offline_and_repairs_missing_or_corrupt_files() {
|
||||
let env = Env::new();
|
||||
let data = env.root.parent().unwrap();
|
||||
let agent = managed_bundle(data, "lombok").unwrap();
|
||||
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
|
||||
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
|
||||
for contents in [None, Some(b"incomplete jar".as_slice())] {
|
||||
if let Some(contents) = contents {
|
||||
fs::write(&agent, contents).unwrap();
|
||||
} else {
|
||||
fs::remove_file(&agent).unwrap();
|
||||
}
|
||||
assert_eq!(managed_bundle(data, "lombok"), Some(agent.clone()));
|
||||
assert_eq!(sources::sha256_file(&agent).unwrap(), checksum);
|
||||
}
|
||||
assert_eq!(fs::read_dir(agent.parent().unwrap()).unwrap().count(), 1, "no temporary jars left behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_windows_get_the_same_complete_bundled_lombok() {
|
||||
let env = Env::new();
|
||||
let data = env.root.parent().unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
let windows: Vec<_> = (0..8).map(|_| scope.spawn(|| managed_bundle(data, "lombok").unwrap())).collect();
|
||||
let paths: Vec<_> = windows.into_iter().map(|window| window.join().unwrap()).collect();
|
||||
assert!(paths.iter().all(|path| path == &paths[0]));
|
||||
let checksum = include_str!("../../../plugins/lombok/SHA256SUMS").split_whitespace().next().unwrap();
|
||||
assert_eq!(sources::sha256_file(&paths[0]).unwrap(), checksum);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_parsing() {
|
||||
assert_eq!(parse_version("rust-analyzer 1.85.0 (4d91de4e 2025-02-17)").as_deref(), Some("1.85.0"));
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! `cargo test -p ide-tools --test prettier -- --ignored --nocapture`
|
||||
//! Installs into a temporary managed-tools directory; no repository dependency is added.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_language::FormatterConfig;
|
||||
use ide_tools::{Tools, formatter};
|
||||
use std::path::Path;
|
||||
|
||||
fn format(
|
||||
tools: &Tools,
|
||||
path: &Path,
|
||||
parser: &str,
|
||||
text: &str,
|
||||
range: Option<(u32, u32)>,
|
||||
) -> ide_core::IdeResult<String> {
|
||||
let config = FormatterConfig {
|
||||
tool: "prettier".into(),
|
||||
parser: parser.into(),
|
||||
range: matches!(parser, "typescript" | "json" | "graphql"),
|
||||
};
|
||||
let mut cmd = tools.formatter_command("prettier", path, None)?;
|
||||
cmd.current_dir(path.parent().unwrap());
|
||||
cmd.args(formatter::arguments(&config, path, 4, true, range));
|
||||
formatter::run(cmd, text, &CancellationToken::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Node.js/npm and network; installs real Prettier into a temporary managed directory"]
|
||||
fn real_prettier_config_ignore_languages_ranges_and_errors() {
|
||||
if std::env::var_os("OPENIDEA_TEST_OFFLINE").is_some() {
|
||||
eprintln!("SKIP real Prettier: OPENIDEA_TEST_OFFLINE is set; npm network access is disabled");
|
||||
return;
|
||||
}
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let tools = Tools::new(&temp.path().join("app-data"));
|
||||
let info = tools.info("prettier", &[]).unwrap();
|
||||
if !info.can_install {
|
||||
eprintln!("SKIP real Prettier: {}", info.unavailable_reason.unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
tools.install("prettier", &[], &Default::default(), &|_| {}).expect(
|
||||
"Prettier managed install failed; offline environments may set OPENIDEA_TEST_OFFLINE=1 (reported as SKIP)",
|
||||
);
|
||||
let project = temp.path().join("project with spaces");
|
||||
std::fs::create_dir_all(project.join("docs/nested")).unwrap();
|
||||
let md = project.join("docs/nested/readme.md");
|
||||
let input = "# Title\ntext\n\n| a | b |\n|---|---|\n|1|2|\n\n- a\n - b\n";
|
||||
let output = format(&tools, &md, "markdown", input, None).unwrap();
|
||||
assert!(output.starts_with("# Title\n\ntext\n"), "{output}");
|
||||
assert!(output.contains("| 1 | 2 |"), "{output}");
|
||||
assert!(!md.exists(), "formatting must never write the file");
|
||||
std::fs::write(project.join(".prettierrc"), r#"{"proseWrap":"always","printWidth":24,"tabWidth":2}"#).unwrap();
|
||||
let prose =
|
||||
"This is a long paragraph with several words that should wrap according to the project configuration.\n";
|
||||
assert!(format(&tools, &md, "markdown", prose, None).unwrap().lines().count() > 2);
|
||||
std::fs::write(project.join(".prettierignore"), "docs/nested/readme.md\n").unwrap();
|
||||
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), input);
|
||||
std::fs::remove_file(project.join(".prettierignore")).unwrap();
|
||||
std::fs::remove_file(project.join(".prettierrc")).unwrap();
|
||||
assert_eq!(
|
||||
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
|
||||
"a {\n color: red;\n}\n"
|
||||
);
|
||||
std::fs::write(project.join("package.json"), r#"{"prettier":{"tabWidth":3,"proseWrap":"never"}}"#).unwrap();
|
||||
assert_eq!(
|
||||
format(&tools, &project.join("a.css"), "css", "a{color:red}", None).unwrap(),
|
||||
"a {\n color: red;\n}\n"
|
||||
);
|
||||
assert_eq!(format(&tools, &project.join("a.yaml"), "yaml", "a: [1,2]\n", None).unwrap(), "a: [1, 2]\n");
|
||||
let js = "const a={x:1};\nconst b={y:2};\n";
|
||||
let formatted = format(&tools, &project.join("a.ts"), "typescript", js, Some((0, 14))).unwrap();
|
||||
assert!(formatted.starts_with("const a = { x: 1 };"), "{formatted}");
|
||||
assert!(formatted.contains("const b={y:2};"), "{formatted}");
|
||||
assert!(format(&tools, &project.join("bad.css"), "css", "a {", None).is_err());
|
||||
// Nearest project version must win over the managed install, including npm's object-style bin.
|
||||
let package = project.join("node_modules/prettier");
|
||||
std::fs::create_dir_all(&package).unwrap();
|
||||
std::fs::write(package.join("package.json"), r#"{"bin":{"prettier":"cli.cjs"}}"#).unwrap();
|
||||
std::fs::write(
|
||||
package.join("cli.cjs"),
|
||||
"process.stdin.resume(); process.stdin.on('end', () => process.stdout.write('project version\\n')); ",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(format(&tools, &md, "markdown", input, None).unwrap(), "project version\n");
|
||||
std::fs::write(package.join("cli.cjs"), "setInterval(() => {}, 1000)").unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
assert!(format(&tools, &md, "markdown", input, None).unwrap_err().message.contains("timed out"));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(20));
|
||||
eprintln!(
|
||||
"PASS: real managed Prettier, config/ignore discovery, editor defaults, YAML/CSS/Markdown, ranges, errors, project precedence, timeout; no disk writes"
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// File charset plus an optional reversible ASCII representation (for properties).
|
||||
/// File charset plus an optional ASCII representation (for properties).
|
||||
/// The host supplies conversion functions so Git and documents share one codec.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TextEncoding {
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Disk comparisons use the same excludes and layered ignore files as the project tree.
|
||||
use crate::{EntryKind, ExcludeMatcher, rules::GitignoreStack};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DirectoryCompareStatus {
|
||||
LeftOnly,
|
||||
RightOnly,
|
||||
Different,
|
||||
Equal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryCompareEntry {
|
||||
pub path: String,
|
||||
pub status: DirectoryCompareStatus,
|
||||
pub left_kind: Option<EntryKind>,
|
||||
pub right_kind: Option<EntryKind>,
|
||||
#[specta(optional)]
|
||||
pub left_link: Option<String>,
|
||||
#[specta(optional)]
|
||||
pub right_link: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryCompareProgress {
|
||||
pub scanned: u32,
|
||||
pub compared: u32,
|
||||
pub total: Option<u32>,
|
||||
pub bytes_read: f64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
kind: EntryKind,
|
||||
size: u64,
|
||||
link: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn check(cancel: &CancellationToken) -> IdeResult<()> {
|
||||
if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
struct Rules {
|
||||
base: PathBuf,
|
||||
excludes: ExcludeMatcher,
|
||||
ignore: GitignoreStack,
|
||||
}
|
||||
|
||||
impl Rules {
|
||||
fn new(root: &Path, project: Option<&Path>, excludes: &[String]) -> Self {
|
||||
Self {
|
||||
base: project.filter(|p| root.starts_with(p)).unwrap_or(root).to_path_buf(),
|
||||
excludes: ExcludeMatcher::new(excludes),
|
||||
ignore: GitignoreStack::new(root),
|
||||
}
|
||||
}
|
||||
|
||||
fn excluded(&self, path: &Path, dir: bool) -> bool {
|
||||
path.file_name().is_some_and(|n| n == ".git")
|
||||
|| self.excludes.is_excluded(path.strip_prefix(&self.base).unwrap_or(path))
|
||||
|| self.ignore.is_ignored(path, dir)
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(
|
||||
root: &Path,
|
||||
rules: &Rules,
|
||||
cancel: &CancellationToken,
|
||||
progress: &mut DirectoryCompareProgress,
|
||||
emit: &impl Fn(DirectoryCompareProgress),
|
||||
) -> IdeResult<BTreeMap<String, Entry>> {
|
||||
if !fs::metadata(root)?.is_dir() {
|
||||
return Err(IdeError::invalid(format!("{} is not a directory", root.display())));
|
||||
}
|
||||
let mut result = BTreeMap::new();
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(dir) = pending.pop() {
|
||||
check(cancel)?;
|
||||
for item in fs::read_dir(dir)? {
|
||||
check(cancel)?;
|
||||
let path = item?.path();
|
||||
let meta = fs::symlink_metadata(&path)?;
|
||||
if rules.excluded(&path, meta.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let kind = if meta.file_type().is_symlink() {
|
||||
EntryKind::Symlink
|
||||
} else if meta.is_dir() {
|
||||
pending.push(path.clone());
|
||||
EntryKind::Dir
|
||||
} else if meta.is_file() {
|
||||
EntryKind::File
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let rel = ide_core::ipc_path::to_ipc(rel);
|
||||
let link = if kind == EntryKind::Symlink { Some(fs::read_link(&path)?) } else { None };
|
||||
result.insert(rel.clone(), Entry { kind, size: meta.len(), link });
|
||||
progress.scanned += 1;
|
||||
progress.path = rel;
|
||||
emit(progress.clone());
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn hash(
|
||||
path: &Path,
|
||||
cancel: &CancellationToken,
|
||||
progress: &mut DirectoryCompareProgress,
|
||||
emit: &impl Fn(DirectoryCompareProgress),
|
||||
) -> IdeResult<gix::hash::ObjectId> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hash = gix::hash::hasher(gix::hash::Kind::Sha256);
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
check(cancel)?;
|
||||
let len = file.read(&mut buffer)?;
|
||||
if len == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&buffer[..len]);
|
||||
progress.bytes_read += len as f64;
|
||||
emit(progress.clone());
|
||||
}
|
||||
hash.try_finalize().map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Size checks avoid reading unequal files. Equal-sized files are hashed in bounded,
|
||||
/// cancellable chunks; symlinks compare their link text and are never traversed.
|
||||
pub fn compare_directories(
|
||||
left: &Path,
|
||||
right: &Path,
|
||||
project: Option<&Path>,
|
||||
excludes: &[String],
|
||||
cancel: &CancellationToken,
|
||||
emit: impl Fn(DirectoryCompareProgress),
|
||||
) -> IdeResult<Vec<DirectoryCompareEntry>> {
|
||||
check(cancel)?;
|
||||
let mut progress = DirectoryCompareProgress::default();
|
||||
emit(progress.clone());
|
||||
let l = scan(left, &Rules::new(left, project, excludes), cancel, &mut progress, &emit)?;
|
||||
let r = scan(right, &Rules::new(right, project, excludes), cancel, &mut progress, &emit)?;
|
||||
let mut paths: Vec<_> = l.keys().chain(r.keys()).cloned().collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
progress.total = Some(paths.len() as u32);
|
||||
let mut rows = BTreeMap::new();
|
||||
for path in paths {
|
||||
check(cancel)?;
|
||||
progress.path.clone_from(&path);
|
||||
let status = match (l.get(&path), r.get(&path)) {
|
||||
(Some(_), None) => DirectoryCompareStatus::LeftOnly,
|
||||
(None, Some(_)) => DirectoryCompareStatus::RightOnly,
|
||||
(Some(a), Some(b)) => {
|
||||
let equal = a.kind == b.kind
|
||||
&& match a.kind {
|
||||
EntryKind::Dir => true,
|
||||
EntryKind::Symlink => a.link == b.link,
|
||||
EntryKind::File => {
|
||||
a.size == b.size
|
||||
&& hash(&left.join(&path), cancel, &mut progress, &emit)?
|
||||
== hash(&right.join(&path), cancel, &mut progress, &emit)?
|
||||
}
|
||||
};
|
||||
if equal { DirectoryCompareStatus::Equal } else { DirectoryCompareStatus::Different }
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
rows.insert(
|
||||
path.clone(),
|
||||
DirectoryCompareEntry {
|
||||
path: path.clone(),
|
||||
status,
|
||||
left_kind: l.get(&path).map(|e| e.kind),
|
||||
right_kind: r.get(&path).map(|e| e.kind),
|
||||
left_link: l.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()),
|
||||
right_link: r.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()),
|
||||
},
|
||||
);
|
||||
progress.compared += 1;
|
||||
emit(progress.clone());
|
||||
}
|
||||
let changed: Vec<_> =
|
||||
rows.values().filter(|r| r.status != DirectoryCompareStatus::Equal).map(|r| r.path.clone()).collect();
|
||||
for path in changed {
|
||||
for parent in Path::new(&path).ancestors().skip(1) {
|
||||
if let Some(row) = rows.get_mut(&ide_core::ipc_path::to_ipc(parent))
|
||||
&& row.status == DirectoryCompareStatus::Equal
|
||||
{
|
||||
row.status = DirectoryCompareStatus::Different;
|
||||
}
|
||||
}
|
||||
}
|
||||
check(cancel)?;
|
||||
Ok(rows.into_values().collect())
|
||||
}
|
||||
|
||||
/// Merges a subtree without following links inside the comparison roots. Existing files
|
||||
/// require explicit overwrite; directory merges preserve destination-only entries.
|
||||
pub fn copy_merge(
|
||||
from: &Path,
|
||||
to: &Path,
|
||||
overwrite: bool,
|
||||
project: Option<&Path>,
|
||||
excludes: &[String],
|
||||
roots: (&Path, &Path),
|
||||
) -> IdeResult<()> {
|
||||
fn resolve(path: &Path) -> IdeResult<PathBuf> {
|
||||
if path.exists() {
|
||||
return Ok(dunce::canonicalize(path)?);
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| IdeError::invalid("copy requires an absolute path"))?;
|
||||
Ok(resolve(parent)?.join(path.file_name().ok_or_else(|| IdeError::invalid("missing file name"))?))
|
||||
}
|
||||
if !from.is_absolute() || !to.is_absolute() {
|
||||
return Err(IdeError::invalid("copy requires absolute paths"));
|
||||
}
|
||||
if !from.starts_with(roots.0) || !to.starts_with(roots.1) {
|
||||
return Err(IdeError::invalid("copy paths must belong to the comparison roots"));
|
||||
}
|
||||
let source = resolve(from)?;
|
||||
let target = resolve(to)?;
|
||||
if source.starts_with(&target) || target.starts_with(&source) {
|
||||
return Err(IdeError::invalid("cannot copy overlapping paths"));
|
||||
}
|
||||
for parent in to.ancestors().skip(1) {
|
||||
// The user explicitly selected this root; aliases above it (e.g. /tmp
|
||||
// on macOS) are valid. Links inside the copied subtree are not followed.
|
||||
if to == roots.1 || parent == roots.1 {
|
||||
break;
|
||||
}
|
||||
if fs::symlink_metadata(parent).is_ok_and(|m| m.file_type().is_symlink()) {
|
||||
return Err(IdeError::invalid("cannot copy through a destination symlink"));
|
||||
}
|
||||
}
|
||||
let source_base = project.filter(|p| from.starts_with(p)).or(Some(roots.0));
|
||||
let target_base = project.filter(|p| to.starts_with(p)).or(Some(roots.1));
|
||||
let rules = Rules::new(from, source_base, excludes);
|
||||
let target_rules = Rules::new(to, target_base, excludes);
|
||||
let mut pending = vec![(from.to_path_buf(), to.to_path_buf())];
|
||||
while let Some((src, dst)) = pending.pop() {
|
||||
let meta = fs::symlink_metadata(&src)?;
|
||||
if rules.excluded(&src, meta.is_dir()) || target_rules.excluded(&dst, meta.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let existing = fs::symlink_metadata(&dst).ok();
|
||||
let merge = meta.is_dir() && existing.as_ref().is_some_and(|m| m.is_dir());
|
||||
if existing.is_some() && !merge && !overwrite {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into());
|
||||
}
|
||||
if meta.is_dir() {
|
||||
if existing.is_some() && !merge {
|
||||
crate::delete(&dst)?;
|
||||
}
|
||||
fs::create_dir_all(&dst)?;
|
||||
for entry in fs::read_dir(&src)? {
|
||||
let entry = entry?;
|
||||
pending.push((entry.path(), dst.join(entry.file_name())));
|
||||
}
|
||||
} else {
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Stage the complete file before touching its destination.
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let temp = dst.with_file_name(format!(".openidea-copy-{}-{id}", std::process::id()));
|
||||
let mut staged = false;
|
||||
let result: IdeResult<()> = (|| {
|
||||
#[cfg(windows)]
|
||||
if meta.file_type().is_symlink() {
|
||||
use std::os::windows::fs::{FileTypeExt, symlink_dir, symlink_file};
|
||||
let target = fs::read_link(&src)?;
|
||||
if meta.file_type().is_symlink_dir() {
|
||||
symlink_dir(target, &temp)?;
|
||||
} else {
|
||||
symlink_file(target, &temp)?;
|
||||
}
|
||||
} else {
|
||||
crate::copy(&src, &temp)?;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
crate::copy(&src, &temp)?;
|
||||
staged = true;
|
||||
// Recheck after staging: another writer may have created the target.
|
||||
if fs::symlink_metadata(&dst).is_ok() {
|
||||
if !overwrite {
|
||||
return Err(
|
||||
std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into()
|
||||
);
|
||||
}
|
||||
crate::delete(&dst)?;
|
||||
}
|
||||
fs::rename(&temp, &dst)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = &result
|
||||
&& (staged || error.kind != ide_core::error::ErrorKind::AlreadyExists)
|
||||
{
|
||||
let _ = crate::delete_permanently(&temp);
|
||||
}
|
||||
result?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::error::ErrorKind;
|
||||
|
||||
fn put(root: &Path, path: &str, content: &[u8]) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_add_delete_modify_equal_binary_unicode_empty_and_type_conflicts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "子目录/仅左.txt", b"left");
|
||||
put(&right, "only-right", b"right");
|
||||
for (path, a, b) in [
|
||||
("same", b"same".as_slice(), b"same".as_slice()),
|
||||
("binary", &[0, 255, 10], &[0, 254, 10]),
|
||||
("size", b"a", b"bb"),
|
||||
("conflict", b"file", b""),
|
||||
] {
|
||||
put(&left, path, a);
|
||||
if path == "conflict" {
|
||||
put(&right, "conflict/child", b"child");
|
||||
} else {
|
||||
put(&right, path, b);
|
||||
}
|
||||
}
|
||||
fs::create_dir(left.join("empty")).unwrap();
|
||||
fs::create_dir(right.join("empty")).unwrap();
|
||||
let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap();
|
||||
let status = |path| rows.iter().find(|r| r.path == path).unwrap().status;
|
||||
assert_eq!(status("same"), DirectoryCompareStatus::Equal);
|
||||
assert_eq!(status("empty"), DirectoryCompareStatus::Equal);
|
||||
for path in ["binary", "size", "conflict"] {
|
||||
assert_eq!(status(path), DirectoryCompareStatus::Different);
|
||||
}
|
||||
assert_eq!(status("子目录/仅左.txt"), DirectoryCompareStatus::LeftOnly);
|
||||
assert_eq!(status("only-right"), DirectoryCompareStatus::RightOnly);
|
||||
assert_eq!(status("conflict/child"), DirectoryCompareStatus::RightOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_project_anchored_excludes_and_layered_ignores() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
for root in [&left, &right] {
|
||||
put(root, "nested/node_modules/x", b"ignored");
|
||||
put(root, "target/x", b"ignored");
|
||||
put(root, ".git/config", b"ignored");
|
||||
put(root, "generated/x", b"ignored");
|
||||
put(root, ".gitignore", b"*.log\n");
|
||||
put(root, "nested/debug.log", b"ignored");
|
||||
put(root, "kept", b"kept");
|
||||
}
|
||||
let excludes = ["**/node_modules", "**/target", "/left/generated", "/right/generated"].map(String::from);
|
||||
let rows =
|
||||
compare_directories(&left, &right, Some(tmp.path()), &excludes, &CancellationToken::new(), |_| {}).unwrap();
|
||||
assert!(
|
||||
rows.iter()
|
||||
.all(|r| !["node_modules", "target", ".git/", "generated", ".log"].iter().any(|p| r.path.contains(p)))
|
||||
);
|
||||
assert!(rows.iter().any(|r| r.path == "kept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_stops_before_scan_during_scan_and_during_hashing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "big", &vec![1; 1024 * 1024]);
|
||||
put(&right, "big", &vec![1; 1024 * 1024]);
|
||||
for phase in 0..3 {
|
||||
let cancel = CancellationToken::new();
|
||||
if phase == 0 {
|
||||
cancel.cancel();
|
||||
}
|
||||
let result = compare_directories(&left, &right, None, &[], &cancel, |p| {
|
||||
if phase == 1 && p.scanned > 0 || phase == 2 && p.bytes_read > 0.0 {
|
||||
cancel.cancel();
|
||||
}
|
||||
});
|
||||
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_mismatches_are_not_hashed_and_directory_status_tracks_children() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "nested/file", b"a");
|
||||
put(&right, "nested/file", b"ab");
|
||||
let bytes = std::cell::Cell::new(0.0);
|
||||
let rows =
|
||||
compare_directories(&left, &right, None, &[], &CancellationToken::new(), |p| bytes.set(p.bytes_read))
|
||||
.unwrap();
|
||||
assert_eq!(bytes.get(), 0.0);
|
||||
assert!(rows.iter().all(|r| r.status == DirectoryCompareStatus::Different));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_copies_with_explicit_overwrite_and_keeps_destination_only_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "file", b"new");
|
||||
put(&left, "target/ignored", b"ignored");
|
||||
put(&right, "file", b"old");
|
||||
put(&right, "keep", b"keep");
|
||||
assert_eq!(
|
||||
copy_merge(&left, &right, false, None, &["target".into()], (&left, &right)).unwrap_err().kind,
|
||||
ErrorKind::AlreadyExists
|
||||
);
|
||||
assert_eq!(fs::read(right.join("file")).unwrap(), b"old");
|
||||
copy_merge(&left, &right, true, None, &["target".into()], (&left, &right)).unwrap();
|
||||
assert_eq!(fs::read(right.join("file")).unwrap(), b"new");
|
||||
assert_eq!(fs::read(right.join("keep")).unwrap(), b"keep");
|
||||
assert!(!right.join("target").exists());
|
||||
for (from, to) in [(&left, left.clone()), (&left, left.join("child")), (&right, tmp.path().to_path_buf())] {
|
||||
assert_eq!(
|
||||
copy_merge(from, &to, true, None, &[], (tmp.path(), tmp.path())).unwrap_err().kind,
|
||||
ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtree_copy_keeps_excludes_anchored_to_each_comparison_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "sub/build/keep", b"included below sub");
|
||||
put(&left, "sub/protected", b"do not overwrite");
|
||||
put(&right, "sub/protected", b"keep destination");
|
||||
put(&right, ".gitignore", b"sub/protected\n");
|
||||
copy_merge(&left.join("sub"), &right.join("sub"), true, None, &["/build".into()], (&left, &right)).unwrap();
|
||||
assert_eq!(fs::read(right.join("sub/build/keep")).unwrap(), b"included below sub");
|
||||
assert_eq!(fs::read(right.join("sub/protected")).unwrap(), b"keep destination");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_copy_replaces_file_directory_type_conflicts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
put(&left, "file", b"file");
|
||||
put(&right, "file/child", b"old directory");
|
||||
put(&left, "tree/child", b"new directory");
|
||||
put(&right, "tree", b"old file");
|
||||
for path in ["file", "tree"] {
|
||||
copy_merge(&left.join(path), &right.join(path), true, None, &[], (&left, &right)).unwrap();
|
||||
}
|
||||
assert_eq!(fs::read(right.join("file")).unwrap(), b"file");
|
||||
assert_eq!(fs::read(right.join("tree/child")).unwrap(), b"new directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_compare_link_text_and_are_not_followed_when_copying() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let left = tmp.path().join("left");
|
||||
let right = tmp.path().join("right");
|
||||
fs::create_dir(&left).unwrap();
|
||||
fs::create_dir(&right).unwrap();
|
||||
symlink(".", left.join("loop")).unwrap();
|
||||
symlink("missing", right.join("loop")).unwrap();
|
||||
let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].status, DirectoryCompareStatus::Different);
|
||||
copy_merge(&left.join("loop"), &right.join("copied"), false, None, &[], (&left, &right)).unwrap();
|
||||
assert_eq!(fs::read_link(right.join("copied")).unwrap(), PathBuf::from("."));
|
||||
symlink(&right, tmp.path().join("alias")).unwrap();
|
||||
assert_eq!(
|
||||
copy_merge(&left.join("loop"), &tmp.path().join("alias/new"), true, None, &[], (tmp.path(), tmp.path()))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -62,20 +62,19 @@ pub fn decode_properties_escapes(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// Serializes displayed properties text as ASCII, using uppercase UTF-16 units.
|
||||
/// Existing ASCII Unicode escapes retain their spelling and meaning on disk.
|
||||
pub fn encode_properties_ascii(text: &str) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
let current = rest;
|
||||
rest = &rest[c.len_utf8()..];
|
||||
if c == '\\' {
|
||||
if rest.starts_with('\\') {
|
||||
out.push_str("\\\\");
|
||||
rest = &rest[1..];
|
||||
} else if unicode_character(current).is_some() || rest.chars().next().is_some_and(|next| !next.is_ascii()) {
|
||||
// Keep a literal backslash from swallowing a generated escape,
|
||||
// or becoming a Unicode escape on the next load.
|
||||
} else if rest.chars().next().is_some_and(|next| !next.is_ascii()) {
|
||||
// Keep a literal backslash from swallowing a generated escape.
|
||||
out.push_str("\\u005C");
|
||||
} else {
|
||||
out.push(c);
|
||||
@@ -387,13 +386,32 @@ mod tests {
|
||||
for text in ["plain=ASCII\r\n", r"escaped=\\u0041", r"bad=\u12xz", r"bad=\uD800"] {
|
||||
assert_eq!(encode_properties_ascii(text), text);
|
||||
}
|
||||
for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\u0041", r"\\u0041", r"\uD83D\uDE00"] {
|
||||
for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\\u0041"] {
|
||||
let ascii = encode_properties_ascii(text);
|
||||
assert!(ascii.is_ascii());
|
||||
assert_eq!(decode_properties_escapes(&ascii), text, "{ascii}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_ascii_escapes_are_not_escaped_again() {
|
||||
for text in [
|
||||
r"test.key2=\u4F60\u597D",
|
||||
r"test.key3=\u4f60\u597d",
|
||||
r"ascii=\u0041\u005C\u000A",
|
||||
r"emoji=\uD83D\uDE00",
|
||||
r"slashes=\\u4F60 \\\u4F60 \\\\u4F60",
|
||||
r"bad=\u123 \u12xz \uu0041 \uD800 \uDC00",
|
||||
] {
|
||||
assert_eq!(encode_properties_ascii(text), text, "{text}");
|
||||
}
|
||||
let mixed = "raw=\\u4f60\\u597d; native=你好😀";
|
||||
let expected = r"raw=\u4f60\u597d; native=\u4F60\u597D\uD83D\uDE00";
|
||||
assert_eq!(encode_properties_ascii(mixed), expected);
|
||||
assert_eq!(encode_properties_ascii(expected), expected);
|
||||
assert_eq!(decode_properties_escapes(expected), "raw=你好; native=你好😀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_line_terminator_escapes_survive_an_edit() {
|
||||
let source = r"key=one\u000Atwo\u000Dthree";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod archive;
|
||||
mod bytes;
|
||||
mod compare;
|
||||
mod encoding;
|
||||
mod ops;
|
||||
mod rules;
|
||||
@@ -20,6 +21,9 @@ pub use archive::{
|
||||
read_archive_entry, split_archive_path,
|
||||
};
|
||||
pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range};
|
||||
pub use compare::{
|
||||
DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge,
|
||||
};
|
||||
use encoding::SNIFF_LEN;
|
||||
pub use encoding::{
|
||||
LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode,
|
||||
|
||||
@@ -641,6 +641,57 @@ fn properties_conversion_uses_displayed_utf16_and_survives_reload() {
|
||||
assert!(!f.docs.is_dirty(doc.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_pasted_escapes_survive_save_and_decode_on_reload() {
|
||||
let pasted = "test.key1=Hello\ntest.key2=\\u4F60\\u597D\ntest.key3=\\u4f60\\u597d\n";
|
||||
let displayed = "test.key1=Hello\ntest.key2=你好\ntest.key3=你好\n";
|
||||
for encoding in ["utf-8", "iso-8859-1", "utf-16le"] {
|
||||
for eol in ["\n", "\r\n"] {
|
||||
let f = fixture();
|
||||
let path = f.root.join("test_zh_CN.properties");
|
||||
let source = pasted.replace('\n', eol);
|
||||
fs::write(&path, ide_vfs::encode(&source, encoding, encoding == "utf-16le").unwrap()).unwrap();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
defaults.properties_fallback = encoding.into();
|
||||
f.docs.set_file_defaults(defaults.clone());
|
||||
let doc = f.docs.open(&path).unwrap();
|
||||
assert_eq!(doc.text, displayed);
|
||||
|
||||
f.docs.replace_all(doc.id, pasted).unwrap();
|
||||
f.docs.save(doc.id).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), source.as_bytes(), "{encoding} {eol:?}");
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted);
|
||||
assert!(!f.docs.is_dirty(doc.id).unwrap());
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, pasted, "own saves do not reload");
|
||||
|
||||
f.docs.close(doc.id);
|
||||
let reopened = f.docs.open(&path).unwrap();
|
||||
assert_eq!(reopened.text, displayed);
|
||||
// An edit followed by its inverse preserves the original escape casing.
|
||||
f.docs.replace_all(reopened.id, pasted).unwrap();
|
||||
f.docs.replace_all(reopened.id, displayed).unwrap();
|
||||
f.docs.save(reopened.id).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), source.as_bytes());
|
||||
|
||||
let external = format!("{source}test.key4=\\uD83D\\uDE00{eol}");
|
||||
fs::write(&path, &external).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n"));
|
||||
defaults.native_to_ascii = false;
|
||||
f.docs.set_file_defaults(defaults.clone());
|
||||
f.docs.reload_properties();
|
||||
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, external.replace("\r\n", "\n"));
|
||||
defaults.native_to_ascii = true;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
f.docs.reload_properties();
|
||||
assert_eq!(f.docs.snapshot(reopened.id).unwrap().text, format!("{displayed}test.key4=😀\n"));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), external);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_toggle_reloads_only_clean_properties_and_pins_dirty_codec() {
|
||||
let f = fixture();
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/** Docking agent conversations between the editor and the Agents tool window by drag & drop. */
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const TASK = {
|
||||
id: "t1",
|
||||
title: "Repro task",
|
||||
status: "ready" as const,
|
||||
isolation: "local" as const,
|
||||
provider: "codex",
|
||||
model: null,
|
||||
cwd: "/dev/acme-api",
|
||||
branch: null,
|
||||
base: null,
|
||||
baseCommit: null,
|
||||
error: null,
|
||||
createdMs: 0,
|
||||
updatedMs: 0,
|
||||
config: [],
|
||||
commands: [],
|
||||
usage: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
/** Seeds one task in the agents store (no backend in plain-browser mode) and docks it. */
|
||||
async function seedAndDock(page: Page) {
|
||||
await page.evaluate(async (task) => {
|
||||
// Paths go through variables: knip can't resolve these browser-absolute URLs.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const store = await import(storePath);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
store.dockTask(task.id);
|
||||
}, TASK);
|
||||
await page.locator("span[title='Repro task']").first().waitFor();
|
||||
}
|
||||
|
||||
test("drags the docked conversation back onto the editor", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
// An editor pane, so the session tab has a content drop target to land on.
|
||||
await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick();
|
||||
await page.getByRole("tab", { name: "README.md" }).waitFor();
|
||||
await seedAndDock(page);
|
||||
|
||||
const title = page.locator("span[title='Repro task']").first();
|
||||
const to = await page.getByRole("tabpanel").first().boundingBox();
|
||||
const from = await title.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toBeVisible();
|
||||
await expect(page.getByRole("tree", { name: "Agent tasks" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("drags the docked conversation back onto the empty editor", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
// Docking closes the session tab; with no other tabs the editor is empty and
|
||||
// must still take the drop (regression: the empty state had no drop target).
|
||||
await seedAndDock(page);
|
||||
await expect(page.locator("[data-editor-area] [role='tabpanel']")).toHaveCount(0);
|
||||
|
||||
const title = page.locator("span[title='Repro task']").first();
|
||||
const to = await page.getByText("Drop files here to open them").boundingBox();
|
||||
const from = await title.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("docks a session tab dropped on the tool window", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.getByRole("treeitem", { name: "README.md", exact: true }).dblclick();
|
||||
await page.getByRole("tab", { name: "README.md" }).waitFor();
|
||||
await page.evaluate(async (task) => {
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const modelPath = "/src/features/agents/model.ts";
|
||||
const contributionsPath = "/src/workbench/contributions.ts";
|
||||
const store = await import(storePath);
|
||||
const { TOOL_WINDOW } = await import(modelPath);
|
||||
const { toolWindowService } = await import(contributionsPath);
|
||||
store.useAgents.setState({
|
||||
loaded: true,
|
||||
error: null,
|
||||
providers: [],
|
||||
tasks: new Map([[task.id, task]]),
|
||||
unread: new Set(),
|
||||
docked: null,
|
||||
});
|
||||
toolWindowService().show(TOOL_WINDOW, true);
|
||||
store.openTask(task.id);
|
||||
}, TASK);
|
||||
await page.getByRole("tab", { name: "Repro task" }).waitFor();
|
||||
|
||||
const tab = page.getByRole("tab", { name: "Repro task" });
|
||||
const to = await page.getByRole("tree", { name: "Agent tasks" }).boundingBox();
|
||||
const from = await tab.boundingBox();
|
||||
await page.mouse.move(from!.x + from!.width / 2, from!.y + from!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to!.x + to!.width / 2, to!.y + to!.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Docked: the tab closes and the tool window gives itself to the conversation.
|
||||
await expect(page.getByRole("tab", { name: "Repro task" })).toHaveCount(0);
|
||||
await expect(page.getByRole("tree", { name: "Agent tasks" })).toHaveCount(0);
|
||||
await expect(page.locator("span[title='Repro task']").first()).toBeVisible();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "css"
|
||||
name = "CSS"
|
||||
formatter = { tool = "prettier", parser = "css", range = false }
|
||||
extensions = ["css", "pcss", "postcss"]
|
||||
block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "graphql"
|
||||
name = "GraphQL"
|
||||
formatter = { tool = "prettier", parser = "graphql", range = true }
|
||||
extensions = ["graphql", "graphqls", "gql"]
|
||||
line-comment = "#"
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "html"
|
||||
name = "HTML"
|
||||
formatter = { tool = "prettier", parser = "html", range = false }
|
||||
extensions = ["html", "htm", "xhtml", "shtml", "html.tmpl"]
|
||||
first-line = "^\\s*<!(?i:doctype)\\s+html"
|
||||
block-comment = ["<!--", "-->"]
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ resolveAdditionalTextEditsSupport = true
|
||||
|
||||
[language-servers.initialization-options.settings.java]
|
||||
autobuild = { enabled = true }
|
||||
contentProvider = { preferred = "fernflower" }
|
||||
contentProvider = { preferred = "fernflowerContentProvider" }
|
||||
maven = { downloadSources = true, updateSnapshots = false }
|
||||
eclipse = { downloadSources = true }
|
||||
references = { includeDecompiledSources = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "javascript"
|
||||
name = "JavaScript"
|
||||
formatter = { tool = "prettier", parser = "babel", range = true }
|
||||
extensions = ["js", "mjs", "cjs"]
|
||||
first-line = "^#!.*\\b(node|nodejs|deno|bun)\\b"
|
||||
line-comment = "//"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "javascriptreact"
|
||||
name = "JavaScript JSX"
|
||||
formatter = { tool = "prettier", parser = "babel", range = true }
|
||||
extensions = ["jsx"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "json"
|
||||
name = "JSON"
|
||||
formatter = { tool = "prettier", parser = "json", range = true }
|
||||
extensions = ["json", "geojson", "topojson", "webmanifest", "har", "avsc", "jsonl", "ndjson", "json.map"]
|
||||
filenames = [".prettierrc", ".watchmanconfig", "composer.lock", ".arcconfig"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "jsonc"
|
||||
name = "JSON with Comments"
|
||||
formatter = { tool = "prettier", parser = "jsonc", range = true }
|
||||
extensions = ["jsonc", "code-workspace", "code-snippets"]
|
||||
filenames = ["tsconfig.json", "jsconfig.json", "tsconfig.*.json", "jsconfig.*.json", ".eslintrc", ".eslintrc.json", ".jshintrc", ".babelrc", ".swcrc", "devcontainer.json", ".devcontainer.json", "launch.json", "tasks.json", "settings.json", "extensions.json", "keybindings.json", "tslint.json", "api-extractor.json", "typedoc.json", "deno.json", "biome.json", "turbo.json", "language-configuration.json"]
|
||||
line-comment = "//"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "less"
|
||||
name = "Less"
|
||||
formatter = { tool = "prettier", parser = "less", range = false }
|
||||
extensions = ["less"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "markdown"
|
||||
name = "Markdown"
|
||||
formatter = { tool = "prettier", parser = "markdown", range = false }
|
||||
extensions = ["md", "markdown", "mdown", "mkd", "mkdn", "mdwn", "mdx", "ronn", "workbook"]
|
||||
block-comment = ["<!--", "-->"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "scss"
|
||||
name = "SCSS"
|
||||
formatter = { tool = "prettier", parser = "scss", range = false }
|
||||
extensions = ["scss"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "typescript"
|
||||
name = "TypeScript"
|
||||
formatter = { tool = "prettier", parser = "typescript", range = true }
|
||||
extensions = ["ts", "mts", "cts"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "typescriptreact"
|
||||
name = "TypeScript JSX"
|
||||
formatter = { tool = "prettier", parser = "typescript", range = true }
|
||||
extensions = ["tsx"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "vue"
|
||||
name = "Vue"
|
||||
formatter = { tool = "prettier", parser = "vue", range = true }
|
||||
extensions = ["vue"]
|
||||
block-comment = ["<!--", "-->"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
id = "yaml"
|
||||
name = "YAML"
|
||||
formatter = { tool = "prettier", parser = "yaml", range = false }
|
||||
extensions = ["yaml", "yml"]
|
||||
filenames = [".clang-format", ".clang-tidy", ".clangd", ".gemrc", "pnpm-lock.yaml", "CITATION.cff"]
|
||||
first-line = "^%YAML"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "openidea",
|
||||
"private": true,
|
||||
"description": "An open-source IDE inspired by IntelliJ IDEA",
|
||||
"version": "0.1.32",
|
||||
"version": "0.1.33",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
@@ -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=<jar>` and
|
||||
`-Dopenidea.kotlin.staging=<absolute-directory>`. 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.
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -0,0 +1,5 @@
|
||||
<component-set><components><component>
|
||||
<role>org.apache.maven.AbstractMavenLifecycleParticipant</role>
|
||||
<role-hint>openidea-kotlin-output</role-hint>
|
||||
<implementation>ai.openidea.kotlin.IsolatedOutput</implementation>
|
||||
</component></components></component-set>
|
||||
@@ -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<MavenProject, File> outputs = new LinkedHashMap<>();
|
||||
private File staging;
|
||||
private final Map<MavenProject, Map<String, String>> kotlin = new LinkedHashMap<>();
|
||||
private final Set<MavenProject> 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<String, String> 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<String, String> 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<MavenProject> 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<Path> 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<String> modules = new ArrayList<>();
|
||||
for (Map.Entry<MavenProject, File> entry : outputs.entrySet()) {
|
||||
MavenProject project = entry.getKey();
|
||||
File main = entry.getValue();
|
||||
List<String> 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<String> goals = new ArrayList<>();
|
||||
for (Map.Entry<String, String> 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();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plugin>
|
||||
<extension point="org.eclipse.jdt.ls.core.delegateCommandHandler">
|
||||
<delegateCommandHandler class="ai.openidea.kotlin.KotlinLibraries">
|
||||
<command id="openidea.kotlin.replace"/>
|
||||
</delegateCommandHandler>
|
||||
</extension>
|
||||
</plugin>
|
||||
@@ -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<ProjectLibraries> 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<Library> libraries; }
|
||||
|
||||
@Override public Object executeCommand(String command, List<Object> 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<IClasspathEntry> 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<IClasspathAttribute> 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;
|
||||
}
|
||||
}
|
||||
@@ -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 = '''<build><plugins><plugin><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-maven-plugin</artifactId><version>2.0.10</version><executions><execution><id>kotlin-compile</id><phase>process-sources</phase><goals><goal>compile</goal></goals><configuration><jvmTarget>1.8</jvmTarget><sourceDirs><sourceDir>src/main/java</sourceDir></sourceDirs></configuration></execution></executions></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin></plugins></build>'''
|
||||
plugin = plugin.replace('</executions>', '<execution><id>kotlin-test</id><phase>process-test-sources</phase><goals><goal>test-compile</goal></goals><configuration><jvmTarget>1.8</jvmTarget><sourceDirs><sourceDir>src/test/java</sourceDir></sourceDirs></configuration></execution></executions>')
|
||||
stdlib = '<dependencies><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib</artifactId><version>2.0.10</version></dependency></dependencies>'
|
||||
properties = '<properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target></properties>'
|
||||
prefix = '<project xmlns="http://maven.apache.org/POM/4.0.0"><modelVersion>4.0.0</modelVersion>'
|
||||
(single / 'pom.xml').write_text(prefix + '<groupId>probe</groupId><artifactId>single</artifactId><version>1</version>' + properties + stdlib + plugin + '</project>')
|
||||
(multi / 'pom.xml').write_text(prefix + '<groupId>probe</groupId><artifactId>reactor</artifactId><version>1</version><packaging>pom</packaging>' + properties + '<modules><module>channel-lib</module><module>app</module></modules></project>')
|
||||
parent = '<parent><groupId>probe</groupId><artifactId>reactor</artifactId><version>1</version></parent>'
|
||||
(lib / 'pom.xml').write_text(prefix + parent + '<artifactId>channel-lib</artifactId>' + stdlib + plugin + '</project>')
|
||||
(app / 'pom.xml').write_text(prefix + parent + '<artifactId>app</artifactId><dependencies><dependency><groupId>probe</groupId><artifactId>channel-lib</artifactId><version>1</version></dependency></dependencies></project>')
|
||||
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 + '<groupId>probe</groupId><artifactId>failure-reactor</artifactId><version>1</version><packaging>pom</packaging>' + properties + '<modules><module>broken-java</module><module>independent</module><module>downstream</module></modules></project>')
|
||||
failure_parent = '<parent><groupId>probe</groupId><artifactId>failure-reactor</artifactId><version>1</version></parent>'
|
||||
for name in ['broken-java', 'independent', 'downstream']:
|
||||
module = fail_reactor/name
|
||||
(module/'src/main/java/probe').mkdir(parents=True)
|
||||
dependency = '<dependencies><dependency><groupId>probe</groupId><artifactId>broken-java</artifactId><version>1</version></dependency><dependency><groupId>org.jetbrains.kotlin</groupId><artifactId>kotlin-stdlib</artifactId><version>2.0.10</version></dependency></dependencies>' if name == 'downstream' else stdlib
|
||||
(module/'pom.xml').write_text(prefix + failure_parent + '<artifactId>' + name + '</artifactId>' + dependency + plugin + '</project>')
|
||||
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)
|
||||
@@ -0,0 +1,104 @@
|
||||
Copyright (C) 2009-2021 The Project Lombok Authors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
==============================================================================
|
||||
Licenses for included components:
|
||||
|
||||
org.ow2.asm:asm
|
||||
org.ow2.asm:asm-analysis
|
||||
org.ow2.asm:asm-commons
|
||||
org.ow2.asm:asm-tree
|
||||
org.ow2.asm:asm-util
|
||||
ASM: a very small and fast Java bytecode manipulation framework
|
||||
Copyright (c) 2000-2011 INRIA, France Telecom
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holders nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
|
||||
THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
rzwitserloot/com.zwitserloot.cmdreader
|
||||
|
||||
Copyright © 2010 Reinier Zwitserloot.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
projectlombok/lombok.patcher
|
||||
|
||||
Copyright (C) 2009-2021 The Project Lombok Authors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
@@ -0,0 +1,71 @@
|
||||
# Lombok agent for JDT LS
|
||||
|
||||
`lombok-1.18.48-openidea.1.jar` is the app-provided Java agent for JDT LS. It includes
|
||||
[projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores
|
||||
`lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`.
|
||||
|
||||
The jar is embedded by `ide-tools`, so every platform receives the same bytes without a download.
|
||||
Both Java server startup and launch diagnostics obtain it through `managed_bundle`. Tool detection
|
||||
reports the same version and path, replacing older IDE-installed official agents automatically.
|
||||
An explicit user `-javaagent` still takes precedence. Project Maven/Gradle dependencies are unchanged.
|
||||
The filename includes the patch version because JDT LS workspace keys include the agent path;
|
||||
changing agents must rebuild classes produced by the old agent. Old jars remain for running windows.
|
||||
|
||||
Replace this temporary bundle with an official Maven Central release once that release includes
|
||||
#4059. Restore the registry's Maven Central install method and remove the embedded jar. Keep the
|
||||
workspace migration test when changing its path.
|
||||
|
||||
## Provenance and build
|
||||
|
||||
- Repository: https://github.com/projectlombok/lombok
|
||||
- Base tag: `v1.18.48` (`b48657c83ce44d027984f539bd36048293ce5e8e`).
|
||||
- Cherry-picked commits from PR #4059, in order:
|
||||
- `2d535482a913de46c27d06c5b0b294918ede3f9c`
|
||||
- `3098f4dd78b5349c2f382e7310e64db810dd380b`
|
||||
- Upstream version: `1.18.48`; `openidea.1` identifies this distribution of the patched agent.
|
||||
Upstream's own `java -jar ... --version` still reports `1.18.48`.
|
||||
- SHA-256: `10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4`
|
||||
- License: [MIT, including bundled component notices](LICENSE), also retained inside the jar.
|
||||
- Build environment: macOS arm64, OpenJDK 23.0.1, Apache Ant 1.10.15, upstream Ivy setup.
|
||||
|
||||
Build using the upstream Ant entry point described in its contributing guide:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/projectlombok/lombok.git
|
||||
cd lombok
|
||||
git checkout -b openidea-1.18.48 v1.18.48
|
||||
git fetch https://github.com/chagong/lombok.git fix-equinox-bundle-wiring
|
||||
git cherry-pick 2d535482a913de46c27d06c5b0b294918ede3f9c 3098f4dd78b5349c2f382e7310e64db810dd380b
|
||||
ant dist
|
||||
cp dist/lombok.jar /path/to/openidea/plugins/lombok/lombok-1.18.48-openidea.1.jar
|
||||
```
|
||||
|
||||
The changelog context conflicts because the first commit adds a release-note line under the
|
||||
older development heading and the second removes it. Resolve mechanically by adding then removing
|
||||
only that line, retaining the complete v1.18.48 changelog. The final source diff from v1.18.48 changes
|
||||
only `src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`,
|
||||
and `test/core/src/lombok/launch/PatchFixesHiderTest.java`; no other Java source changes were made.
|
||||
In the build checkout, the two remote resolvers in
|
||||
`buildScripts/ivysettings.xml` were reordered to try Maven Central before Eclipse staging; the
|
||||
local resolver and all dependency coordinates were retained. The JVM was configured to use the
|
||||
local HTTP/HTTPS proxy for downloads. Ant's timestamps mean a rebuild need not be byte-identical;
|
||||
`SHA256SUMS` records the checked-in artifact and is verified by the Rust regression tests.
|
||||
|
||||
## Validation
|
||||
|
||||
A parent POM with DTO and application modules uses Lombok 1.18.46. Root `lombok.config` enables
|
||||
`config.stopBubbling = true` and `lombok.accessors.chain = true`.
|
||||
|
||||
On macOS arm64 with JDT LS 1.63.0 / Equinox 3.25.0, server OpenJDK 25 and project JDK 21:
|
||||
|
||||
- Official 1.18.46 and 1.18.48 agents generated `void` setters; the following Maven incremental
|
||||
build reused the DTO classes and failed with `void cannot be dereferenced`.
|
||||
- This agent passed five fresh project/workspace builds. `javap` confirmed setters returning
|
||||
the DTO class; the following Maven incremental install passed.
|
||||
- Without `lombok.config`, both official 1.18.48 and patched agents built successfully and produced
|
||||
identical `javap` output, including default `void` setters.
|
||||
- Upstream `PatchFixesHiderTest` passed both the standard OSGi and legacy Equinox lookup tests.
|
||||
|
||||
Windows and Linux runtime validation has not been performed. Rust tests cover source selection
|
||||
for both supported architectures on all three OS variants, offline provisioning, corruption
|
||||
repair, concurrent windows, explicit user overrides, and JDT LS workspace migration.
|
||||
@@ -0,0 +1 @@
|
||||
10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4 lombok-1.18.48-openidea.1.jar
|
||||
Binary file not shown.
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user