ztheme 1.1.0

Fast asynchronous Zsh prompt
use std::collections::HashSet;
use std::io;

use crate::context::Runtime;

use super::{
    MAX_LAYOUT_LINES, MAX_LAYOUT_SEGMENTS, MAX_SEGMENT_SPACING, SegmentId, Spacing, Theme,
    ValidatedLayout, contains_control, git_symbol_values, highlight_style, invalid, palette_color,
    style_open, valid_color, valid_identifier, valid_syntax_style_name,
};

pub(super) fn validate(theme: &Theme) -> io::Result<ValidatedLayout> {
    for name in theme.segments.runtimes.keys() {
        if Runtime::from_name(name).is_none() {
            return Err(invalid(format!("unknown segment configuration `{name}`")));
        }
    }
    for runtime in Runtime::ALL {
        if !theme.segments.runtimes.contains_key(runtime.name()) {
            return Err(invalid(format!("missing segments.{}", runtime.name())));
        }
    }
    for (name, color) in &theme.palette {
        if !valid_identifier(name) {
            return Err(invalid(format!("invalid palette name `{name}`")));
        }
        if !valid_color(color) {
            return Err(invalid(format!(
                "palette `{name}` must be an RGB color such as #89b4fa"
            )));
        }
    }
    for required in ["accent", "muted", "success", "error"] {
        palette_color(theme, required)?;
    }

    validate_theme_literals(theme)?;
    validate_styles(theme)?;
    validate_spacing(theme)?;

    let width = &theme.segments.directory.width;
    if !(1..=100).contains(&width.percent) || width.minimum == 0 || width.minimum > width.maximum {
        return Err(invalid(
            "directory width requires percent 1-100 and 0 < minimum <= maximum",
        ));
    }

    if theme.layout.lines.is_empty() || theme.layout.lines.len() > MAX_LAYOUT_LINES {
        return Err(invalid("layout.lines must contain between 1 and 8 lines"));
    }

    let mut seen = HashSet::new();
    let mut lines = Vec::with_capacity(theme.layout.lines.len());
    let mut asynchronous = Vec::new();
    let mut total = 0;
    for line in &theme.layout.lines {
        if line.is_empty() {
            return Err(invalid("layout lines cannot be empty"));
        }
        let mut parsed = Vec::with_capacity(line.len());
        for name in line {
            let segment = parse_layout_segment(name, theme)?;
            if !seen.insert(segment) {
                return Err(invalid(format!("segment `{name}` appears more than once")));
            }
            if segment.is_async() {
                asynchronous.push(segment);
            }
            parsed.push(segment);
            total += 1;
        }
        lines.push(parsed);
    }

    let mut right = Vec::with_capacity(theme.layout.right.len());
    for name in &theme.layout.right {
        let segment = parse_layout_segment(name, theme)?;
        if !segment.is_right_safe() {
            return Err(invalid(format!(
                "segment `{name}` is not allowed in the right prompt"
            )));
        }
        if !seen.insert(segment) {
            return Err(invalid(format!("segment `{name}` appears more than once")));
        }
        right.push(segment);
        total += 1;
    }
    if total > MAX_LAYOUT_SEGMENTS {
        return Err(invalid("layout contains more than 64 segments"));
    }

    if let Some((line_index, item_index)) = find_segment(&lines, SegmentId::Character)
        && (line_index + 1 != lines.len() || item_index + 1 != lines[line_index].len())
    {
        return Err(invalid(
            "`character` must be the final segment of the final left line",
        ));
    }

    Ok(ValidatedLayout {
        lines,
        right,
        asynchronous,
    })
}

fn parse_layout_segment(name: &str, theme: &Theme) -> io::Result<SegmentId> {
    let segment =
        SegmentId::parse(name).ok_or_else(|| invalid(format!("unknown segment `{name}`")))?;
    if let SegmentId::Runtime(runtime) = segment
        && !theme.segments.runtimes.contains_key(runtime.name())
    {
        return Err(invalid(format!(
            "missing configuration for runtime segment `{name}`"
        )));
    }
    Ok(segment)
}

fn find_segment(lines: &[Vec<SegmentId>], needle: SegmentId) -> Option<(usize, usize)> {
    lines.iter().enumerate().find_map(|(line_index, line)| {
        line.iter()
            .position(|segment| *segment == needle)
            .map(|item_index| (line_index, item_index))
    })
}

