Compare commits

...
Author SHA1 Message Date
l0ng-ai f7690df2fe fix(windows): preserve relocated tool wrappers and local tool consumers 2026-11-01 19:00:39 +08:00
l0ng-ai 70acc79d14 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/ide-host/Cargo.toml
#	crates/ide-host/src/project.rs
2026-11-01 18:59:37 +08:00
l0ng-ai 49f9c57bcc docs: remove the README build section 2026-11-01 18:46:40 +08:00
l0ng-ai 07609ef9c6 chore: v0.1.33 2026-11-01 18:35:25 +08:00
l0ng-ai bf2427bd3a feat(java): selectable Java formatting profiles (#680)
Java formatting comes from jdtls' Eclipse formatter, whose defaults differ from the IntelliJ IDEA code style. Settings › Editor › Code Style › Java now offers a formatting profile: Default (unchanged), IntelliJ IDEA, or a custom Eclipse formatter XML (absolute or project-relative path).

The IntelliJ IDEA profile is a bundled Eclipse formatter XML that follows IDEA's default Java code style item by item (indents, aligned multi-line parameters, annotation placement, else/catch/finally on the same line, array initializers, lambdas, blank lines, comments not reflowed). Manual line breaks are kept; lines longer than 120 columns wrap where necessary, which differs from IDEA's default of not wrapping. The profile reaches a running jdtls through didChangeConfiguration without a restart; an unreadable custom file shows a warning and leaves code intelligence running. Code style changes reconfigure language servers without scheduling Kotlin bridge builds.

Also drops two catalog entries whose texts are no longer used.

Fixes #649
2026-11-01 18:26:24 +08:00
l0ng-ai 48f9891fc4 fix: store rebuildable Windows data in Local 2026-11-01 14:11:50 +08:00
51 changed files with 1529 additions and 103 deletions

No files matched your search

Generated
+37 -35
View File
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ide-agent"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]]
name = "ide-analysis"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ast-grep-core",
"ide-core",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "ide-build"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4090,7 +4090,7 @@ dependencies = [
[[package]]
name = "ide-clipboard"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
@@ -4099,7 +4099,7 @@ dependencies = [
[[package]]
name = "ide-core"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4115,7 +4115,7 @@ dependencies = [
[[package]]
name = "ide-coverage"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-run",
@@ -4129,7 +4129,7 @@ dependencies = [
[[package]]
name = "ide-dap"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-jsonrpc",
@@ -4147,7 +4147,7 @@ dependencies = [
[[package]]
name = "ide-db"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"bb8",
"dirs 6.0.0",
@@ -4169,7 +4169,7 @@ dependencies = [
[[package]]
name = "ide-docker"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"libc",
@@ -4183,7 +4183,7 @@ dependencies = [
[[package]]
name = "ide-fonts"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"objc2-core-foundation",
"objc2-core-text",
@@ -4195,7 +4195,7 @@ dependencies = [
[[package]]
name = "ide-github"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-vcs",
@@ -4209,7 +4209,7 @@ dependencies = [
[[package]]
name = "ide-host"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"dunce",
@@ -4250,11 +4250,12 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
]
[[package]]
name = "ide-http"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"parking_lot",
@@ -4272,7 +4273,7 @@ dependencies = [
[[package]]
name = "ide-index"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"criterion",
@@ -4294,7 +4295,7 @@ dependencies = [
[[package]]
name = "ide-jsonrpc"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"encoding_rs",
"futures",
@@ -4310,7 +4311,7 @@ dependencies = [
[[package]]
name = "ide-language"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"criterion",
@@ -4365,7 +4366,7 @@ dependencies = [
[[package]]
name = "ide-lsp"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"futures",
@@ -4374,6 +4375,7 @@ dependencies = [
"ide-jsonrpc",
"ide-language",
"ide-text",
"ide-vfs",
"ignore",
"lsp-types",
"parking_lot",
@@ -4389,7 +4391,7 @@ dependencies = [
[[package]]
name = "ide-notebook"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"futures",
"ide-core",
@@ -4412,7 +4414,7 @@ dependencies = [
[[package]]
name = "ide-plugin"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-language",
@@ -4430,7 +4432,7 @@ dependencies = [
[[package]]
name = "ide-profiler"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-run",
@@ -4443,7 +4445,7 @@ dependencies = [
[[package]]
name = "ide-remote"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"globset",
"ide-core",
@@ -4460,7 +4462,7 @@ dependencies = [
[[package]]
name = "ide-run"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4483,7 +4485,7 @@ dependencies = [
[[package]]
name = "ide-sdk"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"ide-core",
@@ -4502,7 +4504,7 @@ dependencies = [
[[package]]
name = "ide-search"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"criterion",
"globset",
@@ -4522,7 +4524,7 @@ dependencies = [
[[package]]
name = "ide-spell"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"parking_lot",
@@ -4535,7 +4537,7 @@ dependencies = [
[[package]]
name = "ide-ssh"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dirs 6.0.0",
"flate2",
@@ -4554,7 +4556,7 @@ dependencies = [
[[package]]
name = "ide-syntax"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"ide-language",
@@ -4569,7 +4571,7 @@ dependencies = [
[[package]]
name = "ide-terminal"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"libc",
@@ -4582,7 +4584,7 @@ dependencies = [
[[package]]
name = "ide-text"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"criterion",
"ide-core",
@@ -4594,7 +4596,7 @@ dependencies = [
[[package]]
name = "ide-tools"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"flate2",
"hex",
@@ -4615,7 +4617,7 @@ dependencies = [
[[package]]
name = "ide-vcs"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"dunce",
"encoding_rs",
@@ -4634,7 +4636,7 @@ dependencies = [
[[package]]
name = "ide-vfs"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"chardetng",
"dunce",
@@ -4656,7 +4658,7 @@ dependencies = [
[[package]]
name = "ide-web"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"ide-core",
"open",
@@ -4672,7 +4674,7 @@ dependencies = [
[[package]]
name = "ide-workspace"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"dunce",
@@ -6143,7 +6145,7 @@ dependencies = [
[[package]]
name = "openidea"
version = "0.1.32"
version = "0.1.33"
dependencies = [
"blake3",
"block2 0.6.2",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "src-tauri"]
[workspace.package]
version = "0.1.32"
version = "0.1.33"
edition = "2024"
rust-version = "1.96"
license = "Apache-2.0"
-20
View File
@@ -14,8 +14,6 @@
 · 
<a href="#benchmark">Benchmark</a>
&nbsp;·&nbsp;
<a href="#build">Build</a>
&nbsp;·&nbsp;
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
@@ -47,24 +45,6 @@ IDEA analyses Python more deeply, so "ready" doesn't mean the same work on
both sides. Both are measured from the outside, the same way: see
[`perf/compare`](perf/compare) to run it on your machine and projects.
## Build
```sh
pnpm install
pnpm tauri dev
```
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
Native notebook execution tests are opt-in and need two distinct Python environments
with `ipykernel` installed:
```sh
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
```
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
+6
View File
@@ -371,6 +371,12 @@ pub struct TextAttributesOverride {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageCodeStyle {
/// Java formatter: `default` | `intellij` | `custom` (other languages ignore it).
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter: Option<String>,
/// Eclipse formatter XML; relative paths start at the project root.
#[serde(skip_serializing_if = "Option::is_none")]
pub formatter_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_tab: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
+3
View File
@@ -52,3 +52,6 @@ tempfile.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
+1
View File
@@ -55,6 +55,7 @@ async fn run(base: PathBuf) -> i32 {
let sink_peer = peer.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
data_dir,
languages,
syntax: Arc::default(),
+474
View File
@@ -0,0 +1,474 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
.iter()
.any(|root| entry.starts_with(root))
|| entry.starts_with("agents/tools")
|| entry == Path::new("agents/registry.json")
|| (entry.components().count() == 1
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
}
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
if is_local(entry) { local.join(entry) } else { data.join(entry) }
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Skip,
Rename,
}
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
}
#[derive(Debug, PartialEq, Eq)]
enum RenameFailure {
Copy,
KeepSource,
}
fn rename_failure(error: &io::Error) -> RenameFailure {
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
}
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
/// the background. Consumers always use Local, even when migration fails.
pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
}
fn migrate_entries(data: &Path, local: &Path) {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
"cache",
"lsp",
"notebook-runtime",
"server",
"java-usages-1.1.1.jar",
"agents/registry.json",
]
.into_iter()
.map(PathBuf::from)
.collect();
// Virtual environments contain absolute interpreter paths. Keep the old copy
// intact; these tools are installed afresh in Local when requested. Registry
// agents also have absolute commands in user config, so leave legacy installs.
match fs::read_dir(data.join("tools")) {
Ok(tools) => {
for entry in tools {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
continue;
}
};
let name = entry.file_name();
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
});
if venv {
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
} else {
entries.push(Path::new("tools").join(name));
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
return Ok(false);
}
if fs::symlink_metadata(source)?.file_type().is_symlink() {
return Err(io::Error::other("refusing to migrate a linked data entry"));
}
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
// Relative wrappers work at the old location too, so even a failed rename
// leaves the old installation usable and needs no rollback.
relocate_shims(source, source)?;
match rename_missing(source, target) {
Ok(()) => {
tracing::info!(?source, ?target, "moved legacy data to Local");
Ok(false)
}
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
Err(error) => Err(error),
}
}
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Ok(());
}
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
fs::create_dir_all(parent)?;
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
let result = (|| {
copy_tree(source, &staging)?;
relocate_shims(&staging, source)?;
// A tool may have been installed or a cache rebuilt while copying. Never
// merge into or overwrite it, and retain the original for recovery.
if target.try_exists()? {
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
return Ok(());
}
rename_missing(&staging, target)?;
remove_entry(source)?;
tracing::info!(?source, ?target, "copied legacy data to Local");
Ok(())
})();
if staging.exists() {
let _ = remove_entry(&staging);
}
result
}
// std::fs::rename may replace even an empty directory on modern Windows.
#[cfg(windows)]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
// canonicalize supplies extended-length paths, including redirected UNC roots.
let source = fs::canonicalize(source)?;
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
}
value.push(0);
Ok(value)
};
let source = wide(&source)?;
let target = wide(&target)?;
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
// neither replacement nor implicit cross-volume copying is permitted.
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Filesystem tests run on Unix too; production migration is Windows-only.
#[cfg(not(windows))]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs::rename(source, target)
}
fn remove_entry(path: &Path) -> io::Result<()> {
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
}
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(io::Error::other("refusing to follow a link during data migration"));
}
if metadata.is_dir() {
fs::create_dir(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
}
} else if metadata.is_file() {
fs::copy(source, target)?;
} else {
return Err(io::Error::other("unsupported data entry"));
}
Ok(())
}
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
/// valid in both locations if the move must be rolled back. npm shims already use
/// relative paths; payloads and user configuration are never rewritten.
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
return Ok(());
}
let bin = root.join("bin");
if !bin.is_dir() {
return Ok(());
}
for entry in fs::read_dir(bin)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "cmd") {
continue;
}
let text = fs::read_to_string(&path)?;
if text.starts_with("@echo off\r\n") {
let updated = relative_wrapper(&text, source);
if updated != text {
fs::write(path, updated)?;
}
}
}
Ok(())
}
fn relative_wrapper(text: &str, source: &Path) -> String {
// PathBuf can contain both separators (notably roots joined with "tools/demo").
// Compare normalized paths, but retain every other byte of the managed wrapper.
let normalized = text.replace('\\', "/");
let prefix = source.to_string_lossy().replace('\\', "/");
#[cfg(windows)]
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
let mut updated = text.to_owned();
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
for offset in offsets.into_iter().rev() {
let end = offset + prefix.len();
if normalized.as_bytes().get(end) == Some(&b'/') {
updated.replace_range(offset..end, "%~dp0..");
}
}
updated
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
assert_eq!(
relative_wrapper(text, Path::new("C:/old/tools/demo")),
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
);
}
#[test]
fn storage_policy_is_conservative_and_preserves_unix_layout() {
for entry in [
"tools/jdks",
"language-server-runtimes",
"lsp/jdtls",
"index/x",
"cache/x",
"notebook-runtime",
"server",
"logs",
"agents/tools",
"agents/registry.json",
"java-usages-1.1.1.jar",
] {
assert!(is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("local").join(entry)
);
assert_eq!(
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
Path::new("data").join(entry)
);
}
for entry in [
"git-credentials.json",
"history",
"remote",
"scratches",
"consoles",
"plugins",
"workspaces",
"agents/models.json",
"agents/project",
"unknown",
] {
assert!(!is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("roaming").join(entry)
);
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("index")).unwrap();
migrate(&old, &new);
assert!(old.join("index").exists());
assert!(!new.exists());
}
#[test]
fn decisions_cover_missing_existing_and_failed_renames() {
assert_eq!(action(true, true, false), Action::Skip);
assert_eq!(action(false, false, false), Action::Skip);
assert_eq!(action(false, true, true), Action::Skip);
assert_eq!(action(false, true, false), Action::Rename);
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
}
}
#[test]
fn same_volume_moves_and_second_start_does_not_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
fs::write(old.join("git-credentials.json"), "secret").unwrap();
fs::create_dir_all(old.join("index")).unwrap();
migrate_entries(&old, &new);
assert!(!old.join("tools/demo").exists());
assert!(old.join("tools/ty/venv").exists());
assert!(old.join("git-credentials.json").exists());
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
fs::create_dir_all(old.join("index")).unwrap();
fs::write(old.join("index/legacy"), "old").unwrap();
migrate_entries(&old, &new);
assert!(old.join("index/legacy").exists());
assert!(!new.join("index/legacy").exists());
}
#[cfg(unix)]
#[test]
fn copy_failure_does_not_publish_or_remove_source() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
assert!(copy_then_remove(&old, &new).is_err());
assert!(old.join("payload").exists());
assert!(!new.exists());
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
}
#[test]
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("nested")).unwrap();
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(!old.exists());
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
fs::create_dir(&old).unwrap();
fs::write(old.join("legacy"), "old").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(old.join("legacy").exists());
assert!(!new.join("legacy").exists());
let old_file = tmp.path().join("registry-old.json");
let new_file = tmp.path().join("registry-new.json");
fs::write(&old_file, "cached registry").unwrap();
copy_then_remove(&old_file, &new_file).unwrap();
assert!(!old_file.exists());
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
}
#[test]
fn publication_refuses_existing_files_and_empty_directories() {
let tmp = tempfile::tempdir().unwrap();
for directory in [false, true] {
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
if directory {
fs::create_dir(&old).unwrap();
fs::create_dir(&new).unwrap();
fs::write(old.join("payload"), "old").unwrap();
} else {
fs::write(&old, "old").unwrap();
fs::write(&new, "new").unwrap();
}
assert!(rename_missing(&old, &new).is_err());
assert!(old.exists() && new.exists());
if directory {
assert!(!new.join("payload").exists());
} else {
assert_eq!(fs::read_to_string(new).unwrap(), "new");
}
}
}
#[test]
fn wrapper_failure_keeps_the_old_installation() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old/tools/demo");
let new = tmp.path().join("new/tools/demo");
fs::create_dir_all(old.join("bin")).unwrap();
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
assert!(try_rename(&old, &new).is_err());
assert!(old.join("bin/demo.cmd").exists());
assert!(!new.exists());
}
#[test]
fn failures_keep_source_without_publishing_partial_data() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let blocker = tmp.path().join("blocker");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
fs::write(&blocker, "file").unwrap();
assert!(try_rename(&old, &blocker.join("target")).is_err());
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
assert!(old.join("payload").exists());
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
assert!(!tmp.path().join("new").exists());
assert!(
!fs::read_dir(tmp.path())
.unwrap()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ pub async fn format(
}
let config = config.unwrap().clone();
let custom = ide_sdk::apply::setting_path(&project.root, &prettier.path);
let tools = Tools::new(&host.data_dir);
let tools = Tools::new(&host.local_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);
+20 -3
View File
@@ -3,6 +3,7 @@
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
pub mod data_dirs;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -33,7 +34,10 @@ pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
pub struct HostConfig {
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
/// Grammars, possibly still being built on another thread.
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
@@ -49,7 +53,10 @@ pub struct HostConfig {
pub struct Host {
pub trust: trust::TrustStore,
pub config_dir: PathBuf,
/// Persistent user state, including credentials and recovery history.
pub data_dir: PathBuf,
/// Machine-local tools and rebuildable caches.
pub local_data_dir: PathBuf,
pub languages: Arc<LanguageRegistry>,
syntax: Arc<OnceLock<Arc<Syntax>>>,
pub global_settings: SettingsStore,
@@ -76,8 +83,17 @@ pub struct Host {
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } =
config;
let HostConfig {
config_dir,
data_dir,
local_data_dir,
languages,
syntax,
sink_for,
owner,
runtime,
project_environment,
} = config;
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
@@ -86,7 +102,7 @@ impl Host {
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
ssh,
environment: environment::Environment::new(project_environment, data_dir.clone()),
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
global_settings,
// Sessions, runs and terminals report to the window that started them (`window_sink`).
terminals: TerminalManager::new(),
@@ -96,6 +112,7 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.data_dir)
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
+1 -1
View File
@@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.data_dir.clone();
let data_dir = host.local_data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
+1
View File
@@ -30,6 +30,7 @@ impl Fixture {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
+1 -1
View File
@@ -61,7 +61,7 @@ async fn resolve_launch(
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.data_dir.clone();
let data_dir = host.local_data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
+6 -4
View File
@@ -172,12 +172,14 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools", "languageServers", "projectStructure"]
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")))
{
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
if code_style || toolchain {
for project in affected() {
if let Some(bridge) = &project.kotlin_bridge {
if toolchain && let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
let lsp = project.lsp.clone();
+4 -4
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
host.require_trust(window)?;
let data = host.data_dir.clone();
let data = host.local_data_dir.clone();
let (settings, root) = match host.project(window) {
Ok(project) => (project.settings.effective(), project.root.clone()),
Err(_) => (host.global_settings.effective(), Default::default()),
@@ -104,7 +104,7 @@ pub async fn jdk_download_releases(
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = Tools::new(&host.data_dir);
let tools = Tools::new(&host.local_data_dir);
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
host.end_request(request);
@@ -113,7 +113,7 @@ pub async fn jdk_download_releases(
/// Where Download JDK puts JDKs unless told otherwise.
pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::IpcPath> {
Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into())
Ok(ide_tools::jdk::jdks_dir(&host.local_data_dir).into())
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
@@ -130,7 +130,7 @@ pub async fn jdk_download(
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = Tools::new(&host.data_dir);
let tools = Tools::new(&host.local_data_dir);
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
+34 -11
View File
@@ -112,7 +112,7 @@ impl Host {
let project_key = project_key(&root);
let index = ProjectIndex::open(
&root,
&self.data_dir.join("index").join(&project_key),
&self.local_data_dir.join("index").join(&project_key),
index_roots(&root, extra_roots.clone()),
excludes.clone(),
languages.clone(),
@@ -127,8 +127,10 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let data_dir = self.local_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())
@@ -142,6 +144,27 @@ impl Host {
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
if cfg.name == "jdtls" {
let problem = ide_lsp::jdtls::configure_formatter(
cfg,
effective.editor.code_style.languages.get("java"),
root,
&data_dir,
effective.language_servers.get("jdtls"),
)
.err()
.map(|e| e.to_string());
let mut previous = formatter_problem.lock().unwrap();
if problem != *previous {
if let Some(body) = &problem {
sink.emit(IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "Java formatter configuration unavailable".into(),
body: Some(body.clone()),
});
}
*previous = problem;
}
drop(previous);
ide_lsp::jdtls::configure_source_paths(
cfg,
ide_workspace::roots::unmanaged_java_source_paths(
@@ -191,7 +214,7 @@ impl Host {
{
let settings = settings.clone();
let root = root.clone();
let data_dir = self.data_dir.clone();
let data_dir = self.local_data_dir.clone();
let sink = sink.clone();
lsp.set_prepare_hook(Box::new(move |cfg, _, cancel| {
let effective = settings.effective();
@@ -232,8 +255,8 @@ impl Host {
let kotlin_bridge = crate::kotlin_bridge::KotlinBridge::new(
root.clone(),
self.data_dir.join("cache").join(&project_key).join("build"),
&self.data_dir,
self.local_data_dir.join("cache").join(&project_key).join("build"),
&self.local_data_dir,
settings.clone(),
lsp.clone(),
sink.clone(),
@@ -362,7 +385,7 @@ impl Host {
let (db, http_history, agents) = self.project_services(&root, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
@@ -381,9 +404,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
cache_dir: self.data_dir.join("cache").join(&project_key),
cache_dir: self.local_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"),
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
@@ -486,7 +509,7 @@ impl Host {
let project_key = project_key(&dir);
let (db, http_history, agents) = self.project_services(&dir, &project_key);
let notebooks = crate::notebook::NotebookService::new(
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let project = Arc::new(Project {
@@ -503,9 +526,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
cache_dir: self.data_dir.join("cache").join(&project_key),
cache_dir: self.local_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"),
self.local_data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
+39
View File
@@ -0,0 +1,39 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let data = base.join("roaming");
let local = base.join("local");
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("notes.txt"), "hello\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: data.clone(),
local_data_dir: local.clone(),
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,
});
assert_eq!(
std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()),
local.join("tools/jdks")
);
let light = host.open_light("main", &root.join("notes.txt")).unwrap();
assert!(light.cache_dir.starts_with(&local));
assert!(data.join("history").exists());
assert!(!local.join("history").exists());
let project = host.open_project("main", &root).unwrap();
assert!(project.cache_dir.starts_with(&local));
assert!(local.join("index").exists());
assert!(!data.join("index").exists());
assert!(!data.join("tools").exists());
host.shutdown().await;
}
+1
View File
@@ -9,6 +9,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -20,6 +20,7 @@ fn host(base: &Path) -> Host {
Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -10,6 +10,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -79,6 +79,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| sink_events.clone() as SharedSink),
+3
View File
@@ -56,6 +56,7 @@ async fn serves_projects_files_documents_search_and_runs() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
@@ -261,6 +262,7 @@ async fn local_deployment_runs_on_the_project_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_slot.clone(), Some(w.to_owned()))),
@@ -328,6 +330,7 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
+1
View File
@@ -6,6 +6,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -24,6 +24,7 @@ fn terminal_activation_uses_the_resolved_default_shell() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_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)),
+1
View File
@@ -11,6 +11,7 @@ fn host(base: &Path, environment: bool) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
@@ -12,6 +12,7 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -28,6 +28,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -31,6 +31,7 @@ async fn patches_read_and_write_the_requested_repository() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -22,6 +22,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
ide-core.workspace = true
ide-vfs.workspace = true
dunce.workspace = true
ide-text.workspace = true
ide-language.workspace = true
+3
View File
@@ -17,6 +17,9 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const SERVER: &str = "jdtls";
mod formatter;
pub use formatter::configure_formatter;
/// First start imports the Maven / Gradle model, which can take minutes.
const READY_TIMEOUT: Duration = Duration::from_secs(240);
/// A workspace build of a large multi-module project can take far longer than
+224
View File
@@ -0,0 +1,224 @@
use ide_core::IdeResult;
use ide_core::editor_settings::LanguageCodeStyle;
use ide_core::settings::LanguageServerSettings;
use ide_language::LanguageServerConfig;
use serde_json::json;
use std::path::Path;
const PROFILE: &str = "OpenIDEA IntelliJ IDEA";
const XML: &str = include_str!("intellij.xml");
/// Protocol settings only: switching profiles reaches a running jdtls through
/// didChangeConfiguration. Explicit language-server settings are applied later.
pub fn configure_formatter(
cfg: &mut LanguageServerConfig,
style: Option<&LanguageCodeStyle>,
project_root: &Path,
data_dir: &Path,
overrides: Option<&LanguageServerSettings>,
) -> IdeResult<()> {
if cfg.name != "jdtls" {
return Ok(());
}
// jdtls retains omitted preferences. Send empty values when returning to
// Default, while preserving any profile from the base server configuration.
let inherited = crate::handler::all_settings(cfg);
let defaults = json!({
"url": inherited.pointer("/java/format/settings/url").cloned().unwrap_or(json!("")),
"profile": inherited.pointer("/java/format/settings/profile").cloned().unwrap_or(json!("")),
});
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(defaults, overrides);
cfg.settings = Some(settings);
let Some(style) = style else { return Ok(()) };
let (path, profile) = match style.formatter.as_deref() {
Some("intellij") => {
let path = data_dir.join("lsp/jdtls/intellij.xml");
std::fs::create_dir_all(path.parent().unwrap())?;
if std::fs::read_to_string(&path).ok().as_deref() != Some(XML) {
ide_vfs::write_atomic(&path, XML.as_bytes())?;
}
(path, PROFILE)
}
Some("custom") => {
let value = style.formatter_path.as_deref().unwrap_or_default().trim();
if value.is_empty() {
return Err(ide_core::IdeError::invalid("Choose an Eclipse formatter XML file."));
}
let path = project_root.join(value);
// Fail without poisoning the server launch; code intelligence remains available.
std::fs::File::open(&path).map_err(|error| {
let path = path.display();
ide_core::IdeError::invalid(format!("Cannot read Java formatter XML {path}: {error}"))
})?;
if !path.is_file() {
return Err(ide_core::IdeError::invalid(format!("Not a formatter XML file: {}", path.display())));
}
(path, "")
}
_ => return Ok(()),
};
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
settings["java"]["format"]["settings"] = formatter_overrides(
json!({
"url": crate::uri::path_to_uri_string(&path),
"profile": profile,
}),
overrides,
);
cfg.settings = Some(settings);
Ok(())
}
fn formatter_overrides(mut format: serde_json::Value, overrides: Option<&LanguageServerSettings>) -> serde_json::Value {
if let Some(overrides) = overrides {
// all_settings merges protocol settings over initializationOptions.
// Mirror explicit formatter keys so generated protocol values cannot
// accidentally mask an advanced initializationOptions override.
for layer in [
overrides.initialization_options.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
overrides.settings.as_ref().and_then(|v| v.pointer("/settings/java/format/settings")),
overrides.settings.as_ref().and_then(|v| v.pointer("/java/format/settings")),
]
.into_iter()
.flatten()
{
for key in ["url", "profile"] {
if let Some(value) = layer.get(key) {
format[key] = value.clone();
}
}
}
}
format
}
#[cfg(test)]
mod tests {
use super::*;
fn config(name: &str) -> LanguageServerConfig {
serde_json::from_value(json!({"name": name, "command": name})).unwrap()
}
#[test]
fn profiles_resolve_paths_preserve_settings_and_leave_other_servers_alone() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project with 空格");
let data = dir.path().join("data");
std::fs::create_dir_all(&root).unwrap();
let custom = root.join("style.xml");
std::fs::write(&custom, XML).unwrap();
for name in ["jdtls", "rust-analyzer"] {
for mode in ["default", "intellij", "custom", "unknown"] {
let mut cfg = LanguageServerConfig {
name: name.into(),
settings: Some(json!({"java": {"autobuild": {"enabled": false}}})),
..config(name)
};
let initial = cfg.clone();
let style = LanguageCodeStyle {
formatter: Some(mode.into()),
formatter_path: Some("style.xml".into()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &root, &data, None).unwrap();
if name != "jdtls" {
assert_eq!(cfg, initial);
continue;
}
let settings = crate::handler::all_settings(&cfg);
if !["intellij", "custom"].contains(&mode) {
assert_eq!(settings["java"]["format"]["settings"], json!({"url": "", "profile": ""}));
continue;
}
let path = if mode == "custom" { custom.clone() } else { data.join("lsp/jdtls/intellij.xml") };
assert_eq!(settings["java"]["format"]["settings"]["url"], crate::uri::path_to_uri_string(&path));
assert_eq!(
settings["java"]["format"]["settings"]["profile"],
if mode == "custom" { "" } else { PROFILE }
);
assert_eq!(settings["java"]["autobuild"]["enabled"], false);
assert_eq!(std::fs::read_to_string(path).unwrap(), XML);
}
}
}
#[test]
fn unavailable_custom_paths_are_nonfatal_and_do_not_set_launch_error() {
let dir = tempfile::tempdir().unwrap();
for path in ["", "missing.xml", "."] {
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some(path.into()),
..Default::default()
};
assert!(configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), None).is_err());
assert!(cfg.launch_error.is_none());
assert_eq!(crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"], "");
}
}
#[test]
fn default_keeps_explicit_server_configuration_and_custom_accepts_absolute_paths() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.xml");
std::fs::write(&path, XML).unwrap();
let mut cfg = config("jdtls");
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"format": {"settings": {"url": "existing.xml", "profile": "Existing"}}}}
}))
.unwrap(),
);
configure_formatter(&mut cfg, None, dir.path(), dir.path(), None).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
json!({"url": "existing.xml", "profile": "Existing"})
);
let style = LanguageCodeStyle {
formatter: Some("custom".into()),
formatter_path: Some(path.to_string_lossy().into_owned()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), &dir.path().join("different-root"), dir.path(), None).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"]["url"],
crate::uri::path_to_uri_string(&path)
);
}
#[test]
fn advanced_formatter_options_win_for_each_profile_and_protocol_layer() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("custom.xml"), XML).unwrap();
for mode in ["default", "intellij", "custom"] {
for layer in ["initialization", "wrapped", "direct"] {
let mut overrides = LanguageServerSettings {
initialization_options: Some(json!({"settings": {"java": {"format": {"settings": {
"url": "advanced.xml", "profile": "Advanced"
}}}}})),
..Default::default()
};
if layer != "initialization" {
let value = json!({"java": {"format": {"settings": {"profile": "Protocol"}}}});
overrides.settings = Some(if layer == "wrapped" { json!({"settings": value}) } else { value });
}
let mut cfg = config("jdtls");
let style = LanguageCodeStyle {
formatter: Some(mode.into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
};
configure_formatter(&mut cfg, Some(&style), dir.path(), dir.path(), Some(&overrides)).unwrap();
assert_eq!(
crate::handler::all_settings(&cfg)["java"]["format"]["settings"],
json!({
"url": "advanced.xml",
"profile": if layer == "initialization" { "Advanced" } else { "Protocol" },
})
);
}
}
}
}
+235
View File
@@ -0,0 +1,235 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
IntelliJ IDEA Java defaults, approximated using Eclipse JDT formatter options.
Source revision: JetBrains/intellij-community 8fe3709d185cd13053d17b76d9690aef674d4268
platform/code-style-api/src/com/intellij/psi/codeStyle/CommonCodeStyleSettings.java
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleSettings.java
platform/code-style-api/src/com/intellij/psi/codeStyle/CodeStyleDefaults.java
java/java-frontback-impl/src/com/intellij/psi/codeStyle/JavaCodeStyleSettings.java
JavaLanguageCodeStyleSettingsProvider.customizeDefaults does not override these values.
https://www.jetbrains.com/help/idea/code-style-java.html describes the corresponding controls.
Each option below names its IDEA setting; JDT has no exact equivalent for every rule.
A formatter profile does not sort/group imports (Organize Imports is a separate action).
Preserve IDEA KEEP_LINE_BREAKS=true using Wrap where necessary (WRAP_COMPACT) and
join_wrapped_lines=false (Never join already wrapped lines). JDT NO_SPLIT joins some
manual parameter/call/chain breaks even when joining is disabled. Preserving manual
breaks takes priority: wrappable code exceeding 120 columns will also wrap, a known
difference from IDEA defaults (DO_NOT_WRAP / WRAP_LONG_LINES=false).
Alignment values: 16 = WRAP_COMPACT + INDENT_DEFAULT; 18 = WRAP_COMPACT + INDENT_ON_COLUMN.
The comment width 999999 approximates WRAP_COMMENTS=false without JDT integer overflow.
JDT has a shared blank-line preservation limit and different empty-body/Javadoc handling.
-->
<profiles version="23">
<profile kind="CodeFormatterProfile" name="OpenIDEA IntelliJ IDEA" version="23">
<!-- IndentOptions.USE_TAB_CHARACTER = false -->
<setting id="org.eclipse.jdt.core.formatter.tabulation.char" value="space"/>
<!-- IndentOptions.TAB_SIZE = 4 -->
<setting id="org.eclipse.jdt.core.formatter.tabulation.size" value="4"/>
<!-- IndentOptions.INDENT_SIZE = 4 -->
<setting id="org.eclipse.jdt.core.formatter.indentation.size" value="4"/>
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 (two 4-space units) -->
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation" value="2"/>
<!-- IndentOptions.CONTINUATION_INDENT_SIZE = 8 -->
<setting id="org.eclipse.jdt.core.formatter.continuation_indentation_for_array_initializer" value="2"/>
<!-- CodeStyleSettings.RIGHT_MARGIN = 120; wrap where necessary to preserve manual line breaks (see known difference above) -->
<setting id="org.eclipse.jdt.core.formatter.lineSplit" value="120"/>
<!-- KEEP_LINE_BREAKS = true; Never join already wrapped lines -->
<setting id="org.eclipse.jdt.core.formatter.join_wrapped_lines" value="false"/>
<!-- KEEP_BLANK_LINES_IN_DECLARATIONS / IN_CODE / BEFORE_RBRACE = 2 (JDT has one shared limit) -->
<setting id="org.eclipse.jdt.core.formatter.number_of_empty_lines_to_preserve" value="2"/>
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_method_declaration" value="18"/>
<!-- METHOD_PARAMETERS_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS = true (indent on column); JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_parameters_in_constructor_declaration" value="18"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_method_invocation" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_allocation_expression" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_explicit_constructor_call" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_enum_constant" value="16"/>
<!-- CALL_PARAMETERS_WRAP / ANNOTATION_PARAMETER_WRAP = DO_NOT_WRAP; ALIGN_MULTILINE_PARAMETERS_IN_CALLS = false; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation" value="16"/>
<!-- METHOD_CALL_CHAIN_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_selector_in_method_invocation" value="16"/>
<!-- ARRAY_INITIALIZER_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_expressions_in_array_initializer" value="16"/>
<!-- ASSIGNMENT_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_assignment" value="16"/>
<!-- TERNARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_conditional_expression" value="16"/>
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superclass_in_type_declaration" value="16"/>
<!-- EXTENDS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_superinterfaces_in_type_declaration" value="16"/>
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_method_declaration" value="16"/>
<!-- THROWS_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_throws_clause_in_constructor_declaration" value="16"/>
<!-- RESOURCE_LIST_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_resources_in_try" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_multiplicative_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_additive_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_string_concatenation" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_shift_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_relational_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_bitwise_operator" value="16"/>
<!-- BINARY_OPERATION_WRAP = DO_NOT_WRAP; JDT wrap where necessary preserves manual breaks -->
<setting id="org.eclipse.jdt.core.formatter.alignment_for_logical_operator" value="16"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_method_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_constructor_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_block_in_case" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_switch" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_anonymous_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_enum_constant" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_annotation_type_declaration" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_lambda_body" value="end_of_line"/>
<!-- CLASS_BRACE_STYLE / METHOD_BRACE_STYLE / LAMBDA_BRACE_STYLE / BRACE_STYLE = END_OF_LINE; ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.brace_position_for_array_initializer" value="end_of_line"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_else_in_if_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_catch_in_try_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_finally_in_try_statement" value="do not insert"/>
<!-- ELSE_ON_NEW_LINE / CATCH_ON_NEW_LINE / FINALLY_ON_NEW_LINE / WHILE_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_while_in_do_statement" value="do not insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_type" value="insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_method" value="insert"/>
<!-- CLASS / METHOD / FIELD_ANNOTATION_WRAP = WRAP_ALWAYS; DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_field" value="insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_parameter" value="do not insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_local_variable" value="do not insert"/>
<!-- PARAMETER / VARIABLE / ENUM_FIELD_ANNOTATION_WRAP = DO_NOT_WRAP -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_annotation_on_enum_constant" value="do not insert"/>
<!-- BLANK_LINES_BEFORE_PACKAGE = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_package" value="0"/>
<!-- BLANK_LINES_AFTER_PACKAGE = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_package" value="1"/>
<!-- BLANK_LINES_BEFORE_IMPORTS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_imports" value="1"/>
<!-- BLANK_LINES_AFTER_IMPORTS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_imports" value="1"/>
<!-- BLANK_LINES_AROUND_FIELD / BLANK_LINES_AROUND_FIELD_IN_INTERFACE = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_field" value="0"/>
<!-- BLANK_LINES_AROUND_METHOD = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_method" value="1"/>
<!-- BLANK_LINES_AROUND_METHOD_IN_INTERFACE = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_abstract_method" value="1"/>
<!-- BLANK_LINES_AROUND_CLASS = 1 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_member_type" value="1"/>
<!-- BLANK_LINES_AFTER_CLASS_HEADER = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_first_class_body_declaration" value="0"/>
<!-- BLANK_LINES_BEFORE_CLASS_END = 0 (minimum) -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_after_last_class_body_declaration" value="0"/>
<!-- BLANK_LINES_AROUND_METHOD / AROUND_CLASS = 1; JDT chunks separate member kinds, not consecutive fields -->
<setting id="org.eclipse.jdt.core.formatter.blank_lines_before_new_chunk" value="1"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_type_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_declaration_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_enum_constant_header" value="true"/>
<!-- DO_NOT_INDENT_TOP_LEVEL_CLASS_MEMBERS = false -->
<setting id="org.eclipse.jdt.core.formatter.indent_body_declarations_compare_to_annotation_declaration_header" value="true"/>
<!-- INDENT_CASE_FROM_SWITCH = true -->
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_switch" value="true"/>
<!-- INDENT_BREAK_FROM_CASE = true -->
<setting id="org.eclipse.jdt.core.formatter.indent_switchstatements_compare_to_cases" value="true"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_code_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_method_body_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_lambda_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_type_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_anonymous_type_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_enum_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_annotation_declaration_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_if_then_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / LAMBDAS / CLASSES_IN_ONE_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_loop_body_block_on_one_line" value="one_line_never"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_type_declaration" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_method_body" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_block" value="insert"/>
<!-- KEEP_SIMPLE_BLOCKS / METHODS / CLASSES_IN_ONE_LINE = false; empty-block handling may differ in JDT -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_in_empty_anonymous_type_declaration" value="insert"/>
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE / RBRACE_ON_NEXT_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.keep_empty_array_initializer_on_one_line" value="true"/>
<!-- ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_after_opening_brace_in_array_initializer" value="do not insert"/>
<!-- ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE = false; KEEP_LINE_BREAKS = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_new_line_before_closing_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_opening_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_opening_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_closing_brace_in_array_initializer" value="do not insert"/>
<!-- SPACE_BEFORE_ARRAY_INITIALIZER_LBRACE / SPACE_WITHIN_ARRAY_INITIALIZER_BRACES / SPACE_WITHIN_EMPTY_ARRAY_INITIALIZER_BRACES = false -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_between_empty_braces_in_array_initializer" value="do not insert"/>
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_before_lambda_arrow" value="insert"/>
<!-- SPACE_AROUND_LAMBDA_ARROW = true -->
<setting id="org.eclipse.jdt.core.formatter.insert_space_after_lambda_arrow" value="insert"/>
<!-- WRAP_COMMENTS = false; KEEP_FIRST_COLUMN_COMMENT = true (JDT comment formatting disabled to preserve text) -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_line_comments" value="false"/>
<!-- WRAP_COMMENTS = false (JDT comment formatting disabled to preserve text) -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_block_comments" value="false"/>
<!-- JavaCodeStyleSettings.ENABLE_JAVADOC_FORMATTING = true -->
<setting id="org.eclipse.jdt.core.formatter.comment.format_javadoc_comments" value="true"/>
<!-- WRAP_COMMENTS = false; JDT has a separate comment width, so disable its width-based wrapping -->
<setting id="org.eclipse.jdt.core.formatter.comment.line_length" value="999999"/>
<!-- JavaCodeStyleSettings.JD_INDENT_ON_CONTINUATION = false -->
<setting id="org.eclipse.jdt.core.formatter.comment.indent_parameter_description" value="false"/>
<!-- JavaCodeStyleSettings.JD_PARAM_DESCRIPTION_ON_NEW_LINE = false -->
<setting id="org.eclipse.jdt.core.formatter.comment.insert_new_line_for_parameter" value="false"/>
<!-- CodeStyleSettings.FORMATTER_TAGS_ENABLED = true -->
<setting id="org.eclipse.jdt.core.formatter.use_on_off_tags" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_then_statement_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_else_statement_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_imple_if_on_one_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_for_body_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_while_body_on_same_line" value="true"/>
<!-- KEEP_CONTROL_STATEMENT_IN_ONE_LINE = true -->
<setting id="org.eclipse.jdt.core.formatter.keep_simple_do_while_body_on_same_line" value="true"/>
</profile>
</profiles>
@@ -0,0 +1,25 @@
package demo;
import java.util.List;
import java.util.function.Function;
@Deprecated @SuppressWarnings("unused") public class FormatterSample {
@Deprecated @SuppressWarnings("unused") private String first="first";
private String second="second";
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
@Deprecated @SuppressWarnings("unused") public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName, String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName){if(firstParameterWithAVeryLongName==null){return "none";}else{try{return List.of(firstParameterWithAVeryLongName,secondParameterWithAVeryLongName,thirdParameterWithAVeryLongName).stream().map(value->value.trim()).filter(value->!value.isEmpty()).findFirst().orElse("none");}catch(RuntimeException problem){throw problem;}finally{System.out.println("done");}}}
public void multiline(String firstParameter,
String secondParameter,
String thirdParameter){
combine(firstParameter,
secondParameter,
thirdParameter);
List.of(firstParameter, secondParameter)
.stream()
.map(value->value.trim())
.forEach(System.out::println);
}
public void retained(String firstParameter, // retain declaration break
String secondParameter){combine(firstParameter, // retain call break
secondParameter, "third");}
public void arrays(){int[] values=new int[]{1,2,3};int[] empty=new int[]{};Function<String,String> clean=value->{return value.trim();};Runnable run=()->{System.out.println(values.length);};}
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
}
@@ -0,0 +1,65 @@
package demo;
import java.util.List;
import java.util.function.Function;
@Deprecated
@SuppressWarnings("unused")
public class FormatterSample {
@Deprecated
@SuppressWarnings("unused")
private String first = "first";
private String second = "second";
/** A description which remains on a very long single line to check whether formatting introduces wrapping when the right margin is exceeded with ordinary words in a JavaDoc comment. */
@Deprecated
@SuppressWarnings("unused")
public String combine(@Deprecated @SuppressWarnings("unused") String firstParameterWithAVeryLongName,
String secondParameterWithAVeryLongName, String thirdParameterWithAVeryLongName) {
if (firstParameterWithAVeryLongName == null) {
return "none";
} else {
try {
return List
.of(firstParameterWithAVeryLongName, secondParameterWithAVeryLongName,
thirdParameterWithAVeryLongName)
.stream().map(value -> value.trim()).filter(value -> !value.isEmpty()).findFirst()
.orElse("none");
} catch (RuntimeException problem) {
throw problem;
} finally {
System.out.println("done");
}
}
}
public void multiline(String firstParameter,
String secondParameter,
String thirdParameter) {
combine(firstParameter,
secondParameter,
thirdParameter);
List.of(firstParameter, secondParameter)
.stream()
.map(value -> value.trim())
.forEach(System.out::println);
}
public void retained(String firstParameter, // retain declaration break
String secondParameter) {
combine(firstParameter, // retain call break
secondParameter, "third");
}
public void arrays() {
int[] values = new int[]{1, 2, 3};
int[] empty = new int[]{};
Function<String, String> clean = value -> {
return value.trim();
};
Runnable run = () -> {
System.out.println(values.length);
};
}
// A comment which intentionally exceeds the right margin and should remain unwrapped with the built-in profile even when it contains many ordinary words and spaces to split on.
}
+64
View File
@@ -1017,6 +1017,70 @@ async fn settings_changes_reach_servers_without_restarts() {
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_formatter_profiles_reach_initialization_and_live_configuration_without_restart() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
cfg.initialization_options = Some(
toml::Value::try_from(json!({
"settings": {"java": {"autobuild": {"enabled": false}}}
}))
.unwrap(),
);
});
let data = tempfile::tempdir().unwrap();
std::fs::write(h.root.join("custom.xml"), "<profiles/>").unwrap();
let style = Arc::new(Mutex::new(ide_core::editor_settings::LanguageCodeStyle {
formatter: Some("intellij".into()),
formatter_path: Some("custom.xml".into()),
..Default::default()
}));
h.mgr.set_config_hook(Box::new({
let style = style.clone();
let root = h.root.clone();
let data = data.path().to_path_buf();
move |cfg, _| ide_lsp::jdtls::configure_formatter(cfg, Some(&style.lock()), &root, &data, None).unwrap()
}));
h.open("Main.java", "public class Main {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let init = state.requested("initialize")[0].clone();
assert_eq!(
init["initializationOptions"]["settings"]["java"]["format"]["settings"]["profile"],
"OpenIDEA IntelliJ IDEA"
);
for mode in ["custom", "default", "intellij", "default"] {
style.lock().formatter = Some(mode.into());
h.mgr.reconfigure().await;
let expected = match mode {
"custom" => uri_of(&h.root.join("custom.xml")),
"intellij" => uri_of(&data.path().join("lsp/jdtls/intellij.xml")),
_ => String::new(),
};
eventually(|| {
state
.notified("workspace/didChangeConfiguration")
.last()
.is_some_and(|p| p["settings"]["java"]["format"]["settings"]["url"] == expected)
})
.await;
let result: Value = state
.peer()
.request(
"workspace/configuration",
json!({
"items": [{"section": "java.format.settings"}, {"section": "java.autobuild.enabled"}]
}),
None,
)
.await
.unwrap();
assert_eq!(result[0]["url"], expected);
assert_eq!(result[0]["profile"], if mode == "intellij" { "OpenIDEA IntelliJ IDEA" } else { "" });
assert_eq!(result[1], false);
assert_eq!(state.launches.load(Ordering::SeqCst), 1);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn java_source_paths_reach_initialization_live_configuration_and_builds() {
let h = harness_configured("java", "java", &[("jdtls", incremental_caps())], |cfg| {
+47
View File
@@ -62,6 +62,53 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig {
}
}
#[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();
+47 -9
View File
@@ -23,16 +23,26 @@ pub struct InvalidateCachesOptions {
const MARKER: &str = "invalidate-caches.json";
/// Data-directory folders removed for `options`.
pub fn cache_dirs(data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![data_dir.join("index")];
pub fn cache_dirs(data_dir: &Path, local_data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![local_data_dir.join("index")];
if options.local_history {
dirs.push(data_dir.join("history"));
}
if options.language_servers {
dirs.push(data_dir.join("lsp"));
dirs.push(local_data_dir.join("lsp"));
}
if options.build_caches {
dirs.push(data_dir.join("cache"));
dirs.push(local_data_dir.join("cache"));
}
// Also invalidate pre-migration caches so a later start cannot revive them.
if data_dir != local_data_dir {
dirs.push(data_dir.join("index"));
if options.language_servers {
dirs.push(data_dir.join("lsp"));
}
if options.build_caches {
dirs.push(data_dir.join("cache"));
}
}
dirs
}
@@ -57,11 +67,11 @@ pub fn cancel(data_dir: &Path) {
/// Carries out a scheduled invalidation (call at start-up, before projects
/// open). Returns the folders removed.
pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec<PathBuf> {
let Some(options) = pending(data_dir) else { return Vec::new() };
cancel(data_dir);
let mut removed = Vec::new();
for dir in cache_dirs(data_dir, &options) {
for dir in cache_dirs(data_dir, local_data_dir, &options) {
match fs::remove_dir_all(&dir) {
Ok(()) => removed.push(dir),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
@@ -75,6 +85,34 @@ pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
mod tests {
use super::*;
#[test]
fn split_roots_invalidate_local_caches_and_roaming_history() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path().join("roaming");
let local = tmp.path().join("local");
let options = InvalidateCachesOptions { local_history: true, language_servers: true, build_caches: true };
assert_eq!(
cache_dirs(&data, &local, &options),
vec![
local.join("index"),
data.join("history"),
local.join("lsp"),
local.join("cache"),
data.join("index"),
data.join("lsp"),
data.join("cache")
]
);
for path in cache_dirs(&data, &local, &options) {
fs::create_dir_all(path).unwrap();
}
fs::create_dir_all(local.join("tools")).unwrap();
schedule(&data, &options).unwrap();
assert_eq!(apply_pending(&data, &local).len(), 7);
assert!(local.join("tools").exists());
assert_eq!(pending(&data), None);
}
#[test]
fn scheduled_invalidation_runs_once() {
let tmp = tempfile::tempdir().unwrap();
@@ -82,16 +120,16 @@ mod tests {
for d in ["index/p1", "history/p1", "lsp/jdtls/x", "cache/p1/build", "tools/jdk"] {
fs::create_dir_all(data.join(d)).unwrap();
}
assert!(apply_pending(data).is_empty());
assert!(apply_pending(data, data).is_empty());
let opts = InvalidateCachesOptions { local_history: true, language_servers: false, build_caches: true };
schedule(data, &opts).unwrap();
assert_eq!(pending(data), Some(opts));
let removed = apply_pending(data);
let removed = apply_pending(data, data);
assert_eq!(removed, vec![data.join("index"), data.join("history"), data.join("cache")]);
assert!(data.join("lsp/jdtls/x").exists() && data.join("tools/jdk").exists());
assert_eq!(pending(data), None);
assert!(apply_pending(data).is_empty());
assert!(apply_pending(data, data).is_empty());
schedule(data, &InvalidateCachesOptions::default()).unwrap();
cancel(data);
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "openidea",
"private": true,
"description": "An open-source IDE inspired by IntelliJ IDEA",
"version": "0.1.32",
"version": "0.1.33",
"license": "Apache-2.0",
"type": "module",
"scripts": {
+1 -1
View File
@@ -22,7 +22,7 @@ install_dir = { windows = "%LOCALAPPDATA%/OpenIDEA", macos = "/Applications/Open
logs = { windows = "%LOCALAPPDATA%/ai.l0ng.openidea/logs/openidea*.log", macos = "~/Library/Logs/ai.l0ng.openidea/openidea*.log", linux = "~/.local/share/ai.l0ng.openidea/logs/openidea*.log" }
# The index and build caches. jdtls keeps its own workspace under lsp/jdtls,
# next to the jdtls install, so a cold Java run still reuses jdtls's build state.
cache_dirs = { windows = ["%APPDATA%/ai.l0ng.openidea/index", "%APPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
cache_dirs = { windows = ["%LOCALAPPDATA%/ai.l0ng.openidea/index", "%LOCALAPPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
[apps.openidea.markers]
project_opened = 'opened .* in window'
+1 -1
View File
@@ -53,7 +53,7 @@ pub fn run() {
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
if let Some(data) = dirs::data_dir() {
if let Some(data) = dirs::data_local_dir() {
ide_tools::extend_process_path(&data.join(&context.config().identifier));
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier)));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier)));
+8 -3
View File
@@ -182,7 +182,7 @@ fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option<std::pa
if let Some(p) = std::env::var_os("OPENIDEA_SERVER_BINARY").map(std::path::PathBuf::from).filter(|p| p.is_file()) {
return Some(p);
}
let bundled = state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
let bundled = state.local_data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server");
if bundled.is_file() {
return Some(bundled);
}
@@ -205,7 +205,7 @@ async fn download_server(
arch: &str,
progress: Arc<Progress>,
) -> IdeResult<std::path::PathBuf> {
let (data, version, os, arch) = (state.data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned());
let (data, version, os, arch) = (state.local_data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned());
tokio::task::spawn_blocking(move || {
ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| match e {
ide_tools::InstallEvent::Line { text } => tracing::info!("openidea-server download: {text}"),
@@ -259,7 +259,12 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: A
`cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \
(or set OPENIDEA_SERVER_BINARY)",
e.message,
state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server").display()
state
.local_data_dir
.join("server")
.join(format!("{os}-{arch}"))
.join("openidea-server")
.display()
))
});
}
+8 -1
View File
@@ -64,21 +64,28 @@ impl AppState {
}
let config_dir = app.path().app_config_dir()?;
let data_dir = app.path().app_data_dir()?;
let local_data_dir = app.path().app_local_data_dir()?;
std::fs::create_dir_all(&config_dir)?;
std::fs::create_dir_all(&data_dir)?;
if let Err(error) = std::fs::create_dir_all(&local_data_dir) {
tracing::warn!(?local_data_dir, %error, "cannot create local data directory");
}
tracing::info!(?data_dir, ?local_data_dir, "application data directories");
// Saved git HTTPS credentials (askpass answers them silently).
ide_ssh::git_creds::init(data_dir.join("git-credentials.json"));
// File › Invalidate Caches… requested at the last run.
let removed = ide_workspace::caches::apply_pending(&data_dir);
let removed = ide_workspace::caches::apply_pending(&data_dir, &local_data_dir);
if !removed.is_empty() {
tracing::info!("invalidated caches: {removed:?}");
}
ide_host::data_dirs::migrate(&data_dir, &local_data_dir);
let global_sink: SharedSink = Arc::new(TauriSink { app: app.clone(), window: None });
let languages = Arc::new(LanguageRegistry::load(Some(&config_dir.join("languages")))?);
let sink_app = app.clone();
let host = Arc::new(Host::new(ide_host::HostConfig {
config_dir: config_dir.clone(),
data_dir: data_dir.clone(),
local_data_dir,
languages: languages.clone(),
syntax,
sink_for: Box::new(move |window| {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenIDEA",
"version": "0.1.32",
"version": "0.1.33",
"identifier": "ai.l0ng.openidea",
"build": {
"beforeDevCommand": "pnpm dev",
+8
View File
@@ -3903,6 +3903,10 @@ export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
export type LanguageCodeStyle_Deserialize = {
/** Java formatter: `default` | `intellij` | `custom` (other languages ignore it). */
formatter?: string | null,
/** Eclipse formatter XML; relative paths start at the project root. */
formatterPath?: string | null,
useTab?: boolean | null,
tabSize?: number | null,
indent?: number | null,
@@ -3915,6 +3919,10 @@ export type LanguageCodeStyle_Deserialize = {
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
export type LanguageCodeStyle_Serialize = {
/** Java formatter: `default` | `intellij` | `custom` (other languages ignore it). */
formatter?: string | null,
/** Eclipse formatter XML; relative paths start at the project root. */
formatterPath?: string | null,
useTab?: boolean | null,
tabSize?: number | null,
indent?: number | null,
@@ -87,6 +87,9 @@ describe("code style scheme", () => {
it("copies every Code Style value to the other scheme", () => {
stage("editor.codeStyle", "global", "editor.codeStyle.hardWrapAt", 100);
stage("editor.codeStyle", "global", "editor.codeStyle.formatterTagsEnabled", true);
stage("editor.codeStyle", "global", "editor.codeStyle.languages", {
java: { formatter: "custom", formatterPath: "config/formatter.xml" },
});
copyScheme("editor.codeStyle", "global", "project");
const s = useSettingsDialog.getState();
expect(stagedValue(s, "project", "editor.codeStyle.hardWrapAt")).toBe(100);
@@ -0,0 +1,68 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { PageContext } from "../components/context";
import { DEFAULT_SETTINGS } from "../model/schema";
import { openSettings, stagedValue, useSettingsDialog } from "../model/staging";
import { codeStyleLanguagePages } from "../pages/editor/codeStyle";
const mocks = vi.hoisted(() => ({ open: vi.fn() }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
vi.mock("@/platform/ipc", async (original) => {
const actual = await original<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: {
...actual.commands,
languages: async () => [
{ id: "java", name: "Java", indent: { tabSize: 4, insertSpaces: true } },
{ id: "rust", name: "Rust" },
],
},
};
});
beforeEach(() => {
useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null });
openSettings("editor.codeStyle.java");
});
afterEach(cleanup);
async function page(language: string) {
const pages = await codeStyleLanguagePages();
const spec = pages.find((p) => p.id === `editor.codeStyle.${language}`)!;
const Page = spec.component!;
render(
<PageContext.Provider value={{ pageId: spec.id, scope: "global" }}>
<Page />
</PageContext.Provider>,
);
}
const value = () => stagedValue(useSettingsDialog.getState(), "global", "editor.codeStyle.languages");
describe("Java formatting profile", () => {
it("stages profile and relative XML paths, browses for XML, and resets language defaults", async () => {
await page("java");
fireEvent.click(screen.getByRole("button", { name: "Formatting profile" }));
fireEvent.click(screen.getByRole("option", { name: "Custom Eclipse formatter XML" }));
fireEvent.change(screen.getByLabelText("Eclipse formatter XML"), { target: { value: "config/style.xml" } });
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "config/style.xml" } });
mocks.open.mockResolvedValue("/project/custom.xml");
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Browse…" })));
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } });
expect(mocks.open).toHaveBeenCalledWith(
expect.objectContaining({ filters: [{ name: "XML", extensions: ["xml"] }] }),
);
fireEvent.click(screen.getByRole("button", { name: "Formatting profile" }));
fireEvent.click(screen.getByRole("option", { name: "IntelliJ IDEA" }));
expect(screen.queryByLabelText("Eclipse formatter XML")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Reset to Language Defaults" }));
expect(value()).toEqual({});
});
it("keeps the profile controls specific to Java", async () => {
await page("rust");
expect(screen.queryByRole("button", { name: "Formatting profile" })).toBeNull();
});
});
@@ -12,7 +12,7 @@ import { Button, Checkbox, Input, type SelectOption, T } from "@/ui";
import { commands, isTauri, type LanguageInfo_Serialize as LanguageInfo } from "@/platform/ipc";
import { ENCODING_CHOICES, lineCommentDefaults, type LanguageCodeStyle } from "@/platform/editorSettings";
import { NATIVE_TO_ASCII_ACTION } from "../../nativeToAscii";
import { NumberInput, Row } from "../../components/Form";
import { NumberInput, Row, Select } from "../../components/Form";
import { useSetting } from "../../components/context";
import type { PageSpec } from "../spec";
import { CODE_STYLE_SCOPE, CodeStyleMain, SchemeRow, formatterLabels } from "./codeStyleScheme";
@@ -159,6 +159,7 @@ function LanguageStyle({ lang }: { lang: LanguageInfo }) {
return (
<div className={styles.page}>
<SchemeRow />
{lang.id === "java" && <JavaFormatter style={own} onChange={set} />}
<section aria-label={tx("Tabs and Indents")}>
<div className={styles.previewTitle}>
<T>Tabs and Indents</T>
@@ -238,6 +239,62 @@ function LanguageStyle({ lang }: { lang: LanguageInfo }) {
);
}
const JAVA_FORMATTERS: SelectOption<NonNullable<LanguageCodeStyle["formatter"]>>[] = [
{ value: "default", label: "Default" },
{ value: "intellij", label: "IntelliJ IDEA" },
{ value: "custom", label: "Custom Eclipse formatter XML" },
];
function JavaFormatter({
style,
onChange,
}: {
style: LanguageCodeStyle;
onChange: (patch: Partial<LanguageCodeStyle>) => void;
}) {
const id = useId();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const path = await open({
multiple: false,
title: tx("Eclipse formatter XML"),
filters: [{ name: "XML", extensions: ["xml"] }],
});
if (typeof path === "string") onChange({ formatterPath: path });
};
return (
<section aria-label={tx("Formatting profile")}>
<Row
label="Formatting profile"
hint="Changes take effect after Apply without restarting the Java language server."
>
<Select
value={style.formatter ?? "default"}
options={JAVA_FORMATTERS}
onChange={(formatter) => onChange({ formatter })}
aria-label="Formatting profile"
/>
</Row>
{style.formatter === "custom" && (
<Row
label="Eclipse formatter XML"
htmlFor={id}
hint="Absolute or project-relative path. To reload an edited XML file, switch to Default, apply, then select Custom again."
>
<Input
id={id}
value={style.formatterPath ?? ""}
onChange={(e) => onChange({ formatterPath: e.target.value })}
/>
<Button size="small" onClick={browse}>
<T>Browse…</T>
</Button>
</Row>
)}
</section>
);
}
const SKIP = new Set(["plaintext", "log", "diff", "ignore", "gitattributes"]);
/** One Code Style page per language (registered once the language list is known). */
@@ -261,6 +318,9 @@ export async function codeStyleLanguagePages(): Promise<PageSpec[]> {
"Hard wrap at",
"Comment Code",
"Line comment at first column",
...(lang.id === "java"
? ["Formatting profile", "IntelliJ IDEA", "Custom Eclipse formatter XML", "Eclipse formatter XML"]
: []),
],
component: () => <LanguageStyle lang={lang} />,
}));
+3
View File
@@ -24,6 +24,9 @@ export interface CustomColorScheme {
export type CustomColorSchemes = Record<string, CustomColorScheme>;
export interface LanguageCodeStyle {
/** Java formatter; relative XML paths start at the project root. */
formatter?: "default" | "intellij" | "custom";
formatterPath?: string;
useTab?: boolean;
tabSize?: number;
indent?: number;
+10 -3
View File
@@ -1364,12 +1364,10 @@
"Open console for tool output": "为工具输出打开控制台",
"Open documents": "打开的文档",
"Open in Browser": "在浏览器中打开",
"Open in Editor": "在编辑器中打开",
"Open in Find Tool Window": "在“查找”工具窗口中打开",
"Open in Find Window": "在查找窗口中打开",
"Open in Opposite Group": "在相对组中打开",
"Open in New Window": "在新窗口中打开",
"Open in Tool Window": "在工具窗口中打开",
"Open in Terminal": "在终端中打开",
"Open in editor": "在编辑器中打开",
"Open moved files in editor": "在编辑器中打开已移动的文件",
@@ -5447,5 +5445,14 @@
"Chat in Tool Window": "在工具窗口中聊天",
"Chat in Editor": "在编辑器中聊天",
"Agent settings": "智能体设置",
"Describe a task…": "描述一个任务…"
"Describe a task…": "描述一个任务…",
"Formatting profile": "格式化方案",
"Custom Eclipse formatter XML": "自定义 Eclipse 格式化 XML",
"Eclipse formatter XML": "Eclipse 格式化 XML",
"Changes take effect after Apply without restarting the Java language server.": "应用后即生效,无需重启 Java 语言服务器。",
"Absolute or project-relative path. To reload an edited XML file, switch to Default, apply, then select Custom again.": "使用绝对路径或项目相对路径。修改 XML 文件后,请切换到默认方案并应用,再重新选择自定义方案。",
"Cannot read Java formatter XML {path}: {error}": "无法读取 Java 格式化 XML {path}:{error}",
"Java formatter configuration unavailable": "Java 格式化配置不可用",
"Choose an Eclipse formatter XML file.": "请选择 Eclipse 格式化 XML 文件。",
"Not a formatter XML file: {path}": "不是格式化 XML 文件:{path}"
}
+1
View File
@@ -81,6 +81,7 @@ const BUILT_AT_RUNTIME = new Set<string>([]);
const UNTRANSLATED = new Set<string>([
// Product names
"Prettier",
"IntelliJ IDEA",
// Go environment variables and placeholder values
"CGO",
"GOPROXY",