use std::cell::{Cell, OnceCell, RefCell};
use crate::unicode::line_separator_at;
use crate::{Problem, Severity};
#[derive(Debug)]
pub struct Report<'a> {
text: &'a str,
problems: Vec<Problem>,
line_starts: OnceCell<Vec<usize>>,
json5: bool,
ascii: OnceCell<bool>,
furthest: Cell<(usize, usize, usize)>,
checkpoints: RefCell<Vec<(usize, usize)>>,
#[cfg(test)]
counted: Cell<usize>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub struct Location {
pub line: usize,
pub column: usize,
}
impl Location {
pub const START: Location = Location { line: 1, column: 1 };
}
impl<'a> Report<'a> {
pub(crate) fn new(text: &'a str, mut problems: Vec<Problem>, json5: bool) -> Self {
problems.sort_by_key(Problem::offset);
Report {
text,
problems,
line_starts: OnceCell::new(),
json5,
ascii: OnceCell::new(),
furthest: Cell::new((0, 1, 1)),
checkpoints: RefCell::new(vec![(0, 0)]),
#[cfg(test)]
counted: Cell::new(0),
}
}
pub fn problems(&self) -> &[Problem] {
&self.problems
}
pub fn passed(&self) -> bool {
self.problems.iter().all(|p| p.severity() < Severity::Error)
}
pub fn location(&self, offset: usize) -> Location {
let offset = offset.min(self.text.len());
let starts = self
.line_starts
.get_or_init(|| line_starts(self.text, self.json5));
let line = starts.partition_point(|&s| s <= offset);
let start = starts[line - 1];
if *self.ascii.get_or_init(|| self.text.is_ascii()) {
return Location {
line,
column: 1 + offset - start,
};
}
let (furthest, furthest_line, furthest_column) = self.furthest.get();
let column = if furthest_line == line && furthest <= offset {
furthest_column + self.count(furthest, offset)
} else if offset - start <= CHUNK {
1 + self.count(start, offset)
} else {
1 + self
.chars_before(offset)
.saturating_sub(self.chars_before(start))
};
if offset >= furthest {
self.furthest.set((offset, line, column));
}
Location { line, column }
}
fn count(&self, from: usize, to: usize) -> usize {
#[cfg(test)]
self.counted.set(self.counted.get() + to.abs_diff(from));
self.text.get(from..to).map_or(0, |s| s.chars().count())
}
fn chars_before(&self, offset: usize) -> usize {
let mut checkpoints = self.checkpoints.borrow_mut();
while let Some(&(at, before)) = checkpoints.last().filter(|(at, _)| at + CHUNK <= offset) {
let mut next = (at + CHUNK).min(self.text.len());
while !self.text.is_char_boundary(next) {
next += 1;
}
checkpoints.push((next, before + self.count(at, next)));
}
let (at, before) = checkpoints[checkpoints.partition_point(|&(at, _)| at <= offset) - 1];
before + self.count(at, offset)
}
}
const CHUNK: usize = 4096;
fn line_starts(text: &str, json5: bool) -> Vec<usize> {
let bytes = text.as_bytes();
let mut starts = vec![0];
let mut i = 0;
let third = if json5 { 0xE2 } else { b'\n' };
while let Some(found) = memchr::memchr3(b'\n', b'\r', third, &bytes[i..]) {
let at = i + found;
match bytes[at] {
b'\r' if bytes.get(at + 1) == Some(&b'\n') => {
starts.push(at + 2);
i = at + 2;
continue;
}
b'\n' | b'\r' => starts.push(at + 1),
_ if line_separator_at(bytes, at) => starts.push(at + 3),
_ => {}
}
i = at + 1;
}
starts
}
#[cfg(test)]
mod tests {
use super::{Location, Report, line_starts};
use crate::{Config, Dialect, check};
#[test]
fn every_json5_line_terminator_starts_a_line() {
let text = "a\nb\r\nc\rd\u{2028}e\u{2029}f";
assert_eq!(line_starts(text, true), vec![0, 2, 5, 7, 11, 15]);
assert_eq!(line_starts(text, false), vec![0, 2, 5, 7]);
assert_eq!(line_starts("no breaks, but é and ★", true), vec![0]);
}
#[test]
fn locations_agree_in_any_order() {
let report = Report::new("aé★b\ncdé", Vec::new(), true);
let at = |offset| report.location(offset);
let expected = [
(6, (1, 4)),
(1, (1, 2)),
(9, (2, 2)),
(7, (1, 5)),
(12, (2, 4)),
];
for (offset, (line, column)) in expected {
assert_eq!(at(offset), Location { line, column }, "offset {offset}");
}
}
#[test]
fn checkpoints_land_on_character_boundaries() {
let text = format!("a{}", "\u{e9}".repeat(5000));
let report = Report::new(&text, Vec::new(), true);
for k in (0..5000).rev().step_by(97) {
let at = report.location(1 + 2 * k);
assert_eq!(
at,
Location {
line: 1,
column: 2 + k
},
"character {k}"
);
}
}
#[test]
fn ascii_columns_need_no_counting() {
let keys: Vec<String> = (0..1000).map(|i| format!("\"k{i}\":1")).collect();
let text = format!("{{{},{}}}", keys.join(","), keys.join(","));
let report = check(&text, &Config::new(Dialect::Json));
assert_eq!(report.problems().len(), 1000);
for problem in report.problems() {
let at = report.location(problem.offset());
assert_eq!(at.column, problem.offset() + 1);
report.location(problem.related().map(|r| r.offset).unwrap_or_default());
}
assert_eq!(report.counted.get(), 0);
}
#[test]
fn non_ascii_columns_cost_at_most_a_chunk_each() {
let keys: Vec<String> = (0..10_000).map(|i| format!("\"k{i}\":1")).collect();
let text = format!("{{\"\u{e9}\":0,{},{}}}", keys.join(","), keys.join(","));
let report = check(&text, &Config::new(Dialect::Json));
assert_eq!(report.problems().len(), 10_000);
for problem in report.problems() {
let at = report.location(problem.offset());
assert_eq!(at.column, problem.offset());
let first = problem.related().map(|r| r.offset).unwrap_or_default();
assert_eq!(report.location(first).column, first);
}
let bound = 2 * text.len() + 2 * super::CHUNK * report.problems().len();
assert!(
report.counted.get() <= bound,
"{} > {bound}",
report.counted.get()
);
}
}