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