use crate::services::plugins::api::{ViewTokenStyle, ViewTokenWire, ViewTokenWireKind};
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct ViewLine {
pub text: String,
pub char_mappings: Vec<Option<usize>>,
pub char_styles: Vec<Option<ViewTokenStyle>>,
pub tab_starts: HashSet<usize>,
pub line_start: LineStart,
pub ends_with_newline: bool,
}
#[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 const TAB_WIDTH: usize = 8;
fn tab_expansion_width(col: usize) -> usize {
TAB_WIDTH - (col % TAB_WIDTH)
}
pub struct ViewLineIterator<'a> {
tokens: &'a [ViewTokenWire],
token_idx: usize,
next_line_start: LineStart,
binary_mode: bool,
}
impl<'a> ViewLineIterator<'a> {
pub fn new(tokens: &'a [ViewTokenWire]) -> Self {
Self {
tokens,
token_idx: 0,
next_line_start: LineStart::Beginning,
binary_mode: false,
}
}
pub fn with_binary_mode(tokens: &'a [ViewTokenWire], binary: bool) -> Self {
Self {
tokens,
token_idx: 0,
next_line_start: LineStart::Beginning,
binary_mode: binary,
}
}
}
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_mappings = Vec::new();
let mut char_styles = Vec::new();
let mut tab_starts = HashSet::new();
let mut col = 0usize;
let mut ends_with_newline = false;
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() {
text.push(display_ch);
char_mappings.push(source);
char_styles.push(token_style.clone());
col += 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() {
text.push(display_ch);
char_mappings.push(source);
char_styles.push(token_style.clone());
col += 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 = text.len();
tab_starts.insert(tab_start_pos);
let spaces = tab_expansion_width(col);
for _ in 0..spaces {
text.push(' ');
char_mappings.push(source);
char_styles.push(token_style.clone());
}
col += spaces;
} else {
text.push(ch);
char_mappings.push(source);
char_styles.push(token_style.clone());
col += 1;
}
}
self.token_idx += 1;
}
ViewTokenWireKind::Space => {
text.push(' ');
char_mappings.push(token.source_offset);
char_styles.push(token_style);
col += 1;
self.token_idx += 1;
}
ViewTokenWireKind::Newline => {
text.push('\n');
char_mappings.push(token.source_offset);
char_styles.push(token_style);
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 => {
text.push('\n');
char_mappings.push(None);
char_styles.push(None);
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() {
text.push(display_ch);
char_mappings.push(token.source_offset);
char_styles.push(token_style.clone());
col += 1;
}
self.token_idx += 1;
}
}
}
if text.is_empty() && self.token_idx >= self.tokens.len() {
return None;
}
Some(ViewLine {
text,
char_mappings,
char_styles,
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_mappings.is_empty() {
return matches!(
line.line_start,
LineStart::Beginning | LineStart::AfterSourceNewline
);
}
let first_char_is_source = line
.char_mappings
.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_mappings.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>) -> Self {
let lines: Vec<ViewLine> = ViewLineIterator::new(tokens).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 (col, mapping) in line.char_mappings.iter().enumerate() {
if *mapping == Some(byte) {
return Some((line_idx, col));
}
}
return Some((line_idx, line.char_mappings.len()));
}
}
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.char_mappings.len() {
line.char_mappings[col]
} else if !line.char_mappings.is_empty() {
line.char_mappings.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_mappings.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).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).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).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_mappings
.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).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).collect();
assert_eq!(lines_normal.len(), 1);
let lines_binary: Vec<_> = ViewLineIterator::with_binary_mode(&tokens, true).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::with_binary_mode(&tokens, true).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::with_binary_mode(&tokens, true).collect();
assert_eq!(lines.len(), 1);
assert!(
lines[0].text.contains("Normal text 123"),
"Printable chars should be preserved in binary mode"
);
}
}