tiger_lib/report/
errors.rs

1//! Collect error reports and then write them out.
2
3use std::cell::RefCell;
4use std::cmp::Ordering;
5use std::fs::{read, File};
6use std::io::{stdout, Write};
7use std::mem::take;
8use std::ops::{Bound, RangeBounds};
9use std::path::{Path, PathBuf};
10use std::sync::{LazyLock, Mutex, MutexGuard};
11
12use anyhow::Result;
13use encoding_rs::{UTF_8, WINDOWS_1252};
14
15use crate::helpers::{TigerHashMap, TigerHashSet};
16use crate::macros::MACRO_MAP;
17use crate::parse::ignore::IgnoreFilter;
18use crate::report::error_loc::ErrorLoc;
19use crate::report::filter::ReportFilter;
20use crate::report::suppress::{Suppression, SuppressionKey};
21use crate::report::writer::log_report;
22use crate::report::writer_json::log_report_json;
23use crate::report::{ErrorKey, FilterRule, LogReport, OutputStyle, PointedMessage};
24use crate::token::{leak, Loc};
25
26static ERRORS: LazyLock<Mutex<Errors>> = LazyLock::new(|| Mutex::new(Errors::default()));
27
28#[allow(missing_debug_implementations)]
29pub struct Errors {
30    pub(crate) output: RefCell<Box<dyn Write + Send>>,
31
32    /// Extra loaded mods' error tags.
33    pub(crate) loaded_mods_labels: Vec<String>,
34
35    /// Loaded DLCs' error tags.
36    pub(crate) loaded_dlcs_labels: Vec<String>,
37
38    pub(crate) cache: Cache,
39
40    /// Determines whether a report should be printed.
41    pub(crate) filter: ReportFilter,
42    /// Output color and style configuration.
43    pub(crate) styles: OutputStyle,
44
45    pub(crate) suppress: TigerHashMap<SuppressionKey, Vec<Suppression>>,
46    // The range is decomposed into its start and end bounds in order to
47    // avoid dyn shenanigans with the RangeBounds trait.
48    ignore: TigerHashMap<PathBuf, Vec<IgnoreEntry>>,
49
50    /// All reports that passed the checks, stored here to be sorted before being emitted all at once.
51    /// The "abbreviated" reports don't participate in this. They are still emitted immediately.
52    /// It's a `HashSet` because duplicate reports are fairly common due to macro expansion and other revalidations.
53    storage: TigerHashSet<LogReport>,
54}
55
56impl Default for Errors {
57    fn default() -> Self {
58        Errors {
59            output: RefCell::new(Box::new(stdout())),
60            loaded_mods_labels: Vec::default(),
61            loaded_dlcs_labels: Vec::default(),
62            cache: Cache::default(),
63            filter: ReportFilter::default(),
64            styles: OutputStyle::default(),
65            storage: TigerHashSet::default(),
66            suppress: TigerHashMap::default(),
67            ignore: TigerHashMap::default(),
68        }
69    }
70}
71
72impl Errors {
73    fn should_suppress(&mut self, report: &LogReport) -> bool {
74        // TODO: see if this can be done without cloning
75        let key = SuppressionKey { key: report.key, message: report.msg.clone() };
76        if let Some(v) = self.suppress.get(&key) {
77            for suppression in v {
78                if suppression.len() != report.pointers.len() {
79                    continue;
80                }
81                for (s, p) in suppression.iter().zip(report.pointers.iter()) {
82                    if s.path == p.loc.pathname().to_string_lossy()
83                        && s.tag == p.msg
84                        && s.line.as_deref() == self.cache.get_line(p.loc)
85                    {
86                        return true;
87                    }
88                }
89            }
90        }
91        false
92    }
93
94    fn should_ignore(&self, report: &LogReport) -> bool {
95        for p in &report.pointers {
96            if let Some(vec) = self.ignore.get(p.loc.pathname()) {
97                for entry in vec {
98                    if (entry.start, entry.end).contains(&p.loc.line)
99                        && entry.filter.matches(report.key, &report.msg)
100                    {
101                        return true;
102                    }
103                }
104            }
105        }
106        false
107    }
108
109    /// Perform some checks to see whether the report should actually be logged.
110    /// If yes, it will add it to the storage.
111    fn push_report(&mut self, report: LogReport) {
112        if !self.filter.should_print_report(&report) || self.should_suppress(&report) {
113            return;
114        }
115        self.storage.insert(report);
116    }
117
118    /// Immediately log a single-line report about this error.
119    ///
120    /// This is intended for voluminous almost-identical errors, such as from the "unused
121    /// localization" check.
122    // TODO: integrate this function into the error reporting framework.
123    pub fn push_abbreviated<E: ErrorLoc>(&mut self, eloc: E, key: ErrorKey) {
124        let loc = eloc.into_loc();
125        if self.filter.should_maybe_print(key, loc) {
126            if loc.line == 0 {
127                _ = writeln!(self.output.get_mut(), "({key}) {}", loc.pathname().to_string_lossy());
128            } else if let Some(line) = self.cache.get_line(loc) {
129                _ = writeln!(self.output.get_mut(), "({key}) {line}");
130            }
131        }
132    }
133
134    /// Immediately print an error message. It is intended to introduce a following block of
135    /// messages printed with [`Errors::push_abbreviated`].
136    // TODO: integrate this function into the error reporting framework.
137    pub fn push_header(&mut self, _key: ErrorKey, msg: &str) {
138        _ = writeln!(self.output.get_mut(), "{msg}");
139    }
140
141    /// Extract the stored reports, sort them, and return them as a vector of [`LogReport`].
142    /// The stored reports will be left empty.
143    pub fn take_reports(&mut self) -> Vec<LogReport> {
144        let mut reports: Vec<LogReport> = take(&mut self.storage).into_iter().collect();
145        reports.sort_unstable_by(|a, b| {
146            // Severity in descending order
147            let mut cmp = b.severity.cmp(&a.severity);
148            if cmp != Ordering::Equal {
149                return cmp;
150            }
151            // Confidence in descending order too
152            cmp = b.confidence.cmp(&a.confidence);
153            if cmp != Ordering::Equal {
154                return cmp;
155            }
156            // If severity and confidence are the same, order by loc. Check all locs in order.
157            for (a, b) in a.pointers.iter().zip(b.pointers.iter()) {
158                cmp = a.loc.cmp(&b.loc);
159                if cmp != Ordering::Equal {
160                    return cmp;
161                }
162            }
163            // Shorter chain goes first, if it comes to that.
164            cmp = b.pointers.len().cmp(&a.pointers.len());
165            if cmp != Ordering::Equal {
166                return cmp;
167            }
168            // Fallback: order by message text.
169            if cmp == Ordering::Equal {
170                cmp = a.msg.cmp(&b.msg);
171            }
172            cmp
173        });
174        reports
175    }
176
177    /// Print the stored reports.
178    /// Set `json` if they should be printed as a JSON array. Otherwise they are printed in the
179    /// default output format.
180    ///
181    /// Note that the default output format is not stable across versions. It is meant for human
182    /// readability and occasionally gets changed to improve that.
183    ///
184    /// Reports matched by `#tiger-ignore` directives will not be printed.
185    pub fn emit_reports(&mut self, json: bool) {
186        let reports = self.take_reports();
187        if json {
188            _ = writeln!(self.output.get_mut(), "[");
189            let mut first = true;
190            for report in &reports {
191                if self.should_ignore(report) {
192                    continue;
193                }
194                if !first {
195                    _ = writeln!(self.output.get_mut(), ",");
196                }
197                first = false;
198                log_report_json(self, report);
199            }
200            _ = writeln!(self.output.get_mut(), "\n]");
201        } else {
202            for report in &reports {
203                if self.should_ignore(report) {
204                    continue;
205                }
206                log_report(self, report);
207            }
208        }
209    }
210
211    pub fn store_source_file(&mut self, fullpath: PathBuf, source: &'static str) {
212        self.cache.filecache.insert(fullpath, source);
213    }
214
215    /// Get a mutable lock on the global ERRORS struct.
216    ///
217    /// # Panics
218    /// May panic when the mutex has been poisoned by another thread.
219    pub fn get_mut() -> MutexGuard<'static, Errors> {
220        ERRORS.lock().unwrap()
221    }
222
223    /// Like [`Errors::get_mut`] but intended for read-only access.
224    ///
225    /// Currently there is no difference, but if the locking mechanism changes there may be a
226    /// difference.
227    ///
228    /// # Panics
229    /// May panic when the mutex has been poisoned by another thread.
230    pub fn get() -> MutexGuard<'static, Errors> {
231        ERRORS.lock().unwrap()
232    }
233}
234
235#[derive(Debug, Default)]
236pub(crate) struct Cache {
237    /// Files that have been read in to get the lines where errors occurred.
238    /// Cached here to avoid duplicate I/O and UTF-8 parsing.
239    filecache: TigerHashMap<PathBuf, &'static str>,
240
241    /// Files that have been linesplit, cached to avoid doing that work again
242    linecache: TigerHashMap<PathBuf, Vec<&'static str>>,
243}
244
245impl Cache {
246    /// Fetch the contents of a single line from a script file.
247    pub(crate) fn get_line(&mut self, loc: Loc) -> Option<&'static str> {
248        if loc.line == 0 {
249            return None;
250        }
251        let fullpath = loc.fullpath();
252        if let Some(lines) = self.linecache.get(fullpath) {
253            return lines.get(loc.line as usize - 1).copied();
254        }
255        if let Some(contents) = self.filecache.get(fullpath) {
256            let lines: Vec<_> = contents.lines().collect();
257            let line = lines.get(loc.line as usize - 1).copied();
258            self.linecache.insert(fullpath.to_path_buf(), lines);
259            return line;
260        }
261        let bytes = read(fullpath).ok()?;
262        // Try decoding it as UTF-8. If that succeeds without errors, use it, otherwise fall back
263        // to WINDOWS_1252. The decode method will do BOM stripping.
264        let contents = match UTF_8.decode(&bytes) {
265            (contents, _, false) => contents,
266            (_, _, true) => WINDOWS_1252.decode(&bytes).0,
267        };
268        let contents = leak(contents.into_owned());
269        self.filecache.insert(fullpath.to_path_buf(), contents);
270
271        let lines: Vec<_> = contents.lines().collect();
272        let line = lines.get(loc.line as usize - 1).copied();
273        self.linecache.insert(fullpath.to_path_buf(), lines);
274        line
275    }
276}
277
278#[derive(Debug, Clone)]
279struct IgnoreEntry {
280    start: Bound<u32>,
281    end: Bound<u32>,
282    filter: IgnoreFilter,
283}
284
285/// Record a secondary mod to be loaded before the one being validated.
286/// `label` is what it should be called in the error reports; ideally only a few characters long.
287pub fn add_loaded_mod_root(label: String) {
288    let mut errors = Errors::get_mut();
289    errors.loaded_mods_labels.push(label);
290}
291
292/// Record a DLC directory from the vanilla installation.
293/// `label` is what it should be called in the error reports.
294pub fn add_loaded_dlc_root(label: String) {
295    let mut errors = Errors::get_mut();
296    errors.loaded_dlcs_labels.push(label);
297}
298
299/// Configure the error reports to be written to this file instead of to stdout.
300pub fn set_output_file(file: &Path) -> Result<()> {
301    let file = File::create(file)?;
302    Errors::get_mut().output = RefCell::new(Box::new(file));
303    Ok(())
304}
305
306/// Store an error report to be emitted when [`emit_reports`] is called.
307pub fn log(mut report: LogReport) {
308    let mut vec = Vec::new();
309    report.pointers.drain(..).for_each(|pointer| {
310        let index = vec.len();
311        recursive_pointed_msg_expansion(&mut vec, &pointer);
312        vec.insert(index, pointer);
313    });
314    report.pointers.extend(vec);
315    Errors::get_mut().push_report(report);
316}
317
318/// Expand `PointedMessage` recursively.
319/// That is; for the given `PointedMessage`, follow its location's link until such link is no
320/// longer available, adding a newly created `PointedMessage` to the given `Vec` for each linked
321/// location.
322fn recursive_pointed_msg_expansion(vec: &mut Vec<PointedMessage>, pointer: &PointedMessage) {
323    if let Some(link) = pointer.loc.link_idx {
324        let from_here = PointedMessage {
325            loc: MACRO_MAP.get_loc(link).unwrap(),
326            length: 0,
327            msg: Some("from here".to_owned()),
328        };
329        let index = vec.len();
330        recursive_pointed_msg_expansion(vec, &from_here);
331        vec.insert(index, from_here);
332    }
333}
334
335/// Tests whether the report might be printed. If false, the report will definitely not be printed.
336pub fn will_maybe_log<E: ErrorLoc>(eloc: E, key: ErrorKey) -> bool {
337    Errors::get().filter.should_maybe_print(key, eloc.into_loc())
338}
339
340/// Print all the stored reports to the error output.
341/// Set `json` if they should be printed as a JSON array. Otherwise they are printed in the
342/// default output format.
343///
344/// Note that the default output format is not stable across versions. It is meant for human
345/// readability and occasionally gets changed to improve that.
346pub fn emit_reports(json: bool) {
347    Errors::get_mut().emit_reports(json);
348}
349
350/// Extract the stored reports, sort them, and return them as a vector of [`LogReport`].
351/// The stored reports will be left empty.
352pub fn take_reports() -> Vec<LogReport> {
353    Errors::get_mut().take_reports()
354}
355
356pub fn store_source_file(fullpath: PathBuf, source: &'static str) {
357    Errors::get_mut().store_source_file(fullpath, source);
358}
359
360pub fn register_ignore_filter<R>(pathname: PathBuf, lines: R, filter: IgnoreFilter)
361where
362    R: RangeBounds<u32>,
363{
364    let start = lines.start_bound().cloned();
365    let end = lines.end_bound().cloned();
366    let entry = IgnoreEntry { start, end, filter };
367    Errors::get_mut().ignore.entry(pathname).or_default().push(entry);
368}
369
370// =================================================================================================
371// =============== Deprecated legacy calls to submit reports:
372// =================================================================================================
373
374/// Immediately print an error message. It is intended to introduce a following block of
375/// messages printed with [`warn_abbreviated`].
376pub(crate) fn warn_header(key: ErrorKey, msg: &str) {
377    Errors::get_mut().push_header(key, msg);
378}
379
380/// Immediately log a single-line report about this error.
381///
382/// This is intended for voluminous almost-identical errors, such as from the "unused
383/// localization" check.
384pub(crate) fn warn_abbreviated<E: ErrorLoc>(eloc: E, key: ErrorKey) {
385    Errors::get_mut().push_abbreviated(eloc, key);
386}
387
388// =================================================================================================
389// =============== Configuration (Output style):
390// =================================================================================================
391
392/// Override the default `OutputStyle`. (Controls ansi colors)
393pub fn set_output_style(style: OutputStyle) {
394    Errors::get_mut().styles = style;
395}
396
397/// Disable color in the output.
398pub fn disable_ansi_colors() {
399    Errors::get_mut().styles = OutputStyle::no_color();
400}
401
402// =================================================================================================
403// =============== Configuration (Filter):
404// =================================================================================================
405
406/// Configure the error reporter to show errors that are in the base game code.
407/// Normally those are filtered out, to only show errors that involve the mod's code.
408pub fn set_show_vanilla(v: bool) {
409    Errors::get_mut().filter.show_vanilla = v;
410}
411
412/// Configure the error reporter to show errors that are in extra loaded mods.
413/// Normally those are filtered out, to only show errors that involve the mod's code.
414pub fn set_show_loaded_mods(v: bool) {
415    Errors::get_mut().filter.show_loaded_mods = v;
416}
417
418/// Configure the error reporter to only show errors that match this [`FilterRule`].
419pub(crate) fn set_predicate(predicate: FilterRule) {
420    Errors::get_mut().filter.predicate = predicate;
421}