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::text::Span;

use super::super::palette::{
    attr_style, error_style, plain_style, punctuation_style, xml_depth_style,
};
use super::{
    checkpoints::HighlightCheckpointIndex,
    util::{
        escaped_quoted_end, highlight_string_segment_window, push_span_window, quoted_end,
        take_while,
    },
};

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

pub(in crate::viewer) fn highlight_xml_line_window(
    line: &str,
    window_start: usize,
    window_end: usize,
    index: Option<&mut HighlightCheckpointIndex>,
) -> Vec<Span<'static>> {
    let base_depth = xml_depth_from_indent(line);
    let mut spans = Vec::new();
    highlight_inline_xml_window_indexed(
        line,
        0,
        line.len(),
        base_depth,
        window_start..window_end,
        &mut spans,
        index,
    );
    spans
}

pub(in crate::viewer) fn highlight_inline_xml_window_indexed(
    source: &str,
    start: usize,
    end: usize,
    base_depth: usize,
    window: Range<usize>,
    spans: &mut Vec<Span<'static>>,
    mut checkpoints: Option<&mut HighlightCheckpointIndex>,
) {
    let window_start = window.start;
    let window_end = window.end;
    let mut index = start;
    let mut state = XmlPairState::default();
    if let Some(checkpoints) = checkpoints.as_deref_mut()
        && let Some(checkpoint) = checkpoints.xml_line_before(window_start)
    {
        index = checkpoint.byte;
        state = checkpoint.state;
    }

    while index < end && index < window_end {
        if let Some(checkpoints) = checkpoints.as_deref_mut() {
            checkpoints.remember_xml_line(index, &state);
        }

        let rest = &source[index..end];
        if rest.starts_with('<') {
            let end = rest
                .find('>')
                .map(|position| index + position + 1)
                .unwrap_or(end);
            let tag = &source[index..end];
            if looks_like_xml_tag(tag) {
                if end <= window_start {
                    apply_xml_tag_state(tag, &mut state, base_depth);
                } else {
                    highlight_xml_tag_window(
                        source,
                        index,
                        end,
                        &mut state,
                        base_depth,
                        window_start..window_end,
                        spans,
                    );
                }
            } else if end > window_start {
                highlight_string_segment_window(
                    source,
                    index,
                    end,
                    window_start,
                    window_end,
                    spans,
                );
            }
            index = end;
        } else {
            let end = rest
                .find('<')
                .map(|position| index + position)
                .unwrap_or(end);
            if end > window_start {
                highlight_string_segment_window(
                    source,
                    index,
                    end,
                    window_start,
                    window_end,
                    spans,
                );
            }
            index = end;
        }
    }
}

pub(in crate::viewer) fn highlight_xml_tag_window(
    source: &str,
    tag_start: usize,
    end: usize,
    state: &mut XmlPairState,
    base_depth: usize,
    window: Range<usize>,
    spans: &mut Vec<Span<'static>>,
) {
    let window_start = window.start;
    let window_end = window.end;
    let mut index = 0;
    let tag = &source[tag_start..end];
    let kind = xml_tag_kind(tag);
    let name_range = xml_tag_name_range(tag);
    let name = name_range.map(|(start, end)| &tag[start..end]);
    let tag_state = apply_xml_tag_state_with_parts(state, kind, name, base_depth);

    while index < tag.len() {
        let rest = &tag[index..];
        let Some(ch) = rest.chars().next() else {
            break;
        };

        if let Some((name_start, name_end)) = name_range
            && index == name_start
        {
            let style = if tag_state.matched {
                xml_depth_style(tag_state.depth)
            } else {
                error_style()
            };
            push_span_window(
                spans,
                source,
                tag_start + name_start,
                tag_start + name_end,
                style,
                window_start,
                window_end,
            );
            index = name_end;
            continue;
        }

        if ch.is_whitespace() {
            let end = take_while(tag, index, char::is_whitespace);
            push_span_window(
                spans,
                source,
                tag_start + index,
                tag_start + end,
                plain_style(),
                window_start,
                window_end,
            );
            index = end;
            continue;
        }

        if rest.starts_with("\\\"") || rest.starts_with("\\'") {
            let quote = if rest.starts_with("\\\"") { '"' } else { '\'' };
            let end = escaped_quoted_end(tag, index, quote);
            highlight_string_segment_window(
                source,
                tag_start + index,
                tag_start + end,
                window_start,
                window_end,
                spans,
            );
            index = end;
            continue;
        }

        if ch == '"' || ch == '\'' {
            let end = quoted_end(tag, index, ch);
            highlight_string_segment_window(
                source,
                tag_start + index,
                tag_start + end,
                window_start,
                window_end,
                spans,
            );
            index = end;
            continue;
        }

        if "<>/=?!".contains(ch) {
            push_span_window(
                spans,
                source,
                tag_start + index,
                tag_start + index + ch.len_utf8(),
                punctuation_style(),
                window_start,
                window_end,
            );
            index += ch.len_utf8();
            continue;
        }

        if is_xml_name_char(ch) {
            let end = take_while(tag, index, is_xml_name_char);
            push_span_window(
                spans,
                source,
                tag_start + index,
                tag_start + end,
                attr_style(),
                window_start,
                window_end,
            );
            index = end;
            continue;
        }

        push_span_window(
            spans,
            source,
            tag_start + index,
            tag_start + index + ch.len_utf8(),
            plain_style(),
            window_start,
            window_end,
        );
        index += ch.len_utf8();
    }
}

