fix(plugins): hand plugins paths in one form on Windows (#1500)

* fix(plugins): hand plugins paths in one form on Windows

Plugins got the project root, editor path, provider documents, code action edits, vcs-status roots and find-files results as D:\… while event paths from the UI use /, so Checkstyle could not find the project of a saved file. The host now converts every path it hands to plugins with ipc_path::to_ipc.

* fix(checkstyle): match native Windows report paths

* test(plugins): use workbench IPC paths in host fixtures
This commit is contained in:
l0ng-ai
2026-11-05 17:03:09 +08:00
committed by GitHub
parent 7d267ab50e
commit f55cdcdc31
10 changed files with 116 additions and 26 deletions
+16 -2
View File
@@ -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();
+6 -5
View File
@@ -6,6 +6,7 @@
//! `plugins/build.sh <dir>` 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");
+24 -7
View File
@@ -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() }
}
+4 -2
View File
@@ -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<ProjectRes>, path: String) -> Fallible<String> {
@@ -346,7 +348,7 @@ impl editor::Host for StoreData {}
impl editor::HostTextEditor for StoreData {
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
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<EditorRes>) -> wasmtime::Result<String> {
+1 -1
View File
@@ -103,7 +103,7 @@ dependencies = [
[[package]]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"openidea-plugin",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
publish = false
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457
69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250
+1 -1
View File
@@ -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"
+62 -6
View File
@@ -110,6 +110,26 @@ fn project_of(path: &str) -> Option<Project> {
.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<Diagnostic> = 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"]);