fmtview 0.2.1

Fast terminal formatter and viewer for JSON, JSONL, XML-compatible markup, and formatted diffs
Documentation
use super::super::WRAP_CHECKPOINT_INTERVAL_ROWS;

#[derive(Debug, Default)]
pub(in crate::viewer) struct WrapCheckpointIndex {
    pub(in crate::viewer) checkpoints: Vec<WrapCheckpoint>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::viewer) struct WrapCheckpoint {
    pub(in crate::viewer) row: usize,
    pub(in crate::viewer) start_byte: usize,
    pub(in crate::viewer) start_char: usize,
}

impl WrapCheckpointIndex {
    pub(in crate::viewer) fn start_for(&self, row_start: usize) -> WrapCheckpoint {
        self.checkpoints
            .iter()
            .rev()
            .find(|checkpoint| checkpoint.row <= row_start)
            .copied()
            .unwrap_or(WrapCheckpoint {
                row: 0,
                start_byte: 0,
                start_char: 0,
            })
    }

    pub(in crate::viewer) fn remember(&mut self, checkpoint: WrapCheckpoint) {
        if checkpoint.row == 0 || checkpoint.row % WRAP_CHECKPOINT_INTERVAL_ROWS != 0 {
            return;
        }

        match self
            .checkpoints
            .binary_search_by_key(&checkpoint.row, |existing| existing.row)
        {
            Ok(_) => {}
            Err(position) => self.checkpoints.insert(position, checkpoint),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::viewer) struct WrapRange {
    pub(in crate::viewer) start_char: usize,
    pub(in crate::viewer) end_char: usize,
    pub(in crate::viewer) start_byte: usize,
    pub(in crate::viewer) end_byte: usize,
    pub(in crate::viewer) continuation_indent: usize,
}

#[derive(Debug)]
pub(in crate::viewer) struct WrapWindow {
    pub(in crate::viewer) ranges: Vec<WrapRange>,
    pub(in crate::viewer) total_rows: Option<usize>,
}

#[cfg(test)]
pub(in crate::viewer) fn wrap_ranges(
    line: &str,
    width: usize,
    continuation_indent: usize,
    max_rows: usize,
) -> Vec<WrapRange> {
    wrap_ranges_window(line, width, continuation_indent, 0, max_rows).ranges
}

#[cfg(test)]
pub(in crate::viewer) fn wrap_ranges_window(
    line: &str,
    width: usize,
    continuation_indent: usize,
    row_start: usize,
    max_rows: usize,
) -> WrapWindow {
    wrap_ranges_window_indexed(line, width, continuation_indent, row_start, max_rows, None)
}

pub(in crate::viewer) fn wrap_ranges_window_indexed(
    line: &str,
    width: usize,
    continuation_indent: usize,
    row_start: usize,
    max_rows: usize,
    mut checkpoints: Option<&mut WrapCheckpointIndex>,
) -> WrapWindow {
    if max_rows == 0 {
        return WrapWindow {
            ranges: Vec::new(),
            total_rows: None,
        };
    }

    if line.is_empty() || width == 0 {
        return WrapWindow {
            ranges: vec![WrapRange {
                start_char: 0,
                end_char: 0,
                start_byte: 0,
                end_byte: 0,
                continuation_indent: 0,
            }],
            total_rows: Some(1),
        };
    }

    let mut ranges = Vec::new();
    let checkpoint = checkpoints
        .as_deref()
        .map(|checkpoints| checkpoints.start_for(row_start))
        .unwrap_or(WrapCheckpoint {
            row: 0,
            start_byte: 0,
            start_char: 0,
        });
    let mut start_byte = checkpoint.start_byte;
    let mut start_char = checkpoint.start_char;
    let mut row = checkpoint.row;
    let target_end = row_start.saturating_add(max_rows);
    while start_byte < line.len() {
        if let Some(checkpoints) = checkpoints.as_deref_mut() {
            checkpoints.remember(WrapCheckpoint {
                row,
                start_byte,
                start_char,
            });
        }
        let continuation = row > 0;
        let indent = if continuation {
            continuation_indent.min(width.saturating_sub(1))
        } else {
            0
        };
        let row_width = width.saturating_sub(indent).max(1);
        let (end_byte, end_char) = next_wrap_end(line, start_byte, start_char, row_width);
        if row >= row_start && row < target_end {
            ranges.push(WrapRange {
                start_char,
                end_char,
                start_byte,
                end_byte,
                continuation_indent: indent,
            });
        }
        start_byte = end_byte.max(start_byte + 1).min(line.len());
        start_char = end_char.max(start_char + 1);
        row = row.saturating_add(1);
        if row >= target_end && start_byte < line.len() {
            return WrapWindow {
                ranges,
                total_rows: None,
            };
        }
    }

    WrapWindow {
        ranges,
        total_rows: Some(row.max(1)),
    }
}

pub(in crate::viewer) fn next_wrap_end(
    line: &str,
    start_byte: usize,
    start_char: usize,
    row_width: usize,
) -> (usize, usize) {
    let hard_byte = start_byte.saturating_add(row_width.max(1)).min(line.len());
    if line.as_bytes()[start_byte..hard_byte].is_ascii() {
        return next_wrap_end_ascii(line.as_bytes(), start_byte, start_char, row_width);
    }

    let min_end = (row_width / 2).max(1);
    let mut consumed = 0_usize;
    let mut hard_end = None;
    let mut best_end = None;

    for (offset, ch) in line[start_byte..].char_indices() {
        if consumed >= row_width {
            break;
        }
        consumed += 1;
        let byte_end = start_byte + offset + ch.len_utf8();
        let char_end = start_char + consumed;
        hard_end = Some((byte_end, char_end));
        if consumed >= min_end && (ch.is_whitespace() || matches!(ch, ',' | '>' | '}' | ']' | ';'))
        {
            best_end = Some((byte_end, char_end));
        }
    }

    let Some(hard_end) = hard_end else {
        return (start_byte, start_char);
    };
    if hard_end.0 >= line.len() {
        return hard_end;
    }
    best_end.unwrap_or(hard_end)
}

pub(in crate::viewer) fn next_wrap_end_ascii(
    bytes: &[u8],
    start_byte: usize,
    start_char: usize,
    row_width: usize,
) -> (usize, usize) {
    let row_width = row_width.max(1);
    let hard_byte = start_byte.saturating_add(row_width).min(bytes.len());
    if hard_byte <= start_byte {
        return (start_byte, start_char);
    }
    if hard_byte >= bytes.len() {
        return (bytes.len(), start_char + (bytes.len() - start_byte));
    }

    let min_byte = start_byte + (row_width / 2).max(1).min(hard_byte - start_byte);
    for index in (min_byte..hard_byte).rev() {
        if is_ascii_wrap_boundary(bytes[index]) {
            let end_byte = index + 1;
            return (end_byte, start_char + (end_byte - start_byte));
        }
    }

    (hard_byte, start_char + (hard_byte - start_byte))
}

pub(in crate::viewer) fn is_ascii_wrap_boundary(byte: u8) -> bool {
    byte.is_ascii_whitespace() || matches!(byte, b',' | b'>' | b'}' | b']' | b';')
}

pub(in crate::viewer) fn continuation_indent(line: &str, width: usize) -> usize {
    if width < 8 {
        return 0;
    }

    let indent = line
        .chars()
        .take_while(|ch| ch.is_whitespace())
        .map(|ch| if ch == '\t' { 2 } else { 1 })
        .sum::<usize>()
        + 2;
    indent.min(24).min(width / 2)
}