Skip to main content

ktrs_cli/ktlint/reporter/
mod.rs

1//! Ports of the built-in ktlint reporters (`ktlint-cli-reporter-*`) and of `ktlint-cli-reporter-core`.
2//! A reporter is looked up by id ([`get_reporter`]), as ktlint's `ReporterProviderV2` service loader does.
3
4pub mod baseline;
5pub mod checkstyle;
6pub mod format;
7pub mod html;
8pub mod java_map;
9pub mod json;
10pub mod plain;
11pub mod plain_summary;
12pub mod sarif;
13
14use std::path::{Path, PathBuf};
15
16use crate::ktlint::console::Printer;
17
18/// `ktlintVersion(...)` of the 2.0 jar; the run's release: `version::release`.
19pub const KTLINT_VERSION: &str = "2.0.0-ALPHA-4";
20
21/// `KtlintCliError.Status`.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum Status {
24    BaselineIgnored,
25    LintCanNotBeAutocorrected,
26    LintCanBeAutocorrected,
27    FormatIsAutocorrected,
28    KotlinParseException,
29    KtlintRuleEngineException,
30}
31
32/// `KtlintCliError` (`@Poko`).
33#[derive(Clone, Debug, PartialEq, Eq, Hash)]
34pub struct KtlintCliError {
35    pub line: usize,
36    pub col: usize,
37    pub rule_id: String,
38    pub detail: String,
39    pub status: Status,
40    /// ktrs-only: fixed by a ktlint-gradle mode format run, which reports fixed errors with lint statuses
41    /// (`crate::ktlint::gradle`).
42    pub corrected: bool,
43}
44
45impl KtlintCliError {
46    pub fn new(line: usize, col: usize, rule_id: &str, detail: &str, status: Status) -> KtlintCliError {
47        KtlintCliError { line, col, rule_id: rule_id.to_owned(), detail: detail.to_owned(), status, corrected: false }
48    }
49}
50
51/// `ReporterV2`. Called from one thread, files in order.
52pub trait ReporterV2: Send {
53    fn before_all(&mut self) {}
54    fn before(&mut self, _file: &str) {}
55    fn on_lint_error(&mut self, file: &str, ktlint_cli_error: &KtlintCliError);
56    fn after(&mut self, _file: &str) {}
57    fn after_all(&mut self) {}
58}
59
60/// A reporter's `opt` map (a `LinkedHashMap`: insertion order, later puts replace the value in place).
61#[derive(Clone, Debug, Default, PartialEq, Eq)]
62pub struct ReporterOptions(pub Vec<(String, String)>);
63
64impl ReporterOptions {
65    pub fn get(&self, key: &str) -> Option<&str> {
66        self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
67    }
68
69    pub fn put(&mut self, key: &str, value: &str) {
70        match self.0.iter_mut().find(|(k, _)| k == key) {
71            Some(entry) => entry.1 = value.to_owned(),
72            None => self.0.push((key.to_owned(), value.to_owned())),
73        }
74    }
75
76    /// Java's `AbstractMap.toString()`: `{k=v, k2=v2}`.
77    pub fn to_java_string(&self) -> String {
78        let entries: Vec<String> = self.0.iter().map(|(k, v)| format!("{k}={v}")).collect();
79        format!("{{{}}}", entries.join(", "))
80    }
81}
82
83/// What reporters read from the environment: `user.home` and the ktlint release (sarif) and the working
84/// directory (baseline).
85#[derive(Clone, Debug)]
86pub struct ReporterEnvironment {
87    pub user_home: Option<PathBuf>,
88    pub working_dir: PathBuf,
89    pub ktlint_release: &'static str,
90}
91
92/// The ids of the built-in `ReporterProviderV2`s.
93pub const REPORTER_PROVIDER_IDS: [&str; 8] =
94    ["baseline", "checkstyle", "format", "html", "json", "plain", "plain-summary", "sarif"];
95
96/// `ReporterProviderV2.get(out, opt)` of the provider with `id`; `None` for an unknown id. `Err` is the
97/// provider's exception (`IllegalArgumentException: ...`), which upstream does not catch.
98pub fn get_reporter(
99    id: &str,
100    out: Printer,
101    opt: &ReporterOptions,
102    env: &ReporterEnvironment,
103) -> Option<Result<Box<dyn ReporterV2>, String>> {
104    let reporter: Result<Box<dyn ReporterV2>, String> = match id {
105        "baseline" => Ok(Box::new(baseline::BaselineReporter::new(out, env.working_dir.clone()))),
106        "checkstyle" => Ok(Box::new(checkstyle::CheckStyleReporter::new(out))),
107        "format" => format::FormatReporterProvider::get(out, opt).map(|r| Box::new(r) as Box<dyn ReporterV2>),
108        "html" => Ok(Box::new(html::HtmlReporter::new(out))),
109        "json" => Ok(Box::new(json::JsonReporter::new(out))),
110        "plain" => plain::PlainReporterProvider::get(out, opt).map(|r| Box::new(r) as Box<dyn ReporterV2>),
111        "plain-summary" => Ok(Box::new(plain_summary::PlainSummaryReporter::new(out))),
112        "sarif" => Ok(Box::new(sarif::SarifReporter::new(out, env.user_home.clone(), env.ktlint_release))),
113        _ => return None,
114    };
115    Some(reporter)
116}
117
118/// `Color` (plain and format reporters).
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum Color {
121    Black = 30,
122    Red = 31,
123    Green = 32,
124    Yellow = 33,
125    Blue = 34,
126    Magenta = 35,
127    Cyan = 36,
128    LightGray = 37,
129    DarkGray = 90,
130    LightRed = 91,
131    LightGreen = 92,
132    LightYellow = 93,
133    LightBlue = 94,
134    LightMagenta = 95,
135    LightCyan = 96,
136    White = 97,
137}
138
139impl Color {
140    const ENTRIES: [(&'static str, Color); 16] = [
141        ("BLACK", Color::Black),
142        ("RED", Color::Red),
143        ("GREEN", Color::Green),
144        ("YELLOW", Color::Yellow),
145        ("BLUE", Color::Blue),
146        ("MAGENTA", Color::Magenta),
147        ("CYAN", Color::Cyan),
148        ("LIGHT_GRAY", Color::LightGray),
149        ("DARK_GRAY", Color::DarkGray),
150        ("LIGHT_RED", Color::LightRed),
151        ("LIGHT_GREEN", Color::LightGreen),
152        ("LIGHT_YELLOW", Color::LightYellow),
153        ("LIGHT_BLUE", Color::LightBlue),
154        ("LIGHT_MAGENTA", Color::LightMagenta),
155        ("LIGHT_CYAN", Color::LightCyan),
156        ("WHITE", Color::White),
157    ];
158
159    /// The providers' `getColor(opt["color_name"])`.
160    pub fn from_option(name: Option<&str>) -> Result<Color, String> {
161        Color::ENTRIES
162            .iter()
163            .find(|(n, _)| Some(*n) == name)
164            .map(|(_, c)| *c)
165            .ok_or_else(|| "java.lang.IllegalArgumentException: Invalid color parameter.".to_owned())
166    }
167
168    /// `String.color(foreground)`.
169    pub fn paint(self, text: &str) -> String {
170        format!("\u{1b}[{}m{text}\u{1b}[0m", self as u8)
171    }
172}
173
174/// The providers' `String.emptyOrTrue()` over an optional option.
175pub fn empty_or_true(value: Option<&str>) -> bool {
176    matches!(value, Some("" | "true"))
177}
178
179/// Java's `File.separator` as used by `substringAfterLast(File.separator)` in the plain/format reporters.
180pub const FILE_SEPARATOR: &str = if cfg!(windows) { "\\" } else { "/" };
181
182/// `fileName.substring(0, len - name.length).colored() + name` of the plain and format reporters.
183pub fn color_file_name(file_name: &str, colored: impl Fn(&str) -> String) -> String {
184    let name = match file_name.rfind(FILE_SEPARATOR) {
185        Some(i) => &file_name[i + FILE_SEPARATOR.len()..],
186        None => file_name,
187    };
188    colored(&file_name[..file_name.len() - name.len()]) + name
189}
190
191/// `COUNT_DESC_AND_RULE_ID_ASC_COMPARATOR` over `(rule, count)` pairs, then the summary lines.
192pub fn print_summary(out: &mut Printer, header: &str, counts: &[(String, u64)]) {
193    let mut sorted = counts.to_vec();
194    sorted.sort_by(|(ra, ca), (rb, cb)| cb.cmp(ca).then_with(|| java_compare(ra, rb)));
195    out.println(header);
196    for (rule, count) in sorted {
197        out.println(&format!("  {rule}: {count}"));
198    }
199}
200
201/// Java's `String.compareTo`: UTF-16 code unit order.
202pub fn java_compare(a: &str, b: &str) -> std::cmp::Ordering {
203    a.encode_utf16().cmp(b.encode_utf16())
204}
205
206/// `ConcurrentHashMap.merge(key, 1, +)` over a small count list.
207pub fn increment(counts: &mut Vec<(String, u64)>, key: &str) {
208    match counts.iter_mut().rev().find(|(k, _)| k == key) {
209        Some(entry) => entry.1 += 1,
210        None => counts.push((key.to_owned(), 1)),
211    }
212}
213
214/// `KtlintCliError.causedBy()` of the plain and plain-summary reporters.
215pub fn caused_by(error: &KtlintCliError) -> String {
216    match error.status {
217        Status::KotlinParseException => "Not a valid Kotlin file".to_owned(),
218        Status::KtlintRuleEngineException => "An internal error occurred in the Ktlint Rule Engine".to_owned(),
219        _ if error.rule_id.is_empty() => "Unknown".to_owned(),
220        _ => error.rule_id.clone(),
221    }
222}
223
224/// `escapeXMLAttrValue()` of the checkstyle, baseline and html reporters.
225pub fn escape_xml_attr_value(value: &str) -> String {
226    value
227        .replace('&', "&amp;")
228        .replace('"', "&quot;")
229        .replace('\'', "&apos;")
230        .replace('<', "&lt;")
231        .replace('>', "&gt;")
232}
233
234/// `acc.getOrPut(file) { ArrayList() }.add(error)` over a list keeping first-insertion order. Errors
235/// arrive file by file, so the current file is the last entry.
236pub fn accumulate(acc: &mut Vec<(String, Vec<KtlintCliError>)>, file: &str, error: &KtlintCliError) {
237    match acc.iter_mut().rev().find(|(f, _)| f == file) {
238        Some((_, errors)) => errors.push(error.clone()),
239        None => acc.push((file.to_owned(), vec![error.clone()])),
240    }
241}
242
243/// Kotlin's `Path.relativeToOrSelf(base)`: both lexically normalized, then `base.relativize(path)`;
244/// `path` itself when the two are not both absolute (or both relative) with the same root.
245pub fn relative_to_or_self(path: &Path, base: &Path) -> PathBuf {
246    use std::path::Component;
247    fn normalize(p: &Path) -> Vec<Component<'_>> {
248        let mut parts: Vec<Component> = Vec::new();
249        for c in p.components() {
250            match c {
251                Component::CurDir => {}
252                Component::ParentDir if matches!(parts.last(), Some(Component::Normal(_))) => {
253                    parts.pop();
254                }
255                c => parts.push(c),
256            }
257        }
258        parts
259    }
260    fn root<'a>(parts: &[Component<'a>]) -> Vec<Component<'a>> {
261        parts.iter().take_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir)).cloned().collect()
262    }
263    let (p, b) = (normalize(path), normalize(base));
264    if path.is_absolute() != base.is_absolute() || root(&p) != root(&b) {
265        return path.to_path_buf();
266    }
267    let common = p.iter().zip(&b).take_while(|(x, y)| x == y).count();
268    let mut relative = PathBuf::new();
269    for _ in common..b.len() {
270        relative.push("..");
271    }
272    for c in &p[common..] {
273        relative.push(c.as_os_str());
274    }
275    relative
276}
277
278/// `acc.entries.sortedBy { it.key }`.
279pub fn sorted_by_file(acc: &[(String, Vec<KtlintCliError>)]) -> Vec<&(String, Vec<KtlintCliError>)> {
280    let mut sorted: Vec<_> = acc.iter().collect();
281    sorted.sort_by(|a, b| java_compare(&a.0, &b.0));
282    sorted
283}