дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-10 11:45:41 +02:00
Порівняти коміти
1
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
af785f37ed |
No files matched your search
Створено
+35
-40
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,7 +4075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4090,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4099,7 +4099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4115,7 +4115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,7 +4169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4183,19 +4183,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4209,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4254,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4272,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4294,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4310,7 +4309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4365,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4374,7 +4373,6 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"ignore",
|
||||
"lsp-types",
|
||||
"parking_lot",
|
||||
@@ -4390,7 +4388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4413,7 +4411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4431,7 +4429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4444,7 +4442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4461,7 +4459,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4484,7 +4482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4503,7 +4501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4523,7 +4521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4536,7 +4534,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4555,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4570,7 +4568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4583,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4595,7 +4593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4616,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4635,7 +4633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4657,7 +4655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4673,7 +4671,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5808,7 +5806,6 @@ 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",
|
||||
]
|
||||
@@ -6144,7 +6141,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.33"
|
||||
version = "0.1.32"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"block2 0.6.2",
|
||||
@@ -6186,9 +6183,7 @@ 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.33"
|
||||
version = "0.1.32"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
·
|
||||
<a href="#benchmark">Benchmark</a>
|
||||
·
|
||||
<a href="#build">Build</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -45,6 +47,24 @@ 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,13 +1,5 @@
|
||||
# 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)
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! 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,11 +3,8 @@
|
||||
//! 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};
|
||||
@@ -240,7 +237,12 @@ 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 = maven_reactor(m, &rel).ok_or_else(|| 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_dir = subdir(root, &top.dir);
|
||||
if module == top_dir {
|
||||
return Ok(vec![step(top, &[])]);
|
||||
@@ -609,90 +611,6 @@ 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(
|
||||
@@ -702,7 +620,7 @@ pub fn build_for_launch(
|
||||
test: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let plan = plan(root, model, BuildKind::Build, Some(module))?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
@@ -717,14 +635,6 @@ 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))
|
||||
}
|
||||
|
||||
@@ -911,142 +821,6 @@ 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();
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
//! 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,7 +8,6 @@
|
||||
pub mod application_update;
|
||||
pub mod compile;
|
||||
pub mod gradle;
|
||||
pub mod kotlin_bridge;
|
||||
pub mod maven;
|
||||
pub mod pom_edit;
|
||||
mod process;
|
||||
|
||||
@@ -601,72 +601,6 @@ 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.
|
||||
pub(crate) fn program_path(program: &str, env: &BTreeMap<String, String>) -> std::path::PathBuf {
|
||||
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,12 +371,6 @@ 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,15 +12,6 @@ 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)]
|
||||
@@ -33,7 +24,6 @@ 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,
|
||||
@@ -43,21 +33,7 @@ pub enum ErrorKind {
|
||||
|
||||
impl IdeError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
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 }),
|
||||
}
|
||||
Self { kind, message: message.into() }
|
||||
}
|
||||
pub fn not_found(m: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, m)
|
||||
@@ -89,30 +65,3 @@ 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,21 +215,13 @@ 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). 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> {
|
||||
/// (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> {
|
||||
let encoding = encoding.map(str::trim).filter(|e| !e.is_empty()).unwrap_or("UTF-8");
|
||||
let added = fix_kind(Kind::Java, Some(&[vm_args.to_owned()]), vars, inherited, encoding, windows).java_args;
|
||||
if added.is_empty() {
|
||||
if (!windows && is_utf8(encoding)) || sets(vm_args, STD_PROPERTIES) {
|
||||
return None;
|
||||
}
|
||||
let added = added.join(" ");
|
||||
let added = options(STD_PROPERTIES, encoding).join(" ");
|
||||
Some(match vm_args.trim() {
|
||||
"" => added,
|
||||
own => format!("{own} {added}"),
|
||||
@@ -454,53 +446,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn java_debug_launches_write_what_java_debug_decodes() {
|
||||
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"))
|
||||
);
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
fn filtered(chunks: &[&str]) -> String {
|
||||
|
||||
@@ -276,8 +276,6 @@ 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,
|
||||
@@ -294,7 +292,6 @@ 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.
|
||||
@@ -328,7 +325,6 @@ 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,
|
||||
@@ -342,7 +338,6 @@ 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,
|
||||
@@ -508,50 +503,9 @@ 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 {
|
||||
@@ -765,35 +719,6 @@ 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,7 +905,6 @@ 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,11 +7,10 @@ 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", "CFURL"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The font families installed on this machine, for the font pickers in
|
||||
//! Settings (editor, typography fallback, terminal, UI font).
|
||||
//!
|
||||
//! Each platform asks the native font system for installed families: CoreText on
|
||||
//! Each platform asks the font system the WebView itself resolves CSS
|
||||
//! `font-family` names against, so every listed name renders: 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
|
||||
@@ -83,164 +84,14 @@ 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, CFURL};
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType};
|
||||
use objc2_core_text::{
|
||||
CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait,
|
||||
kCTFontTraitsAttribute, kCTFontURLAttribute, kCTFontWeightTrait,
|
||||
kCTFontTraitsAttribute,
|
||||
};
|
||||
|
||||
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
|
||||
@@ -359,92 +210,6 @@ 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
|
||||
@@ -1,107 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
//! 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,9 +4,7 @@
|
||||
//! 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;
|
||||
@@ -150,15 +148,9 @@ 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_project_workers_blocking(
|
||||
vec![project.notebooks.clone()],
|
||||
project.kotlin_bridge.iter().cloned().collect(),
|
||||
);
|
||||
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
@@ -166,14 +158,7 @@ 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_project_workers_blocking(
|
||||
&self,
|
||||
notebooks: Vec<Arc<notebook::NotebookService>>,
|
||||
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
|
||||
) {
|
||||
for bridge in &bridges {
|
||||
bridge.stop();
|
||||
}
|
||||
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
|
||||
for service in ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
@@ -181,9 +166,6 @@ 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;
|
||||
}
|
||||
@@ -204,11 +186,7 @@ impl Host {
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
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(),
|
||||
);
|
||||
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
|
||||
@@ -219,18 +197,12 @@ 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,7 +97,6 @@ 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);
|
||||
@@ -138,9 +137,6 @@ 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,13 +96,7 @@ 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,
|
||||
&config.env,
|
||||
|key| std::env::var(key).ok(),
|
||||
encoding,
|
||||
windows,
|
||||
) {
|
||||
if let Some(vm_args) = ide_core::output_encoding::java_debug_vm_args(vm_args, encoding, windows) {
|
||||
extra.insert("vmArgs".into(), vm_args.into());
|
||||
config.extra_json = Some(serde_json::Value::Object(extra).to_string());
|
||||
}
|
||||
@@ -458,7 +452,7 @@ mod tests {
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: None,
|
||||
env: [("JDK_JAVA_OPTIONS".into(), "".into()), ("JAVA_TOOL_OPTIONS".into(), "".into())].into(),
|
||||
env: Default::default(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -474,7 +468,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 -Dfile.encoding=UTF-8"
|
||||
"-Xmx1g -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dsun.stdout.encoding=UTF-8 -Dsun.stderr.encoding=UTF-8"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(fixed.extra_json.as_deref().unwrap()).unwrap()["mainClass"],
|
||||
@@ -487,13 +481,4 @@ 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,73 +1,8 @@
|
||||
use super::Channel;
|
||||
use crate::Host;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_core::IdeResult;
|
||||
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,13 +1,11 @@
|
||||
//! 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::{JavaBuildStatus, JavaLaunchInfo};
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cancellable with `cancel_request(request)`.
|
||||
@@ -41,46 +39,20 @@ async fn resolve_launch(
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
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 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 lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _external_build = external_build;
|
||||
let built = if delegated {
|
||||
let outputs = project.java_launch_snapshots.lock().build(
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
compile::build_for_launch(
|
||||
&project.root,
|
||||
&model,
|
||||
&load(&project.root, &project.build_cache_dir(), &settings),
|
||||
&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()
|
||||
};
|
||||
@@ -131,26 +103,6 @@ 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)?;
|
||||
@@ -180,40 +132,3 @@ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
//! 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,18 +83,12 @@ pub async fn lsp_implementations(
|
||||
result
|
||||
}
|
||||
|
||||
/// Contents of a read-only language-server document, including JDT library
|
||||
/// classes and disk class files. Cached per project.
|
||||
/// Contents of a virtual document (library class, decompiled code) that a
|
||||
/// language server returned a non-`file:` URI for. 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)`.
|
||||
@@ -214,7 +208,7 @@ pub async fn lsp_format(
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
crate::formatter::format(host, window, doc, range, options).await
|
||||
host.project(window)?.lsp.format(doc, range, options).await
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
@@ -269,13 +263,7 @@ pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
|
||||
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(())
|
||||
host.project(window)?.lsp.restart(server).await
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
@@ -379,10 +367,6 @@ 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,16 +172,11 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
if ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.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 {
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")))
|
||||
{
|
||||
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,15 +16,11 @@ pub fn spawn(
|
||||
options.args.clear();
|
||||
options.env = global.env;
|
||||
}
|
||||
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));
|
||||
// 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 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::new(&host.data_dir);
|
||||
let tools = tools(host, window)?;
|
||||
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::new(&host.data_dir);
|
||||
let tools = tools(host, window)?;
|
||||
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,8 +42,6 @@ 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.
|
||||
@@ -52,7 +50,6 @@ 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)>>,
|
||||
@@ -129,8 +126,6 @@ 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())
|
||||
@@ -144,27 +139,6 @@ 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(
|
||||
@@ -176,7 +150,6 @@ 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);
|
||||
@@ -253,22 +226,11 @@ 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();
|
||||
@@ -289,7 +251,6 @@ 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);
|
||||
@@ -369,7 +330,7 @@ impl Host {
|
||||
let content_watchers = extra_roots
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history, Some(&kotlin_bridge))?;
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
.collect();
|
||||
@@ -405,14 +366,10 @@ 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,
|
||||
@@ -431,9 +388,6 @@ 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());
|
||||
}
|
||||
@@ -527,14 +481,10 @@ 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,
|
||||
@@ -828,15 +778,9 @@ 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,
|
||||
self.kotlin_bridge.as_ref(),
|
||||
) {
|
||||
if let Some(w) =
|
||||
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
|
||||
{
|
||||
watchers.push((r, w));
|
||||
}
|
||||
}
|
||||
@@ -968,9 +912,7 @@ 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,
|
||||
@@ -979,9 +921,6 @@ 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,7 +179,6 @@ 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
|
||||
@@ -191,28 +190,6 @@ 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,9 +106,6 @@ impl crate::Host {
|
||||
if previous == trusted {
|
||||
continue;
|
||||
}
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
@@ -178,30 +175,6 @@ 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"
|
||||
@@ -262,7 +235,6 @@ 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: r"\u4E16\u754C".into() }] },
|
||||
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".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", r"\u4E16\u754C").replace("logical", "changed");
|
||||
let partial_text = displayed.replace("target", "世界").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,
|
||||
r"\u4E16\u754C".into(),
|
||||
"世界".into(),
|
||||
false,
|
||||
vec![ide_search::ReplaceTarget {
|
||||
path: closed_path.clone().into(),
|
||||
@@ -181,6 +181,5 @@ 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,39 +94,6 @@ 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");
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//! 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,94 +20,6 @@ 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,19 +53,6 @@ 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,7 +7,6 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-language.workspace = true
|
||||
|
||||
@@ -17,9 +17,6 @@ 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
|
||||
@@ -192,24 +189,6 @@ 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)>,
|
||||
@@ -399,8 +378,7 @@ impl LspManager {
|
||||
self.jdtls_command_cancellable(path, command, arguments, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
/// Cancellable delegate commands for project-owned background services.
|
||||
pub async fn jdtls_command_cancellable(
|
||||
async fn jdtls_command_cancellable(
|
||||
&self,
|
||||
path: &Path,
|
||||
command: &str,
|
||||
@@ -455,28 +433,9 @@ 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?;
|
||||
@@ -573,59 +532,6 @@ 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| {
|
||||
@@ -636,7 +542,7 @@ impl LspManager {
|
||||
}
|
||||
})?;
|
||||
// Hot code replace after each (auto-)build; errors only cost the feature.
|
||||
let settings = json!({ "debugSupportOnDecompiledSource": "on", "hotCodeReplace": "auto", "showStaticVariables": false, "showQualifiedNames": false,
|
||||
let settings = json!({ "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" });
|
||||
@@ -668,7 +574,6 @@ 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");
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
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" },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
<?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,12 +1886,6 @@ 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"),
|
||||
@@ -2172,7 +2166,6 @@ 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());
|
||||
@@ -2181,7 +2174,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 && !class_file {
|
||||
let result: IdeResult<Option<String>> = if serves_scheme {
|
||||
self.call::<_, Option<Value>>(
|
||||
t,
|
||||
"workspace/textDocumentContent",
|
||||
@@ -2191,7 +2184,7 @@ impl LspManager {
|
||||
)
|
||||
.await
|
||||
.map(|v| v.and_then(|v| v.get("text").and_then(Value::as_str).map(str::to_owned)))
|
||||
} else if class_file && languages.iter().any(|l| l.as_str() == "java") {
|
||||
} else if scheme == "jdt" && languages.iter().any(|l| l.as_str() == "java") {
|
||||
self.call::<_, Option<String>>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT)
|
||||
.await
|
||||
} else {
|
||||
|
||||
@@ -99,8 +99,6 @@ 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,
|
||||
@@ -128,8 +126,6 @@ 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),
|
||||
@@ -138,13 +134,7 @@ impl Server {
|
||||
|
||||
/// Configuration of the current (or last) instance; the base config before the first launch.
|
||||
pub fn effective_config(&self) -> LanguageServerConfig {
|
||||
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
|
||||
self.effective.lock().clone().unwrap_or_else(|| self.config.clone())
|
||||
}
|
||||
|
||||
pub fn set_effective_config(&self, config: LanguageServerConfig) {
|
||||
@@ -563,19 +553,6 @@ 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,9 +219,6 @@ 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)]
|
||||
@@ -338,7 +335,7 @@ pub struct VirtualDocument {
|
||||
pub title: String,
|
||||
/// Origin shown in the tooltip / banner, e.g. the jar path.
|
||||
pub description: Option<String>,
|
||||
/// Generated Java from a decompiler, rather than real source or a disassembler stub.
|
||||
/// Generated by a decompiler / disassembler rather than real sources.
|
||||
pub decompiled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
//! Virtual documents: read-only contents a language server provides for a
|
||||
//! URI (library classes from `jdt://`, disk classes from `file://`, or
|
||||
//! `workspace/textDocumentContent`).
|
||||
//! non-`file:` URI (library classes from `jdt://`, `workspace/textDocumentContent`).
|
||||
|
||||
use percent_encoding::percent_decode_str;
|
||||
|
||||
/// Recognize decompiler headers, without treating disassembler stubs as Java source.
|
||||
/// 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.
|
||||
pub fn is_decompiled(text: &str) -> bool {
|
||||
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))
|
||||
})
|
||||
let head: String = text.chars().take(600).collect::<String>().to_ascii_lowercase();
|
||||
GENERATED_MARKERS.iter().any(|m| head.contains(m))
|
||||
}
|
||||
|
||||
/// `scheme` of a URI (`jdt` for `jdt://…`).
|
||||
@@ -42,16 +34,7 @@ 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 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");
|
||||
}
|
||||
let name = segments(uri).pop().unwrap_or_else(|| uri.to_owned());
|
||||
match name.strip_suffix(".class") {
|
||||
Some(stem) if !decompiled && scheme(uri) == "jdt" => format!("{stem}.java"),
|
||||
_ => name,
|
||||
@@ -85,19 +68,6 @@ 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::*;
|
||||
@@ -114,30 +84,6 @@ 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(
|
||||
@@ -152,18 +98,10 @@ 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;"));
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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.
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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,7 +252,6 @@ fn harness_configured(
|
||||
indent: None,
|
||||
ui_mode: None,
|
||||
framework_ui_modes: Vec::new(),
|
||||
formatter: None,
|
||||
tree_sitter: None,
|
||||
language_servers: servers
|
||||
.iter()
|
||||
@@ -1017,70 +1016,6 @@ 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| {
|
||||
@@ -1313,17 +1248,12 @@ 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, on_save: false }).await.unwrap();
|
||||
let edits = h.mgr.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true }).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, on_save: false })
|
||||
.await
|
||||
.is_err()
|
||||
h.mgr.format(doc, Some(rng(0, 0, 1, 0)), FormatOptions { tab_size: 2, insert_spaces: true }).await.is_err()
|
||||
);
|
||||
|
||||
s.respond("textDocument/semanticTokens/full", json!({ "data": [0, 3, 3, 0, 1, 1, 0, 3, 0, 0] }));
|
||||
@@ -1687,61 +1617,6 @@ 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")]
|
||||
@@ -2807,32 +2682,3 @@ 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,60 +55,12 @@ 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
|
||||
/// (Tools › Terminal › "Activate virtualenv"). `shell` must be the resolved PTY shell.
|
||||
pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option<String> {
|
||||
/// (PyCharm's Tools › Terminal › "Activate virtualenv"), for the given shell.
|
||||
pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Option<String> {
|
||||
if !s.languages.python.activate_virtualenv {
|
||||
return None;
|
||||
}
|
||||
@@ -744,8 +744,12 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option<Str
|
||||
if std::env::var_os("VIRTUAL_ENV").is_some_and(|v| Path::new(&v) == venv) {
|
||||
return None;
|
||||
}
|
||||
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 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 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,20 +717,6 @@ 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");
|
||||
@@ -948,49 +934,6 @@ 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 (source attachments resolved by JDT).
|
||||
//! JDK's `src.zip` and the Maven / Gradle dependency jars (sources jars preferred).
|
||||
|
||||
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 binary jar so Java classes open through JDT's source provider.
|
||||
/// local repository, its root being the jar (the `-sources.jar` when downloaded).
|
||||
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);
|
||||
[".jar", "-sources.jar"]
|
||||
["-sources.jar", ".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();
|
||||
[".jar", "-sources.jar"].iter().find_map(|suffix| {
|
||||
["-sources.jar", ".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
|
||||
//! app-provided jar, or falls back to the newest release in the Maven / Gradle caches.
|
||||
//! newest jar available, managed (installed by the IDE) or 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 app-provided jar (its version is shown by the tool registry).
|
||||
/// `None` for the managed jar (the latest release when installed).
|
||||
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 app-provided jar, always attached: it is inert
|
||||
/// options) wins, then `managed` (the IDE-installed 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,23 +499,6 @@ 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();
|
||||
|
||||
@@ -319,40 +319,14 @@ fn terminal_activation_for_project_venv() {
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None);
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None);
|
||||
fake_venv(tmp.path(), ".venv");
|
||||
let line = terminal_activation(tmp.path(), &s, "/bin/zsh").unwrap();
|
||||
let line = terminal_activation(tmp.path(), &s, Some("/bin/zsh")).unwrap();
|
||||
assert!(line.starts_with("source '") && line.ends_with("/activate'"), "{line}");
|
||||
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");
|
||||
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");
|
||||
s.languages.python.activate_virtualenv = false;
|
||||
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);
|
||||
assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -540,12 +514,12 @@ fn build_dependencies_resolve_to_local_jars() {
|
||||
std::fs::write(&p, "").unwrap();
|
||||
p
|
||||
};
|
||||
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"));
|
||||
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 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");
|
||||
let slf4j = touch(files.join("a1b2/slf4j-api-2.0.9.jar"));
|
||||
touch(files.join("c3d4/slf4j-api-2.0.9-sources.jar"));
|
||||
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 dep = |gradle: bool, g: &str, a: &str, v: &str| BuildDependency {
|
||||
gradle,
|
||||
group: g.into(),
|
||||
@@ -566,14 +540,14 @@ fn build_dependencies_resolve_to_local_jars() {
|
||||
assert_eq!(
|
||||
shown,
|
||||
[
|
||||
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0.jar"),
|
||||
("Maven: com.google.guava:guava:33.0", "maven", "guava-33.0-sources.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.jar"),
|
||||
("Gradle: org.slf4j:slf4j-api:2.0.9", "gradle", "slf4j-api-2.0.9-sources.jar"),
|
||||
]
|
||||
);
|
||||
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava)));
|
||||
assert_eq!(groups[0].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&guava_src)));
|
||||
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)));
|
||||
assert_eq!(groups[2].roots[0].path, ide_core::IpcPath::from(ide_vfs::archive_root(&slf4j_src)));
|
||||
// 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.
|
||||
pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
|
||||
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,12 +808,8 @@ 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());
|
||||
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()]));
|
||||
let (_, args) = resolve_shell(None, &[]);
|
||||
assert_eq!(args, vec!["-l".to_owned()]);
|
||||
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");
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! 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,8 +10,6 @@
|
||||
//! [`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;
|
||||
@@ -148,7 +146,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::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
|
||||
Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,9 +322,6 @@ 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);
|
||||
}
|
||||
@@ -394,12 +389,9 @@ fn is_bundle(command: &str) -> bool {
|
||||
command.ends_with(".jar")
|
||||
}
|
||||
|
||||
/// Path of an app-provided or installed plugin jar. Embedded jars are materialized on demand.
|
||||
/// Path of an installed plugin bundle (`<tools>/<name>/bin/<jar>`), if present.
|
||||
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)
|
||||
@@ -550,7 +542,6 @@ 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(),
|
||||
@@ -613,8 +604,7 @@ impl Tools {
|
||||
};
|
||||
let (status, path, version) = self.detect(spec);
|
||||
let unavailable_reason = self.unavailable_reason(spec);
|
||||
let bundled = matches!(spec.method, Method::Bundled { .. });
|
||||
let can_uninstall = status == ToolStatus::Installed && !bundled;
|
||||
let can_uninstall = status == ToolStatus::Installed;
|
||||
Some(ToolInfo {
|
||||
name: spec.name.to_string(),
|
||||
display_name: spec.display.to_string(),
|
||||
@@ -625,7 +615,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() && !bundled,
|
||||
can_install: unavailable_reason.is_none(),
|
||||
unavailable_reason,
|
||||
can_uninstall,
|
||||
install_hint,
|
||||
@@ -664,12 +654,6 @@ 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 {
|
||||
@@ -798,9 +782,6 @@ 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]);
|
||||
@@ -1207,8 +1188,6 @@ 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,89 +348,6 @@ 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::*;
|
||||
@@ -547,37 +464,3 @@ 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,7 +12,6 @@ pub enum ToolKind {
|
||||
Debugger,
|
||||
/// Loaded into another tool's process, e.g. a jdtls agent.
|
||||
Plugin,
|
||||
Formatter,
|
||||
}
|
||||
|
||||
/// Where release archives come from.
|
||||
@@ -32,8 +31,6 @@ 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.
|
||||
@@ -59,7 +56,6 @@ 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",
|
||||
@@ -106,7 +102,7 @@ impl ToolSpec {
|
||||
|
||||
use Arch::{Arm64, X64};
|
||||
use Os::{Linux, Mac, Windows};
|
||||
use ToolKind::{Debugger, Formatter, LanguageServer, Plugin};
|
||||
use ToolKind::{Debugger, LanguageServer, Plugin};
|
||||
|
||||
const V: Option<(&str, &[&str])> = None;
|
||||
const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")];
|
||||
@@ -123,17 +119,6 @@ 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",
|
||||
@@ -593,13 +578,15 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
display: "Lombok (jdtls agent)",
|
||||
kind: Plugin,
|
||||
commands: &["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"),
|
||||
// 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"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
@@ -955,11 +942,6 @@ 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,6 +434,7 @@ 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,61 +43,6 @@ 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"));
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//! `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 ASCII representation (for properties).
|
||||
/// File charset plus an optional reversible ASCII representation (for properties).
|
||||
/// The host supplies conversion functions so Git and documents share one codec.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TextEncoding {
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
//! 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,19 +62,20 @@ 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 rest.chars().next().is_some_and(|next| !next.is_ascii()) {
|
||||
// Keep a literal backslash from swallowing a generated escape.
|
||||
} 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.
|
||||
out.push_str("\\u005C");
|
||||
} else {
|
||||
out.push(c);
|
||||
@@ -386,32 +387,13 @@ 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"] {
|
||||
for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\u0041", r"\\u0041", r"\uD83D\uDE00"] {
|
||||
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,7 +2,6 @@
|
||||
|
||||
mod archive;
|
||||
mod bytes;
|
||||
mod compare;
|
||||
mod encoding;
|
||||
mod ops;
|
||||
mod rules;
|
||||
@@ -21,9 +20,6 @@ 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,57 +641,6 @@ 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();
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/** 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,6 +1,5 @@
|
||||
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,6 +1,5 @@
|
||||
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,6 +1,5 @@
|
||||
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 = ["<!--", "-->"]
|
||||
|
||||
@@ -32,7 +32,7 @@ resolveAdditionalTextEditsSupport = true
|
||||
|
||||
[language-servers.initialization-options.settings.java]
|
||||
autobuild = { enabled = true }
|
||||
contentProvider = { preferred = "fernflowerContentProvider" }
|
||||
contentProvider = { preferred = "fernflower" }
|
||||
maven = { downloadSources = true, updateSnapshots = false }
|
||||
eclipse = { downloadSources = true }
|
||||
references = { includeDecompiledSources = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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,6 +1,5 @@
|
||||
id = "javascriptreact"
|
||||
name = "JavaScript JSX"
|
||||
formatter = { tool = "prettier", parser = "babel", range = true }
|
||||
extensions = ["jsx"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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,6 +1,5 @@
|
||||
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,6 +1,5 @@
|
||||
id = "less"
|
||||
name = "Less"
|
||||
formatter = { tool = "prettier", parser = "less", range = false }
|
||||
extensions = ["less"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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,6 +1,5 @@
|
||||
id = "scss"
|
||||
name = "SCSS"
|
||||
formatter = { tool = "prettier", parser = "scss", range = false }
|
||||
extensions = ["scss"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "typescript"
|
||||
name = "TypeScript"
|
||||
formatter = { tool = "prettier", parser = "typescript", range = true }
|
||||
extensions = ["ts", "mts", "cts"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "typescriptreact"
|
||||
name = "TypeScript JSX"
|
||||
formatter = { tool = "prettier", parser = "typescript", range = true }
|
||||
extensions = ["tsx"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
id = "vue"
|
||||
name = "Vue"
|
||||
formatter = { tool = "prettier", parser = "vue", range = true }
|
||||
extensions = ["vue"]
|
||||
block-comment = ["<!--", "-->"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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.33",
|
||||
"version": "0.1.32",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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
|
||||
@@ -1,73 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,161 +0,0 @@
|
||||
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
|
||||
@@ -1,32 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
@@ -1,177 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Бінарний файл не відображається.
@@ -1,8 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,94 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,104 +0,0 @@
|
||||
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.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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.
|
||||
@@ -1 +0,0 @@
|
||||
10f5cd1a32b946d3ff1c075735b94f35fffdbac11dea2be29d24dbf591dd51a4 lombok-1.18.48-openidea.1.jar
|
||||
Бінарний файл не відображається.
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача