tiger_lib/report/
errors.rs

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