use super::LineInfo;
use crate::utils::range_utils::byte_to_char_count;
use std::collections::HashMap;
const CHECKPOINT_BYTES: usize = 256;
struct LineColumns(Vec<usize>);
impl LineColumns {
fn new() -> Self {
Self(vec![0])
}
fn column(&mut self, text: &str, byte: usize) -> usize {
let byte = char_boundary(text, byte);
let checkpoint = byte / CHECKPOINT_BYTES;
while self.0.len() <= checkpoint {
let next = self.0.len();
let start = char_boundary(text, (next - 1) * CHECKPOINT_BYTES);
let end = char_boundary(text, next * CHECKPOINT_BYTES);
self.0.push(self.0[next - 1] + text[start..end].chars().count());
}
let start = char_boundary(text, checkpoint * CHECKPOINT_BYTES);
self.0[checkpoint] + text[start..byte].chars().count()
}
}
fn char_boundary(text: &str, byte: usize) -> usize {
let mut byte = byte.min(text.len());
while !text.is_char_boundary(byte) {
byte -= 1;
}
byte
}
pub(super) fn line_index(lines: &[LineInfo], byte_offset: usize) -> usize {
match lines.binary_search_by(|line| {
if byte_offset < line.byte_offset {
std::cmp::Ordering::Greater
} else if byte_offset > line.byte_offset + line.byte_len {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
}) {
Ok(idx) => idx,
Err(idx) => idx.saturating_sub(1),
}
}
pub(crate) struct SourcePositions<'a> {
content: &'a str,
lines: &'a [LineInfo],
columns: HashMap<usize, LineColumns>,
}
impl<'a> SourcePositions<'a> {
pub(crate) fn new(content: &'a str, lines: &'a [LineInfo]) -> Self {
Self {
content,
lines,
columns: HashMap::new(),
}
}
pub(crate) fn position(&mut self, byte_offset: usize) -> (usize, usize, usize) {
let idx = line_index(self.lines, byte_offset);
(idx, idx + 1, self.column_on_line(idx, byte_offset))
}
pub(crate) fn column_on_line(&mut self, line_idx: usize, byte_offset: usize) -> usize {
let line = &self.lines[line_idx];
let text = line.content(self.content);
let byte_col = byte_offset.saturating_sub(line.byte_offset).min(text.len());
if byte_col < CHECKPOINT_BYTES {
byte_to_char_count(text, byte_col) - 1
} else {
self.columns
.entry(line_idx)
.or_insert_with(LineColumns::new)
.column(text, byte_col)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
#[test]
fn line_index_matches_legacy_lookup_at_all_byte_boundaries() {
for content in ["", "\n", "\r\n", "é日本🙂", "é日本🙂\n\n", "é日本🙂\r\n\r\nlast\r"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for offset in (0..=content.len() + 3).chain([usize::MAX]) {
assert_eq!(
line_index(&ctx.lines, offset) + 1,
if ctx.lines.is_empty() {
1
} else {
LintContext::find_line_for_offset(&ctx.lines, content, offset).1
},
"{content:?}, offset={offset}",
);
}
}
}
#[test]
fn line_only_lookup_matches_full_position_at_all_byte_boundaries() {
for content in ["", "\n", "\r\n", "é日本🙂", "é日本🙂\n\n", "é日本🙂\r\n\r\nlast\r"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for offset in (0..=content.len() + 3).chain([usize::MAX]) {
assert_eq!(
ctx.line_number_at_offset(offset),
ctx.offset_to_line_col(offset).0,
"{content:?}, offset={offset}",
);
}
}
}
fn verify_columns(text: &str) {
let mut columns = LineColumns::new();
for byte in (0..=text.len() + 3).rev() {
assert_eq!(columns.column(text, byte), byte_to_char_count(text, byte) - 1);
}
}
#[test]
fn checkpoints_preserve_character_columns() {
for prefix in 0..4 {
for unit in ["a", "é", "日本", "🙂", "e\u{301}", "aé日🙂\t"] {
verify_columns(&format!("{}{}", "x".repeat(prefix), unit.repeat(400)));
}
}
verify_columns("");
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
#[test]
fn indexed_columns_match_scalar_count(chars in proptest::collection::vec(proptest::char::any(), 0..700)) {
verify_columns(&chars.into_iter().collect::<String>());
}
}
#[test]
fn source_positions_preserve_line_boundaries_and_clamping() {
for newline in ["\n", "\r\n"] {
let content = format!("{}{}{}{}end", "é🙂".repeat(150), newline, "a".repeat(700), newline);
let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
let mut positions = SourcePositions::new(&content, &ctx.lines);
for byte in (0..=content.len() + 3).rev() {
let idx = ctx.lines.iter().rposition(|line| line.byte_offset <= byte).unwrap();
let line = &ctx.lines[idx];
let col = byte_to_char_count(line.content(&content), byte - line.byte_offset) - 1;
assert_eq!(positions.position(byte), (idx, idx + 1, col), "at byte {byte}");
assert_eq!(
positions.position(byte),
LintContext::find_line_for_offset(&ctx.lines, &content, byte),
"legacy at byte {byte}"
);
let opposite = content.len().saturating_sub(byte);
assert_eq!(
positions.position(opposite),
LintContext::find_line_for_offset(&ctx.lines, &content, opposite),
"legacy at opposite byte {opposite}"
);
}
}
}
#[test]
fn short_lines_do_not_allocate_column_indexes() {
let ctx = LintContext::new("é link\nnext\n", MarkdownFlavor::Standard, None);
let mut positions = SourcePositions::new(ctx.content, &ctx.lines);
for byte in 0..=ctx.content.len() {
positions.position(byte);
}
assert!(positions.columns.is_empty());
}
#[test]
fn checkpoints_grow_only_to_requested_offsets() {
let text = "é🙂".repeat(1000);
let mut columns = LineColumns::new();
for byte in 0..600 {
assert_eq!(columns.column(&text, byte), byte_to_char_count(&text, byte) - 1);
}
assert_eq!(columns.0.len(), 3);
assert_eq!(columns.column(&text, 12), 4);
assert_eq!(columns.0.len(), 3);
}
#[test]
fn parsed_link_and_image_columns_match_source_across_flavors() {
for flavor in crate::format_proof::ALL_FLAVORS {
for newline in ["\n", "\r\n"] {
let prefix = "é日本🙂\t".repeat(80);
let content = format!(
"{prefix}[](target) [resolved][r] [unresolved][missing] ![alt][missing]\n\n\
{prefix}[multiline\n{prefix}label](target) \n\n\
[r]: target\n"
)
.replace('\n', newline);
let ctx = LintContext::new(&content, flavor, None);
assert!(ctx.links().len() >= 4, "{flavor:?}");
assert!(ctx.images().len() >= 3, "{flavor:?}");
let spans = ctx
.links()
.iter()
.map(|link| {
(
link.byte_offset,
link.byte_end,
link.line,
link.start_col,
link.end_line,
link.end_col,
)
})
.chain(ctx.images().iter().map(|image| {
(
image.byte_offset,
image.byte_end,
image.line,
image.start_col,
image.end_line,
image.end_col,
)
}));
for (start, end, line, col, end_line, end_col) in spans {
for (byte, expected_line, expected_col) in [(start, line, col), (end, end_line, end_col)] {
let preceding = &content[..byte];
let actual_line = preceding.bytes().filter(|&b| b == b'\n').count() + 1;
let actual_col = preceding
.rsplit('\n')
.next()
.unwrap()
.trim_end_matches('\r')
.chars()
.count();
assert_eq!(
(expected_line, expected_col),
(actual_line, actual_col),
"{flavor:?} at {byte}"
);
}
}
}
}
}
}