pub(in crate::viewer) fn apply_xml_tag_state(
    tag: &str,
    state: &mut XmlPairState,
    base_depth: usize,
) -> XmlTagState {
    let kind = xml_tag_kind(tag);
    let name = xml_tag_name_range(tag).map(|(start, end)| &tag[start..end]);
    apply_xml_tag_state_with_parts(state, kind, name, base_depth)
}

pub(in crate::viewer) fn apply_xml_tag_state_with_parts(
    state: &mut XmlPairState,
    kind: XmlTagKind,
    name: Option<&str>,
    base_depth: usize,
) -> XmlTagState {
    state.apply(kind, name, base_depth)
}

#[derive(Debug, Clone, Default)]
pub(in crate::viewer) struct XmlPairState {
    stack: Vec<XmlOpenTag>,
}

#[derive(Debug, Clone)]
pub(in crate::viewer) struct XmlOpenTag {
    name: String,
    depth: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::viewer) enum XmlTagKind {
    Open,
    Close,
    SelfClosing,
    Other,
}

#[derive(Debug, Clone, Copy)]
pub(in crate::viewer) struct XmlTagState {
    depth: usize,
    matched: bool,
}

impl XmlPairState {
    fn apply(&mut self, kind: XmlTagKind, name: Option<&str>, base_depth: usize) -> XmlTagState {
        match (kind, name) {
            (XmlTagKind::Open, Some(name)) => {
                let depth = base_depth + self.stack.len();
                self.stack.push(XmlOpenTag {
                    name: name.to_owned(),
                    depth,
                });
                XmlTagState {
                    depth,
                    matched: true,
                }
            }
            (XmlTagKind::SelfClosing, Some(_)) => XmlTagState {
                depth: base_depth + self.stack.len(),
                matched: true,
            },
            (XmlTagKind::Close, Some(name)) => match self.stack.pop() {
                Some(open) if open.name == name => XmlTagState {
                    depth: open.depth,
                    matched: true,
                },
                Some(open) => {
                    self.stack.push(open);
                    XmlTagState {
                        depth: base_depth + self.stack.len() - 1,
                        matched: false,
                    }
                }
                None => XmlTagState {
                    depth: base_depth,
                    matched: false,
                },
            },
            _ => XmlTagState {
                depth: base_depth + self.stack.len(),
                matched: true,
            },
        }
    }
}

pub(in crate::viewer) fn looks_like_xml_tag(tag: &str) -> bool {
    tag.starts_with("</")
        || tag.starts_with("<?")
        || tag.starts_with("<!")
        || xml_tag_name_range(tag).is_some()
}

pub(in crate::viewer) fn xml_tag_kind(tag: &str) -> XmlTagKind {
    if tag.starts_with("</") {
        XmlTagKind::Close
    } else if tag.starts_with("<?") || tag.starts_with("<!") {
        XmlTagKind::Other
    } else if tag.trim_end_matches('>').trim_end().ends_with('/') {
        XmlTagKind::SelfClosing
    } else {
        XmlTagKind::Open
    }
}

pub(in crate::viewer) fn xml_tag_name_range(tag: &str) -> Option<(usize, usize)> {
    let mut index = if tag.starts_with("</") { 2 } else { 1 };
    while index < tag.len() {
        let ch = tag[index..].chars().next()?;
        if !ch.is_whitespace() {
            break;
        }
        index += ch.len_utf8();
    }

    let start = index;
    let end = take_while(tag, start, is_xml_name_char);
    (end > start).then_some((start, end))
}

pub(in crate::viewer) fn xml_depth_from_indent(line: &str) -> usize {
    line.chars()
        .take_while(|ch| ch.is_whitespace())
        .map(|ch| if ch == '\t' { 2 } else { 1 })
        .sum::<usize>()
        / 2
}

pub(in crate::viewer) fn is_xml_name_char(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.')
}