use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Span {
pub start: u32,
pub end: u32,
pub start_line: u32,
pub start_col: u32,
pub end_line: u32,
pub end_col: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Location {
#[serde(serialize_with = "crate::display::serialize_path")]
pub path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub span: Option<Span>,
}
impl Location {
#[must_use]
pub fn new(path: impl Into<PathBuf>, span: Option<Span>) -> Self {
Self {
path: path.into(),
span,
}
}
#[must_use]
pub fn file(path: impl Into<PathBuf>) -> Self {
Self::new(path, None)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Related {
pub message: String,
pub location: Location,
}
impl Related {
#[must_use]
pub fn new(message: impl Into<String>, location: Location) -> Self {
Self {
message: message.into(),
location,
}
}
}
impl Span {
#[must_use]
pub const fn new(start: u32, end: u32, start_line: u32, start_col: u32, end_line: u32, end_col: u32) -> Self {
Self {
start,
end,
start_line,
start_col,
end_line,
end_col,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct WideChar {
offset: u32,
utf8_len: u32,
utf16_len: u32,
}
impl WideChar {
const fn end(self) -> u32 {
self.offset + self.utf8_len
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineIndex {
line_starts: Vec<u32>,
wide_chars: Vec<WideChar>,
bom_len: u32,
}
const BOM: char = '\u{feff}';
fn to_u32(n: usize) -> u32 {
u32::try_from(n).unwrap_or(u32::MAX)
}
impl LineIndex {
#[must_use]
pub fn new(source: &str) -> Self {
let line_starts = std::iter::once(0)
.chain(memchr::memchr_iter(b'\n', source.as_bytes()).map(|i| to_u32(i + 1)))
.collect();
let bom_len = if source.starts_with(BOM) {
to_u32(BOM.len_utf8())
} else {
0
};
let wide_chars = if source.is_ascii() {
Vec::new()
} else {
source
.char_indices()
.filter(|&(i, c)| c.len_utf8() > 1 && !(i == 0 && c == BOM))
.map(|(i, c)| WideChar {
offset: to_u32(i),
utf8_len: to_u32(c.len_utf8()),
utf16_len: to_u32(c.len_utf16()),
})
.collect()
};
Self {
line_starts,
wide_chars,
bom_len,
}
}
#[must_use]
pub fn line_col(&self, offset: u32) -> (u32, u32) {
let (line, start) = self.line_of(offset);
let skipped = self.skipped_bytes(start, offset, |w| w.utf8_len - 1) + self.bom_before(start, offset);
(line, offset - start - skipped + 1)
}
#[must_use]
pub fn span(&self, start: u32, end: u32) -> Span {
let (start_line, start_col) = self.line_col(start);
let (end_line, end_col) = self.line_col(end);
Span {
start,
end,
start_line,
start_col,
end_line,
end_col,
}
}
#[must_use]
pub fn utf16_col(&self, offset: u32) -> u32 {
let (_, start) = self.line_of(offset);
let skipped = self.skipped_bytes(start, offset, |w| w.utf8_len - w.utf16_len) + self.bom_before(start, offset);
offset - start - skipped + 1
}
fn line_of(&self, offset: u32) -> (u32, u32) {
let line = self.line_starts.partition_point(|&start| start <= offset);
let start = line
.checked_sub(1)
.and_then(|i| self.line_starts.get(i))
.copied()
.unwrap_or(0);
(to_u32(line.max(1)), start)
}
fn bom_before(&self, line_start: u32, offset: u32) -> u32 {
if line_start == 0 { offset.min(self.bom_len) } else { 0 }
}
fn skipped_bytes(&self, line_start: u32, offset: u32, weight: impl Fn(WideChar) -> u32) -> u32 {
let lo = self.wide_chars.partition_point(|w| w.offset < line_start);
let hi = self.wide_chars.partition_point(|w| w.end() <= offset).max(lo);
self.wide_chars
.get(lo..hi)
.unwrap_or_default()
.iter()
.copied()
.map(weight)
.sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constructors_fill_every_field() {
let span = Span::new(1, 4, 1, 2, 1, 5);
assert_eq!(
span,
Span {
start: 1,
end: 4,
start_line: 1,
start_col: 2,
end_line: 1,
end_col: 5
}
);
let related = Related::new("why", Location::new("a.json", Some(span)));
assert_eq!(related.message, "why");
assert_eq!(related.location.span, Some(span));
}
#[test]
fn non_ascii_and_ascii_text_index_alike_up_to_the_wide_characters() {
let ascii = LineIndex::new("ab\ncd");
assert_eq!(ascii.utf16_col(4), 2);
let wide = LineIndex::new("é\nab");
assert_eq!(wide.line_col(3), (2, 1));
assert_eq!(wide.utf16_col(2), 2);
}
#[test]
fn a_byte_order_mark_takes_no_column() {
let index = LineIndex::new("\u{feff}{\"a\": 1}\n\u{feff}b");
assert_eq!(index.line_col(3), (1, 1));
assert_eq!(index.utf16_col(3), 1);
assert_eq!(index.line_col(8), (1, 6));
assert_eq!(index.utf16_col(8), 6);
assert_eq!(index.line_col(15), (2, 2));
assert_eq!(index.utf16_col(15), 2);
}
#[test]
fn ascii_single_line() {
let index = LineIndex::new("hello");
assert_eq!(index.line_col(0), (1, 1));
assert_eq!(index.line_col(4), (1, 5));
assert_eq!(index.line_col(5), (1, 6));
}
#[test]
fn multi_line_and_line_ends() {
let index = LineIndex::new("ab\ncd\n\nef");
let cases = [
(0, (1, 1)),
(2, (1, 3)),
(3, (2, 1)),
(5, (2, 3)),
(6, (3, 1)),
(7, (4, 1)),
(9, (4, 3)),
];
for (offset, expected) in cases {
assert_eq!(index.line_col(offset), expected, "offset {offset}");
}
}
#[test]
fn crlf_keeps_carriage_return_on_previous_line() {
let index = LineIndex::new("ab\r\ncd");
assert_eq!(index.line_col(2), (1, 3));
assert_eq!(index.line_col(3), (1, 4));
assert_eq!(index.line_col(4), (2, 1));
assert_eq!(index.line_col(6), (2, 3));
}
#[test]
fn multibyte_columns_count_scalar_values() {
let index = LineIndex::new("h\u{e9}llo\nw\u{e9}\u{e9}");
assert_eq!(index.line_col(1), (1, 2));
assert_eq!(index.line_col(3), (1, 3));
assert_eq!(index.line_col(6), (1, 6));
assert_eq!(index.line_col(7), (2, 1));
assert_eq!(index.line_col(10), (2, 3));
}
#[test]
fn astral_chars_are_one_column_but_two_utf16_units() {
let index = LineIndex::new("a\u{1F600}b\n\u{1F600}c");
assert_eq!(index.line_col(5), (1, 3));
assert_eq!(index.utf16_col(5), 4);
assert_eq!(index.line_col(6), (1, 4));
assert_eq!(index.utf16_col(6), 5);
assert_eq!(index.line_col(11), (2, 2));
assert_eq!(index.utf16_col(11), 3);
}
#[test]
fn utf16_col_matches_scalar_col_for_bmp_text() {
let index = LineIndex::new("h\u{e9}llo");
for offset in [0, 1, 3, 4, 6] {
assert_eq!(index.utf16_col(offset), index.line_col(offset).1, "offset {offset}");
}
}
#[test]
fn span_combines_both_ends() {
let index = LineIndex::new("ab\ncd");
assert_eq!(
index.span(1, 4),
Span {
start: 1,
end: 4,
start_line: 1,
start_col: 2,
end_line: 2,
end_col: 2
}
);
}
#[test]
fn empty_source_and_location_serde() {
let index = LineIndex::new("");
assert_eq!(index.line_col(0), (1, 1));
let location = Location {
path: "a/package.json".into(),
span: None,
};
assert_eq!(
serde_json::to_value(&location).unwrap_or_default(),
serde_json::json!({ "path": "a/package.json" })
);
}
#[test]
fn location_constructors_set_path_and_span() {
let whole = Location::file("package.json");
assert_eq!(
whole,
Location {
path: "package.json".into(),
span: None
}
);
assert_eq!(Location::new("a", whole.span).path, PathBuf::from("a"));
}
}