//! 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, 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>, } fn unescape(s: &str) -> String { s.replace(""", "\"") .replace("'", "'") .replace("<", "<") .replace(">", ">") .replace(" ", "\n") .replace("&", "&") } fn attr(tag: &str, name: &str) -> Option { 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> { let mut out: BTreeMap> = 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::().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::().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 { 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 { 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(" = 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 { 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, 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#" "#; #[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: "".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", ""); p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation()); let configs: Vec = 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")); } }