fmtview 0.2.1

Fast terminal formatter and viewer for JSON, JSONL, XML-compatible markup, and formatted diffs
Documentation
use std::ops::Range;

use ratatui::{style::Style, text::Span};

use super::super::palette::{
    bool_style, escape_style, key_style, null_style, number_style, plain_style, punctuation_style,
    string_style,
};
use super::{
    checkpoints::HighlightCheckpointIndex,
    util::{escape_token_end, floor_char_boundary, push_span_window, take_while},
    xml::{XmlPairState, apply_xml_tag_state, highlight_xml_tag_window, looks_like_xml_tag},
};

#[cfg(test)]
pub(in crate::viewer) fn highlight_json_like(line: &str) -> Vec<Span<'static>> {
    highlight_json_like_window(line, 0, line.len(), None)
}

pub(in crate::viewer) fn highlight_json_like_window(
    line: &str,
    window_start: usize,
    window_end: usize,
    mut index: Option<&mut HighlightCheckpointIndex>,
) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    let mut cursor = 0;
    let mut value_string_state = None;
    if let Some(checkpoint_index) = index.as_deref_mut()
        && let Some(checkpoint) = checkpoint_index.json_value_before(window_start)
    {
        cursor = checkpoint.byte;
        value_string_state = Some(checkpoint.state);
    }

    while cursor < line.len() && cursor < window_end {
        if let Some(state) = value_string_state.take() {
            let (end, closed) = highlight_json_value_string_continue_window(
                line,
                cursor,
                window_end,
                state,
                window_start..window_end,
                &mut spans,
                index.as_deref_mut(),
            );
            cursor = end;
            if !closed {
                break;
            }
            continue;
        }

        let rest = &line[cursor..];
        let Some(ch) = rest.chars().next() else {
            break;
        };

        if ch.is_whitespace() {
            let end = take_while(line, cursor, char::is_whitespace);
            push_span_window(
                &mut spans,
                line,
                cursor,
                end,
                plain_style(),
                window_start,
                window_end,
            );
            cursor = end;
            continue;
        }

        if ch == '"' {
            if json_quote_starts_value(line, cursor) {
                let (end, closed) = highlight_json_string_value_window(
                    line,
                    cursor,
                    window_end,
                    window_start,
                    window_end,
                    &mut spans,
                    index.as_deref_mut(),
                );
                cursor = end;
                if !closed {
                    break;
                }
                continue;
            }

            let (end, closed) = json_string_end_until(line, cursor, window_end);
            if closed && json_string_is_key(line, end) {
                push_span_window(
                    &mut spans,
                    line,
                    cursor,
                    end,
                    key_style(),
                    window_start,
                    window_end,
                );
            } else {
                let (end, closed) = highlight_json_string_value_window(
                    line,
                    cursor,
                    window_end,
                    window_start,
                    window_end,
                    &mut spans,
                    index.as_deref_mut(),
                );
                cursor = end;
                if !closed {
                    break;
                }
                continue;
            }
            cursor = end;
            continue;
        }

        if ch == '-' || ch.is_ascii_digit() {
            let end = take_while(line, cursor, is_json_number_char);
            push_span_window(
                &mut spans,
                line,
                cursor,
                end,
                number_style(),
                window_start,
                window_end,
            );
            cursor = end;
            continue;
        }

        if let Some((word, style)) = json_keyword(rest) {
            push_span_window(
                &mut spans,
                line,
                cursor,
                cursor + word.len(),
                style,
                window_start,
                window_end,
            );
            cursor += word.len();
            continue;
        }

        if "{}[]:,".contains(ch) {
            push_span_window(
                &mut spans,
                line,
                cursor,
                cursor + ch.len_utf8(),
                punctuation_style(),
                window_start,
                window_end,
            );
            cursor += ch.len_utf8();
            continue;
        }

        push_span_window(
            &mut spans,
            line,
            cursor,
            cursor + ch.len_utf8(),
            plain_style(),
            window_start,
            window_end,
        );
        cursor += ch.len_utf8();
    }

    spans
}

pub(in crate::viewer) fn highlight_json_string_value_window(
    source: &str,
    start: usize,
    limit: usize,
    window_start: usize,
    window_end: usize,
    spans: &mut Vec<Span<'static>>,
    checkpoints: Option<&mut HighlightCheckpointIndex>,
) -> (usize, bool) {
    let inner_start = start + '"'.len_utf8();
    push_span_window(
        spans,
        source,
        start,
        inner_start,
        string_style(),
        window_start,
        window_end,
    );
    highlight_json_value_string_continue_window(
        source,
        inner_start,
        limit,
        XmlPairState::default(),
        window_start..window_end,
        spans,
        checkpoints,
    )
}

