Files
1c5a92f952 feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a
typed WIT API (`openidea:plugin@1.0.0`) and a Rust SDK, shipped as a preview.

- Extension points declared in `plugin.toml` (commands, languages, themes,
  icon themes, menus, key bindings, status items, settings, views, custom
  editors, file icons and templates, run detectors, editor providers),
  validated before anything takes effect and projected onto the workbench's
  own registries: plugin themes and file icon themes are chosen in
  Settings › Appearance and Quick Switch, plugin shortcuts are edited in
  Settings › Keymap, plugin problems are grouped in Settings › Inspections.
- Capabilities with risk levels and consent at install, pinned `process:`
  programs, secrets in the OS keychain.
- Runtime: one thread per active plugin, fuel/time/memory budgets, soft and
  hard deadlines on the editing path, cancellation, crash backoff, idle
  reclaim, a precompiled-component cache; remote projects run code plugins on
  `openidea-server`.
- Web views and custom editors with a sandboxed page, a document bridge
  joined to the text editor's undo history, and split or preview layouts.
- Distribution: signed `.oiplugin` packages, a static marketplace index with
  revocation, versioned installs with rollback, install from a `.zip` or a
  folder. The release workflow signs and publishes the official plugins and
  their index, which released builds use as the default marketplace.
- Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion,
  Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor
  Night, Excalidraw, Graphviz, Regex Tester, License Header.

Closes #732

Co-authored-by: Deng Liu <5108773+dengliu@users.noreply.github.com>
Co-authored-by: florentvdry <16673235+florentvdry@users.noreply.github.com>
2026-11-04 02:54:31 +08:00

318 lines
14 KiB
Rust

