muffintui 0.1.14

A terminal workspace that combines a file tree, editor, shell, and embedded Codex pane
Documentation
use ratatui::{
    style::{Modifier, Style},
    text::{Line, Span},
};

use crate::theme::Theme;

const KEYWORDS: &[&str] = &[
    "as",
    "async",
    "await",
    "break",
    "const",
    "continue",
    "crate",
    "else",
    "enum",
    "extern",
    "false",
    "fn",
    "for",
    "if",
    "impl",
    "in",
    "let",
    "loop",
    "match",
    "mod",
    "move",
    "mut",
    "pub",
    "ref",
    "return",
    "self",
    "Self",
    "static",
    "struct",
    "trait",
    "true",
    "type",
    "use",
    "where",
    "while",
    "yield",
    "class",
    "def",
    "elif",
    "export",
    "from",
    "function",
    "import",
    "interface",
    "package",
    "private",
    "protected",
    "public",
    "switch",
    "var",
    "void",
];

pub fn highlight_line<'a>(line: &'a str, theme: Theme) -> Line<'a> {
    let mut spans = Vec::new();
    let mut plain = String::new();
    let chars: Vec<char> = line.chars().collect();
    let mut i = 0;

    while i < chars.len() {
        if let Some(comment_start) = line_comment_start(&chars, i) {
            flush_plain(&mut spans, &mut plain, theme);
            let comment: String = chars[comment_start..].iter().collect();
            spans.push(Span::styled(comment, comment_style(theme)));
            i = chars.len();
            continue;
        }

        let ch = chars[i];

        if ch == '"' || ch == '\'' {
            flush_plain(&mut spans, &mut plain, theme);
            let (token, next) = consume_quoted(&chars, i, ch);
            spans.push(Span::styled(token, string_style(theme)));
            i = next;
            continue;
        }

        if ch.is_ascii_digit() {
            flush_plain(&mut spans, &mut plain, theme);
            let (token, next) = consume_number(&chars, i);
            spans.push(Span::styled(token, number_style(theme)));
            i = next;
            continue;
        }

        if is_ident_start(ch) {
            let (token, next) = consume_ident(&chars, i);
            if let Some(style) = classify_ident(&token, theme) {
                flush_plain(&mut spans, &mut plain, theme);
                spans.push(Span::styled(token, style));
            } else {
                plain.push_str(&token);
            }
            i = next;
            continue;
        }

        plain.push(ch);
        i += 1;
    }

    flush_plain(&mut spans, &mut plain, theme);
    Line::from(spans)
}

fn line_comment_start(chars: &[char], i: usize) -> Option<usize> {
    if i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '/' {
        return Some(i);
    }

    let is_hash_comment = chars[i] == '#' && chars[..i].iter().all(|ch| ch.is_whitespace());
    is_hash_comment.then_some(i)
}

fn consume_quoted(chars: &[char], start: usize, quote: char) -> (String, usize) {
    let mut token = String::new();
    token.push(quote);
    let mut i = start + 1;
    let mut escaped = false;

    while i < chars.len() {
        let ch = chars[i];
        token.push(ch);
        i += 1;

        if escaped {
            escaped = false;
            continue;
        }

        if ch == '\\' {
            escaped = true;
            continue;
        }

        if ch == quote {
            break;
        }
    }

    (token, i)
}

fn consume_number(chars: &[char], start: usize) -> (String, usize) {
    let mut token = String::new();
    let mut i = start;

    while i < chars.len() {
        let ch = chars[i];
        if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.') {
            token.push(ch);
            i += 1;
        } else {
            break;
        }
    }

    (token, i)
}

fn consume_ident(chars: &[char], start: usize) -> (String, usize) {
    let mut token = String::new();
    let mut i = start;

    while i < chars.len() && is_ident_continue(chars[i]) {
        token.push(chars[i]);
        i += 1;
    }

    (token, i)
}

fn classify_ident(token: &str, theme: Theme) -> Option<Style> {
    if KEYWORDS.contains(&token) {
        Some(keyword_style(theme))
    } else if token
        .chars()
        .next()
        .is_some_and(|ch| ch.is_ascii_uppercase())
    {
        Some(type_style(theme))
    } else {
        None
    }
}

fn flush_plain<'a>(spans: &mut Vec<Span<'a>>, plain: &mut String, theme: Theme) {
    if !plain.is_empty() {
        spans.push(Span::styled(std::mem::take(plain), plain_style(theme)));
    }
}

fn is_ident_start(ch: char) -> bool {
    ch.is_ascii_alphabetic() || ch == '_'
}

fn is_ident_continue(ch: char) -> bool {
    ch.is_ascii_alphanumeric() || ch == '_'
}

fn plain_style(theme: Theme) -> Style {
    Style::default().fg(theme.text)
}

fn keyword_style(theme: Theme) -> Style {
    Style::default()
        .fg(theme.border_focus)
        .add_modifier(Modifier::BOLD)
}

fn type_style(theme: Theme) -> Style {
    Style::default().fg(theme.list_highlight_fg)
}

fn string_style(theme: Theme) -> Style {
    Style::default().fg(theme.accent_info)
}

fn number_style(theme: Theme) -> Style {
    Style::default().fg(theme.accent_warn)
}

fn comment_style(theme: Theme) -> Style {
    Style::default()
        .fg(theme.muted)
        .add_modifier(Modifier::ITALIC)
}