diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 375ab182..a09e61a1 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -2,6 +2,7 @@ //! Capabilities were checked by the runtime before any of this runs. use super::{PluginEvent, PluginEventSink}; +use ide_core::ipc_path::to_ipc; use ide_core::{CancellationToken, TextEdit}; use ide_extension::Package; use ide_language::ProgramPin; @@ -419,7 +420,7 @@ impl HostApi for PluginApi { let info = repo.branch_info().map_err(failed)?; let changed = repo.status().map_err(failed)?.len(); Ok(Some(RepositoryStatus { - root: repo.root().to_string_lossy().into_owned(), + root: to_ipc(repo.root()), branch: info.head, commit: info.head_commit, upstream: info.upstream, @@ -467,7 +468,7 @@ impl HostApi for PluginApi { } let Ok(rel) = entry.path().strip_prefix(root) else { continue }; if matcher.is_match(rel) { - out.push(entry.path().to_string_lossy().into_owned()); + out.push(to_ipc(entry.path())); } } out.sort(); @@ -806,6 +807,19 @@ mod tests { PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared } } + #[test] + fn repository_and_search_paths_match_ipc_events() { + let tmp = tempfile::tempdir().unwrap(); + assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success()); + let root = tmp.path().canonicalize().unwrap(); + std::fs::create_dir(root.join("src")).unwrap(); + let file = root.join("src/Example.java"); + std::fs::write(&file, "class Example {}\n").unwrap(); + let api = api(&root, Arc::default()); + assert_eq!(api.find_files(&root, "**/*.java", 8).unwrap(), [to_ipc(&file)]); + assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root)); + } + #[test] fn programs_run_only_as_approved() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 575584f0..ee5db393 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -6,6 +6,7 @@ //! `plugins/build.sh ` after changing their source or `wit/`. use ide_core::error::ErrorKind; +use ide_core::ipc_path::to_ipc; use ide_host::plugins::*; use ide_plugin::{EditorContext, HostError, Invocation, Limits}; use parking_lot::Mutex; @@ -214,7 +215,7 @@ fn fixture(limits: Limits) -> Fixture { std::fs::create_dir_all(project.join("src")).unwrap(); std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap(); std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap(); - let project = project.canonicalize().unwrap(); + let project = dunce::canonicalize(project).unwrap(); let events: Events = Arc::default(); let sink = { let events = events.clone(); @@ -359,7 +360,7 @@ fn read_file_is_confined_to_the_project() { let inv = Invocation { client: None, workspace_root: None, editor: None }; let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message; assert!(err.contains("no project is open"), "{err}"); - assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy()); + assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project)); assert_eq!(f.status("misbehave"), PluginStatus::Active); } @@ -1740,7 +1741,7 @@ fn official_checkstyle_checks_on_save() { let java = f.project.join("src/A.java"); std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap(); let saved = |f: &Fixture| { - f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]); + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]); f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); }; saved(&f); @@ -1793,7 +1794,7 @@ fn official_license_header_reports_fixes_and_scans() { f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned() }; - f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]); + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]); f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); assert!(f.host.wait_events(Duration::from_secs(20))); let d = diagnostics(&a).expect("published on open"); @@ -1818,7 +1819,7 @@ fn official_license_header_reports_fixes_and_scans() { f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap(); assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan"); std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap(); - f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]); + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]); f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); assert!(f.host.wait_events(Duration::from_secs(20))); assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there"); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 19d97abb..e7fe2555 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -13,6 +13,7 @@ use crate::types::{ }; use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings}; use ide_core::CancellationToken; +use ide_core::ipc_path::to_ipc; use ide_extension::Capability; use parking_lot::Mutex; use std::path::Path; @@ -935,12 +936,7 @@ fn location_from_wit(l: wit_types::Location) -> Location { } fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document { - wit_prov::Document { - path: d.path.to_string_lossy().into_owned(), - language: d.language.clone(), - version: d.version, - text: d.text.clone(), - } + wit_prov::Document { path: to_ipc(&d.path), language: d.language.clone(), version: d.version, text: d.text.clone() } } /// Resets the budgets for the next call: fuel, deadline, cancellation. @@ -1012,7 +1008,7 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction { .edits .iter() .map(|(path, edits)| wit_prov::FileEdit { - path: path.to_string_lossy().into_owned(), + path: to_ipc(path), edits: edits .iter() .map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() }) @@ -1131,6 +1127,27 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn provider_and_resolved_action_paths_match_ipc_events() { + for (path, expected) in [ + (r"C:\project\Example.java", "C:/project/Example.java"), + (r"\\?\C:\project\Example.java", "C:/project/Example.java"), + (r"\\server\share\Example.java", "//server/share/Example.java"), + ] { + let doc = ProviderDocument { path: path.into(), ..Default::default() }; + assert_eq!(doc_to_wit(&doc).path, expected); + let action = CodeAction { + title: "Fix".into(), + kind: None, + preferred: false, + edits: vec![(path.into(), Vec::new())], + data: None, + }; + assert_eq!(code_action_to_wit(&action).edits[0].path, expected); + } + } + fn ext(kind: &str) -> wit_types::Extension { wit_types::Extension { kind: kind.into(), data: "{}".into() } } diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index b274a088..a1f7b753 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -5,6 +5,7 @@ use crate::runtime::{StoreData, clip}; use crate::sandbox; use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host}; +use ide_core::ipc_path::to_ipc; use ide_extension::Capability; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -310,7 +311,8 @@ impl workspace::HostProject for StoreData { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); } - Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned())) + // Paths reach plugins in IPC form (`/` on Windows), as the UI's event paths do, so they compare as text. + Ok(Ok(to_ipc(&self.table.get(&this)?.root))) } fn read_file(&mut self, this: Resource, path: String) -> Fallible { @@ -346,7 +348,7 @@ impl editor::Host for StoreData {} impl editor::HostTextEditor for StoreData { fn path(&mut self, this: Resource) -> wasmtime::Result { - Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned()) + Ok(to_ipc(&self.table.get(&this)?.ctx.path)) } fn language(&mut self, this: Resource) -> wasmtime::Result { diff --git a/plugins/official/checkstyle/Cargo.lock b/plugins/official/checkstyle/Cargo.lock index 5c3d4f38..bc79d791 100644 --- a/plugins/official/checkstyle/Cargo.lock +++ b/plugins/official/checkstyle/Cargo.lock @@ -103,7 +103,7 @@ dependencies = [ [[package]] name = "openidea-plugin-openidea-checkstyle" -version = "0.1.0" +version = "0.1.1" dependencies = [ "openidea-plugin", ] diff --git a/plugins/official/checkstyle/Cargo.toml b/plugins/official/checkstyle/Cargo.toml index 2a04ad51..0ffe0337 100644 --- a/plugins/official/checkstyle/Cargo.toml +++ b/plugins/official/checkstyle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openidea-plugin-openidea-checkstyle" -version = "0.1.0" +version = "0.1.1" edition = "2024" publish = false diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index e6c41ba8..0c5b399d 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 9d7525f7..d07b95aa 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457 +69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250 diff --git a/plugins/official/checkstyle/plugin.toml b/plugins/official/checkstyle/plugin.toml index 1732ad30..9c83c2b1 100644 --- a/plugins/official/checkstyle/plugin.toml +++ b/plugins/official/checkstyle/plugin.toml @@ -1,6 +1,6 @@ id = "openidea.checkstyle" name = "Checkstyle" -version = "0.1.0" +version = "0.1.1" description = "Checks Java files with Checkstyle when you save them: problems in the editor and the Problems view, quick fixes for tabs, trailing spaces and missing final newlines." authors = ["OpenIDEA contributors"] license = "Apache-2.0" diff --git a/plugins/official/checkstyle/src/lib.rs b/plugins/official/checkstyle/src/lib.rs index aebf998e..6159e7ce 100644 --- a/plugins/official/checkstyle/src/lib.rs +++ b/plugins/official/checkstyle/src/lib.rs @@ -110,6 +110,26 @@ fn project_of(path: &str) -> Option { .max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0)) } +fn windows_path(path: &str) -> String { + let path = path.replace('\\', "/"); + if let Some(rest) = path.strip_prefix("//?/UNC/") { + format!("//{rest}") + } else { + path.strip_prefix("//?/").unwrap_or(&path).to_owned() + } +} + +fn report_matches_file(reported: &str, path: &str) -> bool { + // WASI plugins run on every host, so interpret the host's path rather than cfg(windows). + let drive = path.as_bytes().get(1) == Some(&b':') && path.as_bytes()[0].is_ascii_alphabetic(); + if drive || path.starts_with("//") || path.starts_with(r"\\") { + let (reported, path) = (windows_path(reported), windows_path(path)); + reported == path || path.ends_with(&reported) + } else { + reported == path || path.ends_with(reported) + } +} + /// The configuration for files of `project`. fn config_for(project: &Project) -> String { PROJECT_CONFIGS @@ -138,11 +158,8 @@ impl Checkstyle { return Err(Error::Failed(format!("checkstyle: {}", out.stderr.lines().next().unwrap_or("no report")))); } let report = parse_report(&out.stdout); - let problems = report - .into_iter() - .find(|(f, _)| f == path || path.ends_with(f.as_str())) - .map(|(_, p)| p) - .unwrap_or_default(); + let problems = + report.into_iter().find(|(f, _)| report_matches_file(f, path)).map(|(_, p)| p).unwrap_or_default(); let text = project.read_file(path).unwrap_or_default(); let diagnostics: Vec = problems.iter().map(|p| diagnostic(p, &text)).collect(); host::publish_diagnostics(path, &diagnostics)?; @@ -233,7 +250,7 @@ impl Plugin for Checkstyle { register_plugin!(Checkstyle, providers); -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; use openidea_plugin::ProcessOutput; @@ -288,6 +305,45 @@ mod tests { assert_eq!(actions[1].edits[0].1[0].text, " void f() {}"); } + #[test] + fn unix_report_paths_preserve_backslashes() { + assert!(report_matches_file("/project/A\\B.java", "/project/A\\B.java")); + assert!(!report_matches_file("/project/A\\B.java", "/project/A/B.java")); + } + + #[test] + fn windows_reports_match_the_saved_file_and_keep_quick_fixes() { + for (root, reported) in [ + ("C:/project", r"C:\project\src\A.java"), + ("C:/project", r"\\?\C:\project\src\A.java"), + ("C:/project", r"src\A.java"), + ("//server/share/project", r"\\server\share\project\src\A.java"), + ("//server/share/project", r"\\?\UNC\server\share\project\src\A.java"), + ] { + let host = + MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]); + host.set_project_root(Some(root)); + host.add_file("src/A.java", "class A {\n\tint x;\n void f() {} \n}"); + let xml = REPORT.replace("/project/src/A.java", reported); + host.on_process("checkstyle", move |_, _| { + Ok(ProcessOutput { status: Some(1), stdout: xml.clone(), stderr: String::new() }) + }); + let path = format!("{root}/src/A.java"); + let mut plugin = Checkstyle::default(); + plugin.handle_events(&[Event::FileSaved(path.clone())], &host.invocation()); + assert_eq!(host.diagnostics()[&path].len(), 2, "{reported}"); + let doc = Document { + path: path.clone(), + language: "java".into(), + version: 1, + text: "class A {\n\tint x;\n void f() {} \n}".into(), + }; + let actions = plugin.code_actions(&doc, Range::new(Position::new(1, 0), Position::new(2, 0))); + assert_eq!(actions.len(), 2, "{reported}"); + assert_eq!(actions[0].edits[0].0, path); + } + } + #[test] fn the_projects_configuration_wins() { let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);