//! Checkstyle for OpenIDEA: runs the `checkstyle` command line on a Java file
//! when it is saved (as a cancellable task; a newer save cancels the running
//! check), publishes the problems as diagnostics, and offers quick fixes for
//! the ones it can fix textually.
//!
//! The configuration is the project's own when it has one (`checkstyle.xml`,
//! `config/checkstyle/checkstyle.xml` as Gradle and Maven projects keep it,
//! or `.checkstyle.xml`), else the Google or Sun checks chosen with the
//! commands.
use openidea_plugin::host::{self, storage};
use openidea_plugin::providers::{CodeAction, Document, Providers};
use openidea_plugin::{
Diagnostic, Error, Event, Invocation, NotificationLevel, Plugin, Position, Project, Range, Severity, TextEdit,
register_plugin,
};
use std::collections::BTreeMap;
/// Built into the Checkstyle jar.
const GOOGLE: &str = "/google_checks.xml";
const SUN: &str = "/sun_checks.xml";
/// Project configurations, by preference (project-relative; the check runs in the project root).
const PROJECT_CONFIGS: [&str; 3] = ["checkstyle.xml", "config/checkstyle/checkstyle.xml", ".checkstyle.xml"];
/// One reported problem.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Problem {
/// 0-based.
pub line: u32,
/// 0-based; `None` for the whole line.
pub column: Option<u32>,
pub severity: Severity,
pub message: String,
/// The check's simple name (`FileTabCharacter`).
pub check: String,
}
#[derive(Default)]
pub struct Checkstyle {
/// Last results per file (for quick fixes; rebuilt by the next save).
problems: BTreeMap<String, Vec<Problem>>,
}
fn unescape(s: &str) -> String {
s.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&#10;", "\n")
.replace("&amp;", "&")
}
fn attr(tag: &str, name: &str) -> Option<String> {
let start = tag.find(&format!(" {name}=\""))? + name.len() + 3;
let end = tag[start..].find('"')? + start;
Some(unescape(&tag[start..end]))
}
/// Problems per file from Checkstyle's `-f xml` report.
pub fn parse_report(xml: &str) -> BTreeMap<String, Vec<Problem>> {
let mut out: BTreeMap<String, Vec<Problem>> = BTreeMap::new();
let mut file = None;
for tag in xml.split('<').skip(1) {
let tag = format!(" {}", tag.split('>').next().unwrap_or(""));
if tag.starts_with(" file ") {
file = attr(&tag, "name");
if let Some(f) = &file {
out.entry(f.clone()).or_default();
}
} else if tag.starts_with(" error ") {
let (Some(f), Some(line)) = (&file, attr(&tag, "line").and_then(|l| l.parse::<u32>().ok())) else {
continue;
};
let severity = match attr(&tag, "severity").as_deref() {
Some("error") => Severity::Error,
Some("info") => Severity::Info,
Some("ignore") => continue,
_ => Severity::Warning,
};
let check = attr(&tag, "source").unwrap_or_default();
// Checks configured with an `id` report it after a `#` (Google checks: `RegexpSinglelineCheck#noTrailingWhitespace`).
let check = check.split('#').next().unwrap_or_default();
let check = check.rsplit('.').next().unwrap_or(check).trim_end_matches("Check").to_owned();
out.entry(f.clone()).or_default().push(Problem {
line: line.saturating_sub(1),
column: attr(&tag, "column").and_then(|c| c.parse::<u32>().ok()).map(|c| c.saturating_sub(1)),
severity,
message: attr(&tag, "message").unwrap_or_default(),
check,
});
}
}
out
}
fn diagnostic(p: &Problem, text: &str) -> Diagnostic {
let line_len = text.split('\n').nth(p.line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
let start = p.column.unwrap_or(0).min(line_len);
let end = if p.column.is_some() && start < line_len { start + 1 } else { line_len };
Diagnostic {
range: Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start))),
severity: p.severity,
message: p.message.clone(),
code: Some(p.check.clone()),
}
}
/// The project containing `path`.
fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// The configuration for files of `project`.
fn config_for(project: &Project) -> String {
PROJECT_CONFIGS
.iter()
.find(|c| project.read_file(c).is_ok())
.map(|c| (*c).to_owned())
.or_else(|| storage::get("config").ok().flatten())
.unwrap_or_else(|| GOOGLE.into())
}
impl Checkstyle {
/// Checks `path` and publishes the result. `Err(Cancelled)` when a newer
/// save (or the user) cancelled it.
fn check(&mut self, path: &str) -> Result<usize, Error> {
let project = project_of(path).ok_or_else(|| Error::NotFound(format!("{path} is not in an open project")))?;
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
let task = host::begin_task(&format!("Checkstyle: {name}"), true);
let config = config_for(&project);
let args = vec!["-f".to_owned(), "xml".into(), "-c".into(), config, path.to_owned()];
let out = project.run_process("checkstyle", &args, None)?;
if task.is_cancelled() {
return Err(Error::Cancelled);
}
// Exit status is the number of errors; only missing output is a failure.
if !out.stdout.contains("<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 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)?;
let n = problems.len();
self.problems.insert(path.to_owned(), problems);
Ok(n)
}
}
impl Providers for Checkstyle {
fn code_actions(&mut self, doc: &Document, selection: Range) -> Vec<CodeAction> {
let Some(problems) = self.problems.get(&doc.path) else { return Vec::new() };
let lines: Vec<&str> = doc.text.split('\n').collect();
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for p in problems.iter().filter(|p| p.line >= selection.start.line && p.line <= selection.end.line) {
let Some(line) = lines.get(p.line as usize) else { continue };
let whole = Range::new(Position::new(p.line, 0), Position::new(p.line, line.encode_utf16().count() as u32));
let (title, edit) = match p.check.as_str() {
"FileTabCharacter" => {
("Replace tabs with spaces", TextEdit { range: whole, text: line.replace('\t', " ") })
}
"RegexpSingleline" if p.message.to_lowercase().contains("trailing") => {
("Remove trailing whitespace", TextEdit { range: whole, text: line.trim_end().to_owned() })
}
"NewlineAtEndOfFile" => {
let last = lines.len() as u32 - 1;
let end = Position::new(last, lines[last as usize].encode_utf16().count() as u32);
(
"Add a newline at the end of the file",
TextEdit { range: Range::new(end, end), text: "\n".into() },
)
}
_ => continue,
};
if seen.insert((title, p.line)) {
out.push(CodeAction {
title: format!("Checkstyle: {title}"),
kind: Some("quickfix".into()),
preferred: true,
edits: vec![(doc.path.clone(), vec![edit])],
});
}
}
out
}
}
impl Plugin for Checkstyle {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"check" => {
let editor = ctx.editor().ok_or("no file is open")?;
let n = self.check(&editor.path())?;
Ok(Some(format!("{n} problems")))
}
"use-google" | "use-sun" => {
storage::set("config", if id == "use-sun" { SUN } else { GOOGLE })?;
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
let mut saved: Vec<&str> = events
.iter()
.filter_map(|e| match e {
Event::FileSaved(p) if p.ends_with(".java") => Some(p.as_str()),
_ => None,
})
.collect();
saved.sort_unstable();
saved.dedup();
for path in saved {
match self.check(path) {
Ok(_) => {}
Err(Error::Cancelled) => return, // a newer save takes over
Err(e) => {
host::notify(NotificationLevel::Warning, "Checkstyle", &e.to_string());
return;
}
}
}
}
}
register_plugin!(Checkstyle, providers);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::ProcessOutput;
use openidea_plugin::mock::MockHost;
const REPORT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<checkstyle version="10.17.0">
<file name="/project/src/A.java">
<error line="2" column="1" severity="warning" message="Line contains a tab character." source="com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck"/>
<error line="3" severity="error" message="Trailing whitespace is not allowed" source="com.puppycrawl.tools.checkstyle.checks.regexp.RegexpSinglelineCheck#noTrailingWhitespace"/>
</file>
</checkstyle>"#;
#[test]
fn parses_reports() {
let r = parse_report(REPORT);
let p = &r["/project/src/A.java"];
assert_eq!(p.len(), 2);
assert_eq!((p[0].line, p[0].column, p[0].check.as_str()), (1, Some(0), "FileTabCharacter"));
assert_eq!(
(p[1].severity, p[1].message.as_str(), p[1].check.as_str()),
(Severity::Error, "Trailing whitespace is not allowed", "RegexpSingleline")
);
}
#[test]
fn checks_saved_java_files_and_fixes() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
let text = "class A {\n\tint x;\n void f() {} \n}";
host.add_file("src/A.java", text);
host.on_process("checkstyle", |args, _| {
assert_eq!(&args[..4], ["-f", "xml", "-c", GOOGLE]);
Ok(ProcessOutput { status: Some(1), stdout: REPORT.into(), stderr: String::new() })
});
let mut p = Checkstyle::default();
p.handle_events(
&[Event::FileSaved("/project/src/A.java".into()), Event::FileSaved("/project/notes.txt".into())],
&host.invocation(),
);
assert_eq!(host.process_runs().len(), 1, "only Java files");
assert_eq!(host.process_runs()[0].cwd.as_deref(), Some("/project"));
let d = &host.diagnostics()["/project/src/A.java"];
assert_eq!(d.len(), 2);
assert_eq!(d[1].range, Range::new(Position::new(2, 0), Position::new(2, 16)), "whole line without a column");
assert!(host.tasks()[0].ended);
let doc =
Document { path: "/project/src/A.java".into(), language: "java".into(), version: 1, text: text.into() };
let actions = p.code_actions(&doc, Range::new(Position::new(1, 0), Position::new(2, 0)));
let titles: Vec<_> = actions.iter().map(|a| a.title.as_str()).collect();
assert_eq!(titles, ["Checkstyle: Replace tabs with spaces", "Checkstyle: Remove trailing whitespace"]);
assert_eq!(actions[1].edits[0].1[0].text, " void f() {}");
}
#[test]
fn the_projects_configuration_wins() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
host.add_file("src/A.java", "class A {}");
host.on_process("checkstyle", |_, _| {
Ok(ProcessOutput { status: Some(0), stdout: "<checkstyle/>".into(), stderr: String::new() })
});
let mut p = Checkstyle::default();
p.run_command("use-sun", &host.invocation(), None).unwrap();
p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation());
host.add_file("config/checkstyle/checkstyle.xml", "<module name=\"Checker\"/>");
p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation());
let configs: Vec<String> = host.process_runs().iter().map(|r| r.args[3].clone()).collect();
assert_eq!(configs, [SUN, "config/checkstyle/checkstyle.xml"]);
}
#[test]
fn a_missing_checkstyle_is_reported_once() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage"]);
host.add_file("src/A.java", "class A {}");
Checkstyle::default().handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation());
assert!(host.notifications()[0].body.contains("process:checkstyle"));
}
}