fn validate_theme_literals(theme: &Theme) -> io::Result<()> {
    let directory = &theme.segments.directory;
    let git = &theme.segments.git;
    let character = &theme.segments.character;
    let status = &theme.segments.status;
    let mut values = vec![
        ("layout.separator", theme.layout.separator.as_str()),
        ("directory.prefix", directory.prefix.as_str()),
        ("directory.suffix", directory.suffix.as_str()),
        ("directory.home_symbol", directory.home_symbol.as_str()),
        (
            "directory.truncation_symbol",
            directory.truncation_symbol.as_str(),
        ),
        ("git.prefix", git.prefix.as_str()),
        ("git.suffix", git.suffix.as_str()),
        ("git.symbol", git.symbol.as_str()),
        ("git.action_prefix", git.action_prefix.as_str()),
        ("git.action_suffix", git.action_suffix.as_str()),
        ("git.changes_prefix", git.changes_prefix.as_str()),
        ("character.prefix", character.prefix.as_str()),
        ("character.suffix", character.suffix.as_str()),
        ("character.success", character.success.as_str()),
        ("character.error", character.error.as_str()),
        ("status.prefix", status.prefix.as_str()),
        ("status.suffix", status.suffix.as_str()),
        ("status.success_symbol", status.success_symbol.as_str()),
    ];
    for (name, value) in git_symbol_values(&git.symbols) {
        values.push((name, value));
    }
    for (name, runtime) in &theme.segments.runtimes {
        for (field, value) in [
            ("prefix", runtime.prefix.as_str()),
            ("suffix", runtime.suffix.as_str()),
            ("symbol", runtime.symbol.as_str()),
            ("version_prefix", runtime.version_prefix.as_str()),
            ("environment.prefix", runtime.environment.prefix.as_str()),
            ("environment.suffix", runtime.environment.suffix.as_str()),
        ] {
            if contains_control(value) {
                return Err(invalid(format!(
                    "segments.{name}.{field} contains a control character"
                )));
            }
        }
    }
    if let Some((name, _)) = values
        .into_iter()
        .find(|(_, value)| contains_control(value))
    {
        return Err(invalid(format!("{name} contains a control character")));
    }
    Ok(())
}

fn validate_styles(theme: &Theme) -> io::Result<()> {
    let segments = &theme.segments;
    for style in [
        &theme.input.autosuggestion,
        &theme.input.completion,
        &segments.directory.style,
        &segments.git.style,
        &segments.git.action_style,
        &segments.git.styles.conflicted,
        &segments.git.styles.staged,
        &segments.git.styles.modified,
        &segments.git.styles.deleted,
        &segments.git.styles.untracked,
        &segments.git.styles.ahead,
        &segments.git.styles.behind,
        &segments.git.styles.diverged,
        &segments.git.styles.stash,
        &segments.character.success_style,
        &segments.character.error_style,
        &segments.status.style,
        &segments.status.success_style,
    ] {
        style_open(style, theme)?;
    }
    for runtime in segments.runtimes.values() {
        style_open(&runtime.style, theme)?;
        style_open(&runtime.environment.style, theme)?;
    }
    for (name, style) in &theme.input.syntax {
        if !valid_syntax_style_name(name) {
            return Err(invalid(format!(
                "unknown zsh-syntax-highlighting style `{name}`"
            )));
        }
        highlight_style(style, theme)?;
    }
    Ok(())
}

fn validate_spacing(theme: &Theme) -> io::Result<()> {
    let segments = &theme.segments;
    for (name, spacing) in [
        ("directory", segments.directory.spacing),
        ("git", segments.git.spacing),
        ("character", segments.character.spacing),
        ("status", segments.status.spacing),
    ] {
        validate_segment_spacing(name, spacing)?;
    }
    for (name, runtime) in &segments.runtimes {
        validate_segment_spacing(name, runtime.spacing)?;
    }
    Ok(())
}

fn validate_segment_spacing(name: &str, spacing: Spacing) -> io::Result<()> {
    if spacing.before > MAX_SEGMENT_SPACING || spacing.after > MAX_SEGMENT_SPACING {
        return Err(invalid(format!(
            "segments.{name}.spacing values cannot exceed {MAX_SEGMENT_SPACING}"
        )));
    }
    Ok(())
}