elio 1.0.0

Terminal-native file manager with rich previews, inline images, and mouse support.
Documentation
use super::{looks_numeric, split_unquoted_once, styled_text};
use crate::preview::appearance as theme;
use ratatui::{style::Color, style::Modifier, text::Span};

pub(super) fn highlight_log_line(
    line: &str,
    palette: theme::CodePreviewPalette,
) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    let trimmed = line.trim_start();
    let indent = &line[..line.len().saturating_sub(trimmed.len())];
    spans.push(Span::raw(indent.to_string()));

    let mut rest = trimmed;
    if let Some((timestamp, remaining)) = split_log_timestamp(rest) {
        spans.push(styled_text(timestamp, palette.comment, Modifier::empty()));
        rest = remaining;
        if let Some((whitespace, remaining)) = split_leading_whitespace(rest) {
            spans.push(Span::raw(whitespace.to_string()));
            rest = remaining;
        }
    }

    if let Some((level, remaining)) = split_log_level(rest) {
        spans.push(styled_text(
            level,
            log_level_color(level, palette),
            Modifier::BOLD,
        ));
        rest = remaining;
        if let Some(space_end) = rest
            .char_indices()
            .find(|(_, ch)| !ch.is_whitespace())
            .map(|(index, _)| index)
        {
            spans.push(Span::raw(rest[..space_end].to_string()));
            rest = &rest[space_end..];
        } else {
            spans.push(Span::raw(rest.to_string()));
            return spans;
        }
    }

    spans.extend(highlight_log_message(rest, palette));
    spans
}

fn highlight_log_message(line: &str, palette: theme::CodePreviewPalette) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    let mut current = String::new();

    for token in line.split_inclusive(char::is_whitespace) {
        let word = token.trim_end_matches(char::is_whitespace);
        let suffix = &token[word.len()..];

        if word.is_empty() {
            current.push_str(token);
            continue;
        }

        let styled = if let Some((left, right)) = split_unquoted_once(word, '=') {
            if !current.is_empty() {
                spans.push(Span::raw(std::mem::take(&mut current)));
            }
            spans.push(styled_text(left, palette.parameter, Modifier::BOLD));
            spans.push(styled_text("=", palette.operator, Modifier::empty()));
            spans.extend(super::data::highlight_value_fragment(right, palette));
            if !suffix.is_empty() {
                spans.push(Span::raw(suffix.to_string()));
            }
            continue;
        } else if looks_numeric(word.trim_matches(['[', ']', '(', ')', ',', ';'])) {
            Some(styled_text(word, palette.constant, Modifier::empty()))
        } else if word.starts_with('[') && word.ends_with(']') {
            Some(styled_text(word, palette.r#type, Modifier::empty()))
        } else if word.ends_with(':') && word.len() > 1 {
            Some(styled_text(word, palette.function, Modifier::empty()))
        } else {
            None
        };

        if let Some(span) = styled {
            if !current.is_empty() {
                spans.push(Span::raw(std::mem::take(&mut current)));
            }
            spans.push(span);
            if !suffix.is_empty() {
                spans.push(Span::raw(suffix.to_string()));
            }
        } else {
            current.push_str(token);
        }
    }

    if !current.is_empty() {
        spans.push(Span::raw(current));
    }

    spans
}

fn split_log_timestamp(input: &str) -> Option<(&str, &str)> {
    let mut end = 0usize;
    let mut separators = 0usize;

    for (index, ch) in input.char_indices() {
        if ch.is_ascii_digit() || matches!(ch, '-' | ':' | 'T' | 'Z' | '.' | '+' | '/' | ',') {
            end = index + ch.len_utf8();
            if matches!(ch, '-' | ':' | 'T' | '/') {
                separators += 1;
            }
            continue;
        }
        break;
    }

    if end == 0 || separators < 2 {
        return None;
    }

    Some((&input[..end], &input[end..]))
}

fn split_leading_whitespace(input: &str) -> Option<(&str, &str)> {
    let end = input
        .char_indices()
        .find(|(_, ch)| !ch.is_whitespace())
        .map(|(index, _)| index)
        .unwrap_or(input.len());
    if end == 0 {
        None
    } else {
        Some((&input[..end], &input[end..]))
    }
}

fn split_log_level(input: &str) -> Option<(&str, &str)> {
    let trimmed = input.trim_start();
    let offset = input.len().saturating_sub(trimmed.len());
    let (start, first) = trimmed.char_indices().next()?;

    let (level, consumed) = if first == '[' {
        let end = trimmed.find(']')?;
        (&trimmed[start..=end], end + 1)
    } else {
        let end = trimmed
            .char_indices()
            .find(|(_, ch)| ch.is_whitespace() || matches!(ch, ':' | ',' | ';'))
            .map(|(index, _)| index)
            .unwrap_or(trimmed.len());
        (&trimmed[..end], end)
    };

    let normalized = level
        .trim_matches(|ch| matches!(ch, '[' | ']'))
        .to_ascii_uppercase();
    if !matches!(
        normalized.as_str(),
        "TRACE" | "DEBUG" | "INFO" | "NOTICE" | "WARN" | "WARNING" | "ERROR" | "ERR" | "FATAL"
    ) {
        return None;
    }

    Some((
        &input[offset..offset + consumed],
        &input[offset + consumed..],
    ))
}

fn log_level_color(level: &str, palette: theme::CodePreviewPalette) -> Color {
    match level
        .trim_matches(|ch| matches!(ch, '[' | ']'))
        .to_ascii_uppercase()
        .as_str()
    {
        "TRACE" => palette.comment,
        "DEBUG" => palette.constant,
        "INFO" | "NOTICE" => palette.function,
        "WARN" | "WARNING" => palette.keyword,
        "ERROR" | "ERR" | "FATAL" => palette.invalid,
        _ => palette.fg,
    }
}