pub(in crate::viewer) fn highlight_json_value_string_continue_window(
    source: &str,
    start: usize,
    limit: usize,
    mut state: XmlPairState,
    window: Range<usize>,
    spans: &mut Vec<Span<'static>>,
    mut checkpoints: Option<&mut HighlightCheckpointIndex>,
) -> (usize, bool) {
    let window_start = window.start;
    let window_end = window.end;
    let mut index = start;
    let mut plain_start = start;
    let limit = limit.min(source.len());

    while index < limit {
        if let Some(checkpoints) = checkpoints.as_deref_mut() {
            checkpoints.remember_json_value(index, &state);
        }

        if let Some(escape_end) =
            escape_token_end(source, index).filter(|escape_end| *escape_end <= limit)
        {
            push_span_window(
                spans,
                source,
                plain_start,
                index,
                string_style(),
                window_start,
                window_end,
            );
            push_span_window(
                spans,
                source,
                index,
                escape_end,
                escape_style(),
                window_start,
                window_end,
            );
            index = escape_end;
            plain_start = index;
            continue;
        }

        let Some(ch) = source[index..limit].chars().next() else {
            break;
        };

        if ch == '"' {
            push_span_window(
                spans,
                source,
                plain_start,
                index,
                string_style(),
                window_start,
                window_end,
            );
            let end = index + ch.len_utf8();
            push_span_window(
                spans,
                source,
                index,
                end,
                string_style(),
                window_start,
                window_end,
            );
            return (end, true);
        }

        if ch == '<' {
            let rest = &source[index..limit];
            let tag_end = rest
                .find('>')
                .map(|position| index + position + 1)
                .unwrap_or(limit);
            let tag = &source[index..tag_end];
            if looks_like_xml_tag(tag) {
                push_span_window(
                    spans,
                    source,
                    plain_start,
                    index,
                    string_style(),
                    window_start,
                    window_end,
                );
                if tag_end <= window_start {
                    apply_xml_tag_state(tag, &mut state, 0);
                } else {
                    highlight_xml_tag_window(
                        source,
                        index,
                        tag_end,
                        &mut state,
                        0,
                        window_start..window_end,
                        spans,
                    );
                }
                index = tag_end;
                plain_start = index;
                continue;
            }
        }

        index += ch.len_utf8();
    }

    push_span_window(
        spans,
        source,
        plain_start,
        limit,
        string_style(),
        window_start,
        window_end,
    );
    (limit, false)
}

pub(in crate::viewer) fn json_string_end_until(
    line: &str,
    start: usize,
    limit: usize,
) -> (usize, bool) {
    if start >= line.len() {
        return (line.len(), false);
    }

    let limit = floor_char_boundary(line, limit.min(line.len()));
    let mut escaped = false;
    let mut index = (start + 1).min(limit);
    while index < limit {
        let Some(ch) = line[index..limit].chars().next() else {
            break;
        };
        if escaped {
            escaped = false;
        } else if ch == '\\' {
            escaped = true;
        } else if ch == '"' {
            return (index + ch.len_utf8(), true);
        }

        index += ch.len_utf8();
    }

    (limit, false)
}

pub(in crate::viewer) fn json_quote_starts_value(line: &str, quote_start: usize) -> bool {
    line[..quote_start]
        .chars()
        .rev()
        .find(|ch| !ch.is_whitespace())
        .is_some_and(|ch| matches!(ch, ':' | '['))
}

pub(in crate::viewer) fn json_string_is_key(line: &str, end: usize) -> bool {
    line[end..].trim_start().starts_with(':')
}

pub(in crate::viewer) fn is_json_number_char(ch: char) -> bool {
    ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.' | 'e' | 'E')
}

pub(in crate::viewer) fn json_keyword(rest: &str) -> Option<(&str, Style)> {
    for keyword in ["true", "false"] {
        if rest.starts_with(keyword) && keyword_boundary(rest, keyword.len()) {
            return Some((keyword, bool_style()));
        }
    }

    if rest.starts_with("null") && keyword_boundary(rest, "null".len()) {
        Some(("null", null_style()))
    } else {
        None
    }
}

pub(in crate::viewer) fn keyword_boundary(rest: &str, end: usize) -> bool {
    rest[end..]
        .chars()
        .next()
        .is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_')
}