cuj-tui 0.1.0

cui — a read-only TUI browser for cuj vaults
Documentation
//! Jot Syntax highlighting for the content pane. The `jots` parser
//! is the authority on which constructs exist (cuj SPEC §2.1
//! delegates rendering to the Jot Syntax implementation): construct
//! spans come from the parser's byte-offset `loc` fields, and tag /
//! category occurrences — which carry no locs in the parser output —
//! are located by scanning the text for the parsed names. Raw text
//! is never rewritten; only styling is added.

use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};

use cuj::facts::ProfileConfig;

/// A jot's content prepared for display.
pub struct Rendered {
    pub raw: String,
    pub lines: Vec<Line<'static>>,
}

#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Kind {
    Tag,
    Category,
    Reference,
    Url,
    Bookmark,
    Todo,
    DoneTodo,
}

fn style(kind: Kind) -> Style {
    match kind {
        Kind::Tag => Style::new().fg(Color::Yellow),
        Kind::Category => Style::new().fg(Color::Green),
        Kind::Reference => Style::new().fg(Color::Cyan),
        Kind::Url => Style::new()
            .fg(Color::Blue)
            .add_modifier(Modifier::UNDERLINED),
        Kind::Bookmark => Style::new().fg(Color::Magenta),
        Kind::Todo => Style::new().fg(Color::LightRed),
        Kind::DoneTodo => Style::new().add_modifier(Modifier::DIM),
    }
}

/// Highlight `text` under a profile's parser configuration.
pub fn render(text: &str, profile: &ProfileConfig) -> Rendered {
    let result = cuj::binding::parse(text, profile);

    // Byte-level paint buffer: later paints override earlier ones,
    // so wide todo spans go down first and the narrower constructs
    // inside them (references, tags, …) win locally.
    let mut paint: Vec<Option<Kind>> = vec![None; text.len()];
    let mut mark = |loc: &Option<jots::Loc>, kind: Kind| {
        if let Some(l) = loc {
            for slot in paint.iter_mut().take(l.end.min(text.len())).skip(l.start) {
                *slot = Some(kind);
            }
        }
    };

    for t in &result.todos {
        mark(&t.loc, Kind::Todo);
    }
    for t in &result.done_todos {
        mark(&t.loc, Kind::DoneTodo);
    }
    for t in result.todos.iter().chain(&result.done_todos) {
        for r in &t.references {
            mark(&r.loc, Kind::Reference);
        }
        for r in &t.cross_references {
            mark(&r.loc, Kind::Reference);
        }
        for u in &t.urls {
            mark(&u.loc, Kind::Url);
        }
    }
    for r in &result.references {
        mark(&r.loc, Kind::Reference);
    }
    for r in &result.cross_references {
        mark(&r.loc, Kind::Reference);
    }
    for r in &result.file_references {
        mark(&r.loc, Kind::Reference);
    }
    for r in &result.cross_file_references {
        mark(&r.loc, Kind::Reference);
    }
    for r in &result.resources {
        mark(&r.loc, Kind::Reference);
    }
    for r in &result.inter_jot_resources {
        mark(&r.loc, Kind::Reference);
    }
    for u in &result.urls {
        mark(&u.loc, Kind::Url);
    }
    for b in &result.bookmarks {
        mark(&b.loc, Kind::Bookmark);
    }

    // Tags and categories: paint every textual occurrence of each
    // parsed name (todo-scoped ones included — occurrences inside a
    // todo span override its wash).
    let mut names: Vec<(String, Kind)> = Vec::new();
    let mut collect = |tags: &[String], cats: &[String]| {
        for t in tags {
            names.push((format!("..{t}"), Kind::Tag));
        }
        for c in cats {
            names.push((format!("::{c}"), Kind::Category));
        }
    };
    collect(&result.tags, &result.categories);
    for t in result.todos.iter().chain(&result.done_todos) {
        collect(&t.tags, &t.categories);
    }
    names.sort();
    names.dedup();
    for (needle, kind) in &names {
        for start in occurrences(text, needle) {
            for slot in paint.iter_mut().skip(start).take(needle.len()) {
                *slot = Some(*kind);
            }
        }
    }

    let lines = to_lines(text, &paint);
    Rendered {
        raw: text.to_string(),
        lines,
    }
}

/// Byte offsets of boundary-respecting occurrences of `needle`
/// (a `..tag` or `::cat::path` form) in `text`.
fn occurrences(text: &str, needle: &str) -> Vec<usize> {
    let bytes = text.as_bytes();
    let token = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b':' || b == b'.';
    let mut out = Vec::new();
    let mut from = 0;
    while let Some(i) = text[from..].find(needle) {
        let start = from + i;
        let end = start + needle.len();
        let left_ok = start == 0 || !token(bytes[start - 1]);
        let right_ok = end >= bytes.len() || !token(bytes[end]);
        if left_ok && right_ok {
            out.push(start);
        }
        from = start + 1;
    }
    out
}

/// Split painted text into per-line span runs. Paint marks come
/// from parser byte offsets and needle matches, so run boundaries
/// always fall on UTF-8 character boundaries.
fn to_lines(text: &str, paint: &[Option<Kind>]) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    let mut offset = 0;
    for raw_line in text.split('\n') {
        let mut spans: Vec<Span<'static>> = Vec::new();
        let mut run_start = 0;
        let mut run_kind: Option<Kind> = None;
        let line_paint = &paint[offset..offset + raw_line.len()];
        for (i, k) in line_paint.iter().enumerate() {
            if *k != run_kind {
                if i > run_start {
                    spans.push(span(&raw_line[run_start..i], run_kind));
                }
                run_start = i;
                run_kind = *k;
            }
        }
        if raw_line.len() > run_start {
            spans.push(span(&raw_line[run_start..], run_kind));
        }
        lines.push(Line::from(spans));
        offset += raw_line.len() + 1;
    }
    // split('\n') yields one trailing empty line for newline-
    // terminated text; drop it so line counts match the content.
    if text.ends_with('\n') {
        lines.pop();
    }
    lines
}

fn span(s: &str, kind: Option<Kind>) -> Span<'static> {
    match kind {
        Some(k) => Span::styled(s.to_string(), style(k)),
        None => Span::raw(s.to_string()),
    }
}