use pulldown_cmark::{Event, HeadingLevel, Tag, TagEnd};
use crate::RenderObserver;
#[derive(Debug, Clone)]
pub struct HeadingEntry {
pub level: u8,
pub text: String,
pub plain_offset: usize,
}
#[derive(Debug)]
pub struct RenderedDoc {
pub styled: Vec<u8>,
pub plain: String,
pub line_starts: Vec<usize>,
pub styled_line_starts: Vec<usize>,
pub headings: Vec<HeadingEntry>,
}
impl RenderedDoc {
pub fn line_count(&self) -> usize {
self.line_starts.len().saturating_sub(1).max(1)
}
pub fn styled_line(&self, n: usize) -> &[u8] {
self.styled_line_starts
.get(n..=n + 1)
.map_or(&[][..], |range| &self.styled[range[0]..range[1]])
}
pub fn line_for_plain_offset(&self, offset: usize) -> usize {
match self.line_starts.binary_search(&offset) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
}
}
}
pub fn build(styled: Vec<u8>, headings: Vec<HeadingEntry>) -> RenderedDoc {
let plain = strip_ansi(&styled);
let (line_starts, styled_line_starts) = index_lines(&styled, &plain);
RenderedDoc {
styled,
plain,
line_starts,
styled_line_starts,
headings,
}
}
#[derive(Default)]
pub struct HeadingRecorder {
pending: Option<PendingHeading>,
done: Vec<HeadingEntry>,
}
struct PendingHeading {
level: u8,
plain_offset: u64,
text: String,
}
impl HeadingRecorder {
pub fn finish(self) -> Vec<HeadingEntry> {
self.done
}
}
impl RenderObserver for HeadingRecorder {
fn on_event(&mut self, byte_offset: u64, event: &Event<'_>) {
match event {
Event::Start(Tag::Heading { level, .. }) => {
self.pending = Some(PendingHeading {
level: heading_level_to_u8(*level),
plain_offset: byte_offset,
text: String::new(),
});
}
Event::End(TagEnd::Heading(_)) => {
if let Some(p) = self.pending.take() {
self.done.push(HeadingEntry {
level: p.level,
text: p.text.trim().to_string(),
plain_offset: p.plain_offset as usize,
});
}
}
Event::Text(s) | Event::Code(s) => {
if let Some(p) = self.pending.as_mut() {
p.text.push_str(s);
}
}
_ => {}
}
}
}
fn heading_level_to_u8(level: HeadingLevel) -> u8 {
level as u8
}
fn skip_escape(input: &[u8], start: usize) -> usize {
let Some(&next) = input.get(start + 1) else {
return start + 1;
};
match next {
b'[' => {
let mut i = start + 2;
while input.get(i).is_some_and(|&b| !(0x40..=0x7e).contains(&b)) {
i += 1;
}
i + usize::from(i < input.len())
}
b']' | b'P' | b'_' | b'^' => {
let mut i = start + 2;
while let Some(&b) = input.get(i) {
if b == 0x07 {
return i + 1;
}
if b == 0x1b && input.get(i + 1) == Some(&b'\\') {
return i + 2;
}
i += 1;
}
i
}
_ => start + 2,
}
}
fn strip_ansi(input: &[u8]) -> String {
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
if input[i] == 0x1b {
i = skip_escape(input, i);
} else {
out.push(input[i]);
i += 1;
}
}
String::from_utf8(out)
.unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned())
}
fn index_lines(styled: &[u8], plain: &str) -> (Vec<usize>, Vec<usize>) {
let mut plain_starts = vec![0];
let mut styled_starts = vec![0];
let (mut p, mut s) = (0usize, 0usize);
while s < styled.len() {
if styled[s] == 0x1b {
s = skip_escape(styled, s);
continue;
}
if styled[s] == b'\n' {
s += 1;
p += 1;
plain_starts.push(p);
styled_starts.push(s);
continue;
}
s += 1;
p += 1;
}
if *plain_starts.last().unwrap() != plain.len() {
plain_starts.push(plain.len());
styled_starts.push(styled.len());
}
(plain_starts, styled_starts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_sgr_and_preserves_text() {
let bytes = b"\x1b[1mbold\x1b[0m plain \x1b[34mblue\x1b[0m";
assert_eq!(strip_ansi(bytes), "bold plain blue");
}
#[test]
fn strips_osc8_hyperlinks() {
let bytes = b"\x1b]8;;https://example.com\x1b\\label\x1b]8;;\x1b\\";
assert_eq!(strip_ansi(bytes), "label");
}
#[test]
fn preserves_newlines_for_line_indexing() {
let bytes = b"line one\nline two\n";
let s = strip_ansi(bytes);
assert_eq!(s, "line one\nline two\n");
}
#[test]
fn build_indexes_three_lines() {
let styled = b"\x1b[1malpha\x1b[0m\nbeta\ngamma\n".to_vec();
let doc = build(styled, Vec::new());
assert_eq!(doc.plain, "alpha\nbeta\ngamma\n");
assert_eq!(doc.line_count(), 3);
assert_eq!(doc.line_starts, vec![0, 6, 11, 17]);
assert_eq!(doc.styled_line(1), b"beta\n");
assert_eq!(doc.styled_line(0), b"\x1b[1malpha\x1b[0m\n");
}
#[test]
fn line_lookup_round_trips() {
let styled = b"one\ntwo\nthree\n".to_vec();
let doc = build(styled, Vec::new());
assert_eq!(doc.line_for_plain_offset(0), 0);
assert_eq!(doc.line_for_plain_offset(3), 0); assert_eq!(doc.line_for_plain_offset(4), 1); assert_eq!(doc.line_for_plain_offset(8), 2); assert_eq!(doc.line_for_plain_offset(100), 3); }
}