oy-cli 0.8.5

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use std::borrow::Cow;
use std::fmt::Write as _;
use std::sync::LazyLock;

use syntect::easy::HighlightLines;
use syntect::highlighting::{Theme, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::as_24_bit_terminal_escaped;
use unicode_width::UnicodeWidthChar;

use super::text::{ansi_stripped_width, truncate_width};
use super::{bold, color_enabled, cyan, faint, green, path, red, terminal_width};

pub fn markdown(text: &str) {
    super::out(&render_markdown(text));
}

fn render_markdown(text: &str) -> String {
    if !color_enabled() {
        return text.to_string();
    }
    let mut in_fence = false;
    let mut out = String::new();
    for line in text.lines() {
        let trimmed = line.trim_start();
        let rendered = if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
            in_fence = !in_fence;
            faint(line)
        } else if in_fence {
            cyan(line)
        } else if trimmed.starts_with('#') {
            super::paint("1;35", line)
        } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
            cyan(line)
        } else {
            line.to_string()
        };
        let _ = writeln!(out, "{rendered}");
    }
    if text.ends_with('\n') {
        out
    } else {
        out.trim_end_matches('\n').to_string()
    }
}

pub fn code(path: &str, text: &str, first_line: usize) -> String {
    numbered_block(path, &normalize_code_preview_text(text), first_line)
}

pub fn text_block(title: &str, text: &str) -> String {
    numbered_block(title, text, 1)
}

pub fn block_title(title: &str) -> String {
    path(format_args!("── {title}"))
}

#[cfg(test)]
fn numbered_line(line_number: usize, width: usize, text: &str) -> String {
    numbered_line_with_max_width(line_number, width, text, usize::MAX)
}

fn numbered_line_with_max_width(
    line_number: usize,
    width: usize,
    text: &str,
    max_width: usize,
) -> String {
    let text = normalize_code_preview_text(text);
    let prefix = format!(
        "{} {} ",
        faint(format_args!("{line_number:>width$}")),
        faint("")
    );
    let available = max_width
        .saturating_sub(ansi_stripped_width(&prefix))
        .max(1);
    format!("{prefix}{}", truncate_width(&text, available))
}

fn normalize_code_preview_text(text: &str) -> Cow<'_, str> {
    const TAB_WIDTH: usize = 4;
    if !text.contains('\t') {
        return Cow::Borrowed(text);
    }

    let mut out = String::with_capacity(text.len());
    let mut column = 0usize;
    for ch in text.chars() {
        match ch {
            '\t' => {
                let spaces = TAB_WIDTH - (column % TAB_WIDTH);
                out.extend(std::iter::repeat_n(' ', spaces));
                column += spaces;
            }
            '\n' | '\r' => {
                out.push(ch);
                column = 0;
            }
            _ => {
                out.push(ch);
                column += UnicodeWidthChar::width(ch).unwrap_or(0);
            }
        }
    }
    Cow::Owned(out)
}

fn numbered_block(title: &str, text: &str, first_line: usize) -> String {
    let title = if title.is_empty() { "text" } else { title };
    let line_count = text.lines().count().max(1);
    let width = first_line
        .saturating_add(line_count.saturating_sub(1))
        .max(1)
        .to_string()
        .len();
    let max_width = terminal_width().saturating_sub(4).max(40);
    let code_width = max_width.saturating_sub(width + 3).max(1);
    let mut out = String::new();
    let _ = writeln!(out, "{}", truncate_width(&block_title(title), max_width));
    if text.is_empty() {
        let _ = writeln!(
            out,
            "{}",
            numbered_line_with_max_width(first_line, width, "", max_width)
        );
    } else {
        let display_text = text
            .lines()
            .map(|line| truncate_width(line, code_width))
            .collect::<Vec<_>>()
            .join("\n");
        let highlighted = highlighted_block(title, &display_text);
        let lines = highlighted.as_deref().unwrap_or(&display_text).lines();
        for (idx, line) in lines.enumerate() {
            let _ = writeln!(
                out,
                "{}",
                numbered_line_with_max_width(first_line + idx, width, line, max_width)
            );
        }
    }
    out.trim_end().to_string()
}

static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);

fn highlighted_block(title: &str, text: &str) -> Option<String> {
    if !color_enabled() {
        return None;
    }
    let syntax = syntax_for_title(title)?;
    let theme = terminal_theme()?;
    let mut highlighter = HighlightLines::new(syntax, theme);
    let mut out = String::new();
    for line in text.lines() {
        let ranges = highlighter.highlight_line(line, &SYNTAX_SET).ok()?;
        let _ = writeln!(out, "{}", as_24_bit_terminal_escaped(&ranges, false));
    }
    Some(if text.ends_with('\n') {
        out
    } else {
        out.trim_end_matches('\n').to_string()
    })
}

fn syntax_for_title(title: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
    let syntaxes = &*SYNTAX_SET;
    let name = title.rsplit('/').next().unwrap_or(title);
    if let Some(ext) = name.rsplit_once('.').map(|(_, ext)| ext) {
        syntaxes.find_syntax_by_extension(ext)
    } else {
        syntaxes.find_syntax_by_token(name)
    }
    .or_else(|| syntaxes.find_syntax_by_name(title))
}

fn terminal_theme() -> Option<&'static Theme> {
    THEME_SET
        .themes
        .get("base16-ocean.dark")
        .or_else(|| THEME_SET.themes.values().next())
}

pub fn diff(text: &str) -> String {
    if !color_enabled() {
        return text.to_string();
    }
    let mut out = String::new();
    for line in text.lines() {
        let rendered = if line.starts_with("+++") || line.starts_with("---") {
            bold(line)
        } else if line.starts_with("@@") {
            cyan(line)
        } else if line.starts_with('+') {
            green(line)
        } else if line.starts_with('-') {
            red(line)
        } else {
            line.to_string()
        };
        let _ = writeln!(out, "{rendered}");
    }
    if text.ends_with('\n') {
        out
    } else {
        out.trim_end_matches('\n').to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ui::{OutputMode, set_output_mode};
    use unicode_width::UnicodeWidthStr;

    #[test]
    fn numbered_line_expands_tabs_to_stable_columns() {
        set_output_mode(OutputMode::Normal);
        assert_eq!(numbered_line(7, 1, "\tlet x = 1;"), "7 │     let x = 1;");
        assert_eq!(numbered_line(8, 1, "ab\tcd"), "8 │ ab  cd");
        assert_eq!(
            code("demo.rs", "\tfn main() {}\n\t\tprintln!(\"hi\");", 1),
            "── demo.rs\n1 │     fn main() {}\n2 │         println!(\"hi\");"
        );
    }

    #[test]
    fn numbered_line_clamps_long_read_lines_to_preview_width() {
        set_output_mode(OutputMode::Normal);
        let line = numbered_line_with_max_width(
            394,
            3,
            r#"        .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
            40,
        );
        assert!(UnicodeWidthStr::width(line.as_str()) <= 40, "{line}");
        assert!(line.starts_with("394 │ "));
        assert!(line.ends_with(''));
        assert!(!line.contains('\n'));
    }

    #[test]
    fn code_preview_lines_fit_tool_result_indent_width() {
        set_output_mode(OutputMode::Normal);
        let preview = code(
            "src/audit.rs",
            r#"pub(crate) fn with_transparency_line(report: &str, snippet: &str) -> String {
    .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
            390,
        );
        let max_width = terminal_width().saturating_sub(4).max(40);
        for line in preview.lines() {
            assert!(
                UnicodeWidthStr::width(line) <= max_width,
                "line exceeded {max_width}: {line}"
            );
        }
    }
}