use crate::primitives::ansi::AnsiParser;
use crate::primitives::display_width::char_width;
use crate::services::plugins::api::{ViewTokenStyle, ViewTokenWire, ViewTokenWireKind};
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct ViewLine {
pub text: String,
pub char_source_bytes: Vec<Option<usize>>,
pub char_styles: Vec<Option<ViewTokenStyle>>,
pub char_visual_cols: Vec<usize>,
pub visual_to_char: Vec<usize>,
pub tab_starts: HashSet<usize>,
pub line_start: LineStart,
pub ends_with_newline: bool,
}
impl ViewLine {
#[inline]
pub fn source_byte_at_char(&self, char_idx: usize) -> Option<usize> {
self.char_source_bytes.get(char_idx).copied().flatten()
}
#[inline]
pub fn char_at_visual_col(&self, visual_col: usize) -> usize {
self.visual_to_char
.get(visual_col)
.copied()
.unwrap_or_else(|| self.char_source_bytes.len().saturating_sub(1))
}
#[inline]
pub fn source_byte_at_visual_col(&self, visual_col: usize) -> Option<usize> {
let char_idx = self.char_at_visual_col(visual_col);
self.source_byte_at_char(char_idx)
}
#[inline]
pub fn visual_col_at_char(&self, char_idx: usize) -> usize {
self.char_visual_cols.get(char_idx).copied().unwrap_or(0)
}
#[inline]
pub fn visual_width(&self) -> usize {
self.visual_to_char.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineStart {
Beginning,
AfterSourceNewline,
AfterInjectedNewline,
AfterBreak,
}
impl LineStart {
pub fn is_continuation(&self) -> bool {
matches!(self, LineStart::AfterBreak)
}
}
pub struct ViewLineIterator<'a> {
tokens: &'a [ViewTokenWire],
token_idx: usize,
next_line_start: LineStart,
binary_mode: bool,
ansi_aware: bool,
tab_size: usize,
}
impl<'a> ViewLineIterator<'a> {
pub fn new(
tokens: &'a [ViewTokenWire],
binary_mode: bool,
ansi_aware: bool,
tab_size: usize,
) -> Self {
let tab_size = if tab_size == 0 { 4 } else { tab_size };
Self {
tokens,
token_idx: 0,
next_line_start: LineStart::Beginning,
binary_mode,
ansi_aware,
tab_size,
}
}
#[inline]
fn tab_expansion_width(&self, col: usize) -> usize {
self.tab_size - (col % self.tab_size)
}
}
fn is_unprintable_byte(b: u8) -> bool {
if b == 0x09 || b == 0x0A {
return false;
}
if b < 0x20 {
return true;
}
if b == 0x7F {
return true;
}
false
}
fn format_unprintable_byte(b: u8) -> String {
format!("<{:02X}>", b)
}
impl<'a> Iterator for ViewLineIterator<'a> {
type Item = ViewLine;
fn next(&mut self) -> Option<Self::Item> {
if self.token_idx >= self.tokens.len() {
return None;
}
let line_start = self.next_line_start;
let mut text = String::new();
let mut char_source_bytes: Vec<Option<usize>> = Vec::new();
let mut char_styles: Vec<Option<ViewTokenStyle>> = Vec::new();
let mut char_visual_cols: Vec<usize> = Vec::new();
let mut visual_to_char: Vec<usize> = Vec::new();
let mut tab_starts = HashSet::new();
let mut col = 0usize; let mut ends_with_newline = false;
let mut ansi_parser = if self.ansi_aware {
Some(AnsiParser::new())
} else {
None
};
macro_rules! add_char {
($ch:expr, $source:expr, $style:expr, $width:expr) => {{
let char_idx = char_source_bytes.len();
text.push($ch);
char_source_bytes.push($source);
char_styles.push($style);
char_visual_cols.push(col);
for _ in 0..$width {
visual_to_char.push(char_idx);
}
col += $width;
}};
}
while self.token_idx < self.tokens.len() {
let token = &self.tokens[self.token_idx];
let token_style = token.style.clone();
match &token.kind {
ViewTokenWireKind::Text(t) => {
let base = token.source_offset;
let t_bytes = t.as_bytes();
let mut byte_idx = 0;
while byte_idx < t_bytes.len() {
let b = t_bytes[byte_idx];
let source = base.map(|s| s + byte_idx);
if self.binary_mode && is_unprintable_byte(b) {
let formatted = format_unprintable_byte(b);
for display_ch in formatted.chars() {
add_char!(display_ch, source, token_style.clone(), 1);
}
byte_idx += 1;
continue;
}
let ch = if b < 0x80 {
byte_idx += 1;
b as char
} else {
let remaining = &t_bytes[byte_idx..];
match std::str::from_utf8(remaining) {
Ok(s) => {
if let Some(ch) = s.chars().next() {
byte_idx += ch.len_utf8();
ch
} else {
byte_idx += 1;
'\u{FFFD}'
}
}
Err(e) => {
if self.binary_mode {
let formatted = format_unprintable_byte(b);
for display_ch in formatted.chars() {
add_char!(display_ch, source, token_style.clone(), 1);
}
byte_idx += 1;
continue;
} else {
let valid_up_to = e.valid_up_to();
if valid_up_to > 0 {
if let Some(ch) =
std::str::from_utf8(&remaining[..valid_up_to])
.ok()
.and_then(|s| s.chars().next())
{
byte_idx += ch.len_utf8();
ch
} else {
byte_idx += 1;
'\u{FFFD}'
}
} else {
byte_idx += 1;
'\u{FFFD}'
}
}
}
}
};
if ch == '\t' {
let tab_start_pos = char_source_bytes.len();
tab_starts.insert(tab_start_pos);
let spaces = self.tab_expansion_width(col);
let char_idx = char_source_bytes.len();
text.push(' '); char_source_bytes.push(source);
char_styles.push(token_style.clone());
char_visual_cols.push(col);
for _ in 0..spaces {
visual_to_char.push(char_idx);
}
col += spaces;
for _ in 1..spaces {
text.push(' ');
char_source_bytes.push(source);
char_styles.push(token_style.clone());
char_visual_cols
.push(col - spaces + char_source_bytes.len() - char_idx);
}
} else {
let width = if let Some(ref mut parser) = ansi_parser {
if parser.parse_char(ch).is_none() {
0 } else {
char_width(ch)
}
} else {
char_width(ch)
};
add_char!(ch, source, token_style.clone(), width);
}
}
self.token_idx += 1;
}
ViewTokenWireKind::Space => {
add_char!(' ', token.source_offset, token_style, 1);
self.token_idx += 1;
}
ViewTokenWireKind::Newline => {
add_char!('\n', token.source_offset, token_style, 1);
ends_with_newline = true;
self.next_line_start = if token.source_offset.is_some() {
LineStart::AfterSourceNewline
} else {
LineStart::AfterInjectedNewline
};
self.token_idx += 1;
break;
}
ViewTokenWireKind::Break => {
add_char!('\n', None, None, 1);
ends_with_newline = true;
self.next_line_start = LineStart::AfterBreak;
self.token_idx += 1;
break;
}
ViewTokenWireKind::BinaryByte(b) => {
let formatted = format_unprintable_byte(*b);
for display_ch in formatted.chars() {
add_char!(display_ch, token.source_offset, token_style.clone(), 1);
}
self.token_idx += 1;
}
}
}
let _ = col;
if text.is_empty() && self.token_idx >= self.tokens.len() {
return None;
}
Some(ViewLine {
text,
char_source_bytes,
char_styles,
char_visual_cols,
visual_to_char,
tab_starts,
line_start,
ends_with_newline,
})
}
}
pub fn should_show_line_number(line: &ViewLine) -> bool {
if line.line_start.is_continuation() {
return false;
}
if line.char_source_bytes.is_empty() {
return matches!(
line.line_start,
LineStart::Beginning | LineStart::AfterSourceNewline
);
}
let first_char_is_source = line
.char_source_bytes
.first()
.map(|m| m.is_some())
.unwrap_or(false);
if !first_char_is_source {
return false;
}
true
}
use std::collections::BTreeMap;
use std::ops::Range;
#[derive(Debug, Clone)]
pub struct Layout {
pub lines: Vec<ViewLine>,
pub source_range: Range<usize>,
pub total_view_lines: usize,
pub total_injected_lines: usize,
byte_to_line: BTreeMap<usize, usize>,
}
impl Layout {
pub fn new(lines: Vec<ViewLine>, source_range: Range<usize>) -> Self {
let mut byte_to_line = BTreeMap::new();
for (line_idx, line) in lines.iter().enumerate() {
if let Some(first_byte) = line.char_source_bytes.iter().find_map(|m| *m) {
byte_to_line.insert(first_byte, line_idx);
}
}
let total_view_lines = lines.len();
let total_injected_lines = lines.iter().filter(|l| !should_show_line_number(l)).count();
Self {
lines,
source_range,
total_view_lines,
total_injected_lines,
byte_to_line,
}
}
pub fn from_tokens(
tokens: &[ViewTokenWire],
source_range: Range<usize>,
tab_size: usize,
) -> Self {
let lines: Vec<ViewLine> = ViewLineIterator::new(tokens, false, false, tab_size).collect();
Self::new(lines, source_range)
}
pub fn source_byte_to_view_position(&self, byte: usize) -> Option<(usize, usize)> {
if let Some((&_line_start_byte, &line_idx)) = self.byte_to_line.range(..=byte).last() {
if line_idx < self.lines.len() {
let line = &self.lines[line_idx];
for (char_idx, mapping) in line.char_source_bytes.iter().enumerate() {
if *mapping == Some(byte) {
return Some((line_idx, line.visual_col_at_char(char_idx)));
}
}
return Some((line_idx, line.visual_width()));
}
}
None
}
pub fn view_position_to_source_byte(&self, line_idx: usize, col: usize) -> Option<usize> {
if line_idx >= self.lines.len() {
return None;
}
let line = &self.lines[line_idx];
if col < line.visual_width() {
line.source_byte_at_visual_col(col)
} else if !line.char_source_bytes.is_empty() {
line.char_source_bytes.iter().rev().find_map(|m| *m)
} else {
None
}
}
pub fn get_source_byte_for_line(&self, line_idx: usize) -> Option<usize> {
if line_idx >= self.lines.len() {
return None;
}
self.lines[line_idx]
.char_source_bytes
.iter()
.find_map(|m| *m)
}
pub fn find_nearest_view_line(&self, byte: usize) -> usize {
if let Some((&_line_start_byte, &line_idx)) = self.byte_to_line.range(..=byte).last() {
line_idx.min(self.lines.len().saturating_sub(1))
} else {
0
}
}
pub fn max_top_line(&self, viewport_height: usize) -> usize {
self.lines.len().saturating_sub(viewport_height)
}
pub fn has_content_below(&self, buffer_len: usize) -> bool {
self.source_range.end < buffer_len
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_text_token(text: &str, source_offset: Option<usize>) -> ViewTokenWire {
ViewTokenWire {
kind: ViewTokenWireKind::Text(text.to_string()),
source_offset,
style: None,
}
}
fn make_newline_token(source_offset: Option<usize>) -> ViewTokenWire {
ViewTokenWire {
kind: ViewTokenWireKind::Newline,
source_offset,
style: None,
}
}
fn make_break_token() -> ViewTokenWire {
ViewTokenWire {
kind: ViewTokenWireKind::Break,
source_offset: None,
style: None,
}
}
#[test]
fn test_simple_source_lines() {
let tokens = vec![
make_text_token("Line 1", Some(0)),
make_newline_token(Some(6)),
make_text_token("Line 2", Some(7)),
make_newline_token(Some(13)),
];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].text, "Line 1\n");
assert_eq!(lines[0].line_start, LineStart::Beginning);
assert!(should_show_line_number(&lines[0]));
assert_eq!(lines[1].text, "Line 2\n");
assert_eq!(lines[1].line_start, LineStart::AfterSourceNewline);
assert!(should_show_line_number(&lines[1]));
}
#[test]
fn test_wrapped_continuation() {
let tokens = vec![
make_text_token("Line 1 start", Some(0)),
make_break_token(), make_text_token("continued", Some(12)),
make_newline_token(Some(21)),
];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].line_start, LineStart::Beginning);
assert!(should_show_line_number(&lines[0]));
assert_eq!(lines[1].line_start, LineStart::AfterBreak);
assert!(
!should_show_line_number(&lines[1]),
"Wrapped continuation should NOT show line number"
);
}
#[test]
fn test_injected_header_then_source() {
let tokens = vec![
make_text_token("== HEADER ==", None),
make_newline_token(None),
make_text_token("Line 1", Some(0)),
make_newline_token(Some(6)),
];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].text, "== HEADER ==\n");
assert_eq!(lines[0].line_start, LineStart::Beginning);
assert!(
!should_show_line_number(&lines[0]),
"Injected header should NOT show line number"
);
assert_eq!(lines[1].text, "Line 1\n");
assert_eq!(lines[1].line_start, LineStart::AfterInjectedNewline);
assert!(
should_show_line_number(&lines[1]),
"BUG: Source line after injected header SHOULD show line number!\n\
line_start={:?}, first_char_is_source={}",
lines[1].line_start,
lines[1]
.char_source_bytes
.first()
.map(|m| m.is_some())
.unwrap_or(false)
);
}
#[test]
fn test_mixed_scenario() {
let tokens = vec![
make_text_token("== Block 1 ==", None),
make_newline_token(None),
make_text_token("Line 1", Some(0)),
make_newline_token(Some(6)),
make_text_token("Line 2 start", Some(7)),
make_break_token(),
make_text_token("wrapped", Some(19)),
make_newline_token(Some(26)),
make_text_token("Line 3", Some(27)),
make_newline_token(Some(33)),
];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines.len(), 5);
assert!(!should_show_line_number(&lines[0]));
assert!(should_show_line_number(&lines[1]));
assert!(should_show_line_number(&lines[2]));
assert!(!should_show_line_number(&lines[3]));
assert!(should_show_line_number(&lines[4]));
}
#[test]
fn test_is_unprintable_byte() {
assert!(is_unprintable_byte(0x00));
assert!(is_unprintable_byte(0x01));
assert!(is_unprintable_byte(0x02));
assert!(is_unprintable_byte(0x08));
assert!(!is_unprintable_byte(0x09)); assert!(!is_unprintable_byte(0x0A));
assert!(is_unprintable_byte(0x0B)); assert!(is_unprintable_byte(0x0C)); assert!(is_unprintable_byte(0x0D));
assert!(is_unprintable_byte(0x0E));
assert!(is_unprintable_byte(0x1A)); assert!(is_unprintable_byte(0x1B)); assert!(is_unprintable_byte(0x1C));
assert!(is_unprintable_byte(0x1F));
assert!(!is_unprintable_byte(0x20)); assert!(!is_unprintable_byte(0x41)); assert!(!is_unprintable_byte(0x7E));
assert!(is_unprintable_byte(0x7F));
assert!(!is_unprintable_byte(0x80));
assert!(!is_unprintable_byte(0xFF));
}
#[test]
fn test_format_unprintable_byte() {
assert_eq!(format_unprintable_byte(0x00), "<00>");
assert_eq!(format_unprintable_byte(0x01), "<01>");
assert_eq!(format_unprintable_byte(0x1A), "<1A>");
assert_eq!(format_unprintable_byte(0x7F), "<7F>");
assert_eq!(format_unprintable_byte(0xFF), "<FF>");
}
#[test]
fn test_binary_mode_renders_control_chars() {
let tokens = vec![
ViewTokenWire {
kind: ViewTokenWireKind::Text("Hello\x00World\x01End".to_string()),
source_offset: Some(0),
style: None,
},
make_newline_token(Some(15)),
];
let lines_normal: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines_normal.len(), 1);
let lines_binary: Vec<_> = ViewLineIterator::new(&tokens, true, false, 4).collect();
assert_eq!(lines_binary.len(), 1);
assert!(
lines_binary[0].text.contains("<00>"),
"Binary mode should format null byte as <00>, got: {}",
lines_binary[0].text
);
assert!(
lines_binary[0].text.contains("<01>"),
"Binary mode should format 0x01 as <01>, got: {}",
lines_binary[0].text
);
}
#[test]
fn test_binary_mode_png_header() {
let png_like = "PNG\r\n\x1A\n";
let tokens = vec![ViewTokenWire {
kind: ViewTokenWireKind::Text(png_like.to_string()),
source_offset: Some(0),
style: None,
}];
let lines: Vec<_> = ViewLineIterator::new(&tokens, true, false, 4).collect();
let combined: String = lines.iter().map(|l| l.text.as_str()).collect();
assert!(
combined.contains("<1A>"),
"PNG SUB byte (0x1A) should be rendered as <1A>, got: {:?}",
combined
);
}
#[test]
fn test_binary_mode_preserves_printable_chars() {
let tokens = vec![
ViewTokenWire {
kind: ViewTokenWireKind::Text("Normal text 123".to_string()),
source_offset: Some(0),
style: None,
},
make_newline_token(Some(15)),
];
let lines: Vec<_> = ViewLineIterator::new(&tokens, true, false, 4).collect();
assert_eq!(lines.len(), 1);
assert!(
lines[0].text.contains("Normal text 123"),
"Printable chars should be preserved in binary mode"
);
}
#[test]
fn test_double_width_visual_mappings() {
let tokens = vec![
make_text_token("你好", Some(0)),
make_newline_token(Some(6)),
];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].visual_width(),
5,
"Expected 5 visual columns (2 for 你 + 2 for 好 + 1 for newline), got {}",
lines[0].visual_width()
);
assert_eq!(
lines[0].char_source_bytes.len(),
3,
"Expected 3 char entries (你, 好, newline), got {}",
lines[0].char_source_bytes.len()
);
assert_eq!(
lines[0].source_byte_at_visual_col(0),
Some(0),
"Column 0 should map to byte 0"
);
assert_eq!(
lines[0].source_byte_at_visual_col(1),
Some(0),
"Column 1 should map to byte 0"
);
assert_eq!(
lines[0].source_byte_at_visual_col(2),
Some(3),
"Column 2 should map to byte 3"
);
assert_eq!(
lines[0].source_byte_at_visual_col(3),
Some(3),
"Column 3 should map to byte 3"
);
assert_eq!(
lines[0].source_byte_at_visual_col(4),
Some(6),
"Column 4 (newline) should map to byte 6"
);
}
#[test]
fn test_mixed_width_visual_mappings() {
let tokens = vec![
make_text_token("a你b", Some(0)),
make_newline_token(Some(5)),
];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].visual_width(),
5,
"Expected 5 visual columns, got {}",
lines[0].visual_width()
);
assert_eq!(
lines[0].char_source_bytes.len(),
4,
"Expected 4 char entries, got {}",
lines[0].char_source_bytes.len()
);
assert_eq!(
lines[0].source_byte_at_visual_col(0),
Some(0),
"Column 0 (a) should map to byte 0"
);
assert_eq!(
lines[0].source_byte_at_visual_col(1),
Some(1),
"Column 1 (你 col 1) should map to byte 1"
);
assert_eq!(
lines[0].source_byte_at_visual_col(2),
Some(1),
"Column 2 (你 col 2) should map to byte 1"
);
assert_eq!(
lines[0].source_byte_at_visual_col(3),
Some(4),
"Column 3 (b) should map to byte 4"
);
assert_eq!(
lines[0].source_byte_at_visual_col(4),
Some(5),
"Column 4 (newline) should map to byte 5"
);
}
#[test]
fn test_crlf_char_source_bytes_single_line() {
let tokens = vec![
make_text_token("abc", Some(0)),
make_newline_token(Some(3)), ];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "abc\n");
assert_eq!(
lines[0].char_source_bytes.len(),
4,
"Expected 4 chars: a, b, c, newline"
);
assert_eq!(
lines[0].char_source_bytes[0],
Some(0),
"char 'a' should map to byte 0"
);
assert_eq!(
lines[0].char_source_bytes[1],
Some(1),
"char 'b' should map to byte 1"
);
assert_eq!(
lines[0].char_source_bytes[2],
Some(2),
"char 'c' should map to byte 2"
);
assert_eq!(
lines[0].char_source_bytes[3],
Some(3),
"newline should map to byte 3 (\\r position)"
);
}
#[test]
fn test_crlf_char_source_bytes_multiple_lines() {
let tokens = vec![
make_text_token("abc", Some(0)),
make_newline_token(Some(3)), make_text_token("def", Some(5)),
make_newline_token(Some(8)), make_text_token("ghi", Some(10)),
make_newline_token(Some(13)), ];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].text, "abc\n");
assert_eq!(
lines[0].char_source_bytes,
vec![Some(0), Some(1), Some(2), Some(3)],
"Line 1 char_source_bytes mismatch"
);
assert_eq!(lines[1].text, "def\n");
assert_eq!(
lines[1].char_source_bytes,
vec![Some(5), Some(6), Some(7), Some(8)],
"Line 2 char_source_bytes mismatch - possible CRLF offset drift"
);
assert_eq!(lines[2].text, "ghi\n");
assert_eq!(
lines[2].char_source_bytes,
vec![Some(10), Some(11), Some(12), Some(13)],
"Line 3 char_source_bytes mismatch - CRLF offset drift accumulated"
);
}
#[test]
fn test_crlf_visual_to_source_mapping() {
let tokens = vec![
make_text_token("ab", Some(0)),
make_newline_token(Some(2)),
make_text_token("cd", Some(4)),
make_newline_token(Some(6)),
];
let lines: Vec<_> = ViewLineIterator::new(&tokens, false, false, 4).collect();
assert_eq!(
lines[0].source_byte_at_visual_col(0),
Some(0),
"Line 1 col 0"
);
assert_eq!(
lines[0].source_byte_at_visual_col(1),
Some(1),
"Line 1 col 1"
);
assert_eq!(
lines[0].source_byte_at_visual_col(2),
Some(2),
"Line 1 col 2 (newline)"
);
assert_eq!(
lines[1].source_byte_at_visual_col(0),
Some(4),
"Line 2 col 0"
);
assert_eq!(
lines[1].source_byte_at_visual_col(1),
Some(5),
"Line 2 col 1"
);
assert_eq!(
lines[1].source_byte_at_visual_col(2),
Some(6),
"Line 2 col 2 (newline)"
);
}
}