Skip to main content

jsonvet/
report.rs

1use std::cell::{Cell, OnceCell, RefCell};
2
3use crate::unicode::line_separator_at;
4use crate::{Problem, Severity};
5
6/// The problems found in one text, in document order.
7///
8/// A `Report` is `Send` but not `Sync`: [`location`](Self::location) caches the line starts,
9/// character counts and its furthest answer inside the report. To share one between threads,
10/// put it behind a `Mutex`, or look up the locations first.
11#[derive(Debug)]
12pub struct Report<'a> {
13    text: &'a str,
14    problems: Vec<Problem>,
15    line_starts: OnceCell<Vec<usize>>,
16    /// Whether U+2028 and U+2029 end a line, as only in JSON5.
17    json5: bool,
18    /// Whether the text is all ASCII, where a column is a byte count.
19    ascii: OnceCell<bool>,
20    /// The furthest location found so far, as (offset, line, column). A later offset on the
21    /// same line counts on from it, so locating every problem on a long line stays linear.
22    furthest: Cell<(usize, usize, usize)>,
23    /// (offset, characters before it) about every `CHUNK` bytes from the start, built only as
24    /// far as a lookup has needed, so a column on a long line costs at most two chunks' count.
25    checkpoints: RefCell<Vec<(usize, usize)>>,
26    /// How many bytes `location` has counted characters in, for tests of its cost.
27    #[cfg(test)]
28    counted: Cell<usize>,
29}
30
31/// A 1-based line and column. Lines end at LF, CR and CRLF, and in JSON5 also at U+2028 and
32/// U+2029, which JSON5 counts as line terminators and the other dialects as ordinary
33/// characters; columns count Unicode code points from the start of the line.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
35#[non_exhaustive]
36pub struct Location {
37    /// The line, from 1.
38    pub line: usize,
39    /// The column, from 1.
40    pub column: usize,
41}
42
43impl Location {
44    /// The start of the text: line 1, column 1.
45    pub const START: Location = Location { line: 1, column: 1 };
46}
47
48impl<'a> Report<'a> {
49    /// `json5`: whether U+2028 and U+2029 end a line.
50    pub(crate) fn new(text: &'a str, mut problems: Vec<Problem>, json5: bool) -> Self {
51        // Stable, so problems at the same offset keep the order they were found in.
52        problems.sort_by_key(Problem::offset);
53        Report {
54            text,
55            problems,
56            line_starts: OnceCell::new(),
57            json5,
58            ascii: OnceCell::new(),
59            furthest: Cell::new((0, 1, 1)),
60            checkpoints: RefCell::new(vec![(0, 0)]),
61            #[cfg(test)]
62            counted: Cell::new(0),
63        }
64    }
65
66    /// Every problem, ordered by offset.
67    pub fn problems(&self) -> &[Problem] {
68        &self.problems
69    }
70
71    /// True when no problem is an error.
72    pub fn passed(&self) -> bool {
73        self.problems.iter().all(|p| p.severity() < Severity::Error)
74    }
75
76    /// The line and column of a byte offset in the text. The first call finds the line starts;
77    /// later calls cost a binary search, and in text that is not all ASCII a count of the
78    /// characters before the offset on its line. That count goes on from the furthest offset
79    /// found on the same line, or else covers at most a few KiB, using character counts kept
80    /// every few KiB from the start of the text.
81    /// An offset past the end is treated as the end.
82    pub fn location(&self, offset: usize) -> Location {
83        let offset = offset.min(self.text.len());
84        let starts = self
85            .line_starts
86            .get_or_init(|| line_starts(self.text, self.json5));
87        let line = starts.partition_point(|&s| s <= offset);
88        let start = starts[line - 1];
89        if *self.ascii.get_or_init(|| self.text.is_ascii()) {
90            return Location {
91                line,
92                column: 1 + offset - start,
93            };
94        }
95        let (furthest, furthest_line, furthest_column) = self.furthest.get();
96        let column = if furthest_line == line && furthest <= offset {
97            furthest_column + self.count(furthest, offset)
98        } else if offset - start <= CHUNK {
99            1 + self.count(start, offset)
100        } else {
101            // Saturating, so an offset inside a character cannot underflow.
102            1 + self
103                .chars_before(offset)
104                .saturating_sub(self.chars_before(start))
105        };
106        if offset >= furthest {
107            self.furthest.set((offset, line, column));
108        }
109        Location { line, column }
110    }
111
112    /// The number of characters in `text[from..to]`, or 0 when either end is not a character
113    /// boundary.
114    fn count(&self, from: usize, to: usize) -> usize {
115        #[cfg(test)]
116        self.counted.set(self.counted.get() + to.abs_diff(from));
117        self.text.get(from..to).map_or(0, |s| s.chars().count())
118    }
119
120    /// The number of characters before `offset`, counted on from the nearest checkpoint.
121    fn chars_before(&self, offset: usize) -> usize {
122        let mut checkpoints = self.checkpoints.borrow_mut();
123        while let Some(&(at, before)) = checkpoints.last().filter(|(at, _)| at + CHUNK <= offset) {
124            let mut next = (at + CHUNK).min(self.text.len());
125            while !self.text.is_char_boundary(next) {
126                next += 1;
127            }
128            checkpoints.push((next, before + self.count(at, next)));
129        }
130        let (at, before) = checkpoints[checkpoints.partition_point(|&(at, _)| at <= offset) - 1];
131        before + self.count(at, offset)
132    }
133}
134
135/// The distance between character-count checkpoints, in bytes.
136const CHUNK: usize = 4096;
137
138/// The byte offset where each line starts, the first being 0. With `json5`, U+2028 and U+2029
139/// also end a line.
140fn line_starts(text: &str, json5: bool) -> Vec<usize> {
141    let bytes = text.as_bytes();
142    let mut starts = vec![0];
143    let mut i = 0;
144    // 0xE2 starts U+2028 and U+2029 in UTF-8; outside JSON5, `\n` stands in as a byte already
145    // searched for.
146    let third = if json5 { 0xE2 } else { b'\n' };
147    while let Some(found) = memchr::memchr3(b'\n', b'\r', third, &bytes[i..]) {
148        let at = i + found;
149        match bytes[at] {
150            b'\r' if bytes.get(at + 1) == Some(&b'\n') => {
151                starts.push(at + 2);
152                i = at + 2;
153                continue;
154            }
155            b'\n' | b'\r' => starts.push(at + 1),
156            _ if line_separator_at(bytes, at) => starts.push(at + 3),
157            _ => {}
158        }
159        i = at + 1;
160    }
161    starts
162}
163
164#[cfg(test)]
165mod tests {
166    use super::{Location, Report, line_starts};
167    use crate::{Config, Dialect, check};
168
169    #[test]
170    fn every_json5_line_terminator_starts_a_line() {
171        let text = "a\nb\r\nc\rd\u{2028}e\u{2029}f";
172        assert_eq!(line_starts(text, true), vec![0, 2, 5, 7, 11, 15]);
173        // Elsewhere U+2028 and U+2029 are ordinary characters.
174        assert_eq!(line_starts(text, false), vec![0, 2, 5, 7]);
175        assert_eq!(line_starts("no breaks, but é and ★", true), vec![0]);
176    }
177
178    #[test]
179    fn locations_agree_in_any_order() {
180        let report = Report::new("aé★b\ncdé", Vec::new(), true);
181        let at = |offset| report.location(offset);
182        let expected = [
183            (6, (1, 4)),
184            (1, (1, 2)),
185            (9, (2, 2)),
186            (7, (1, 5)),
187            (12, (2, 4)),
188        ];
189        for (offset, (line, column)) in expected {
190            assert_eq!(at(offset), Location { line, column }, "offset {offset}");
191        }
192    }
193
194    #[test]
195    fn checkpoints_land_on_character_boundaries() {
196        // Byte 4096 falls inside an `é`, so the first checkpoint moves to the next character.
197        let text = format!("a{}", "\u{e9}".repeat(5000));
198        let report = Report::new(&text, Vec::new(), true);
199        for k in (0..5000).rev().step_by(97) {
200            let at = report.location(1 + 2 * k);
201            assert_eq!(
202                at,
203                Location {
204                    line: 1,
205                    column: 2 + k
206                },
207                "character {k}"
208            );
209        }
210    }
211
212    #[test]
213    fn ascii_columns_need_no_counting() {
214        // One line with every key repeated far from its first occurrence, located in the order
215        // the command does: each duplicate, then its first occurrence.
216        let keys: Vec<String> = (0..1000).map(|i| format!("\"k{i}\":1")).collect();
217        let text = format!("{{{},{}}}", keys.join(","), keys.join(","));
218        let report = check(&text, &Config::new(Dialect::Json));
219        assert_eq!(report.problems().len(), 1000);
220        for problem in report.problems() {
221            let at = report.location(problem.offset());
222            assert_eq!(at.column, problem.offset() + 1);
223            report.location(problem.related().map(|r| r.offset).unwrap_or_default());
224        }
225        assert_eq!(report.counted.get(), 0);
226    }
227
228    #[test]
229    fn non_ascii_columns_cost_at_most_a_chunk_each() {
230        // As above, but an `é` first means columns are counted, and the line is long enough
231        // that counting from its start for each first occurrence would be quadratic.
232        let keys: Vec<String> = (0..10_000).map(|i| format!("\"k{i}\":1")).collect();
233        let text = format!("{{\"\u{e9}\":0,{},{}}}", keys.join(","), keys.join(","));
234        let report = check(&text, &Config::new(Dialect::Json));
235        assert_eq!(report.problems().len(), 10_000);
236        for problem in report.problems() {
237            let at = report.location(problem.offset());
238            // Every character but the `é` is one byte.
239            assert_eq!(at.column, problem.offset());
240            let first = problem.related().map(|r| r.offset).unwrap_or_default();
241            assert_eq!(report.location(first).column, first);
242        }
243        let bound = 2 * text.len() + 2 * super::CHUNK * report.problems().len();
244        assert!(
245            report.counted.get() <= bound,
246            "{} > {bound}",
247            report.counted.get()
248        );
249    }
250}