Skip to main content

cui/
highlight.rs

1//! Jot Syntax highlighting for the content pane. The `jots` parser
2//! is the authority on which constructs exist (cuj SPEC §2.1
3//! delegates rendering to the Jot Syntax implementation): construct
4//! spans come from the parser's byte-offset `loc` fields, and tag /
5//! category occurrences — which carry no locs in the parser output —
6//! are located by scanning the text for the parsed names. Raw text
7//! is never rewritten; only styling is added.
8
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::text::{Line, Span};
11
12use cuj::facts::ProfileConfig;
13
14/// A jot's content prepared for display.
15pub struct Rendered {
16    pub raw: String,
17    pub lines: Vec<Line<'static>>,
18}
19
20#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21enum Kind {
22    Tag,
23    Category,
24    Reference,
25    Url,
26    Bookmark,
27    Todo,
28    DoneTodo,
29}
30
31fn style(kind: Kind) -> Style {
32    match kind {
33        Kind::Tag => Style::new().fg(Color::Yellow),
34        Kind::Category => Style::new().fg(Color::Green),
35        Kind::Reference => Style::new().fg(Color::Cyan),
36        Kind::Url => Style::new()
37            .fg(Color::Blue)
38            .add_modifier(Modifier::UNDERLINED),
39        Kind::Bookmark => Style::new().fg(Color::Magenta),
40        Kind::Todo => Style::new().fg(Color::LightRed),
41        Kind::DoneTodo => Style::new().add_modifier(Modifier::DIM),
42    }
43}
44
45/// Highlight `text` under a profile's parser configuration.
46pub fn render(text: &str, profile: &ProfileConfig) -> Rendered {
47    let result = cuj::binding::parse(text, profile);
48
49    // Byte-level paint buffer: later paints override earlier ones,
50    // so wide todo spans go down first and the narrower constructs
51    // inside them (references, tags, …) win locally.
52    let mut paint: Vec<Option<Kind>> = vec![None; text.len()];
53    let mut mark = |loc: &Option<jots::Loc>, kind: Kind| {
54        if let Some(l) = loc {
55            for slot in paint.iter_mut().take(l.end.min(text.len())).skip(l.start) {
56                *slot = Some(kind);
57            }
58        }
59    };
60
61    for t in &result.todos {
62        mark(&t.loc, Kind::Todo);
63    }
64    for t in &result.done_todos {
65        mark(&t.loc, Kind::DoneTodo);
66    }
67    for t in result.todos.iter().chain(&result.done_todos) {
68        for r in &t.references {
69            mark(&r.loc, Kind::Reference);
70        }
71        for r in &t.cross_references {
72            mark(&r.loc, Kind::Reference);
73        }
74        for u in &t.urls {
75            mark(&u.loc, Kind::Url);
76        }
77    }
78    for r in &result.references {
79        mark(&r.loc, Kind::Reference);
80    }
81    for r in &result.cross_references {
82        mark(&r.loc, Kind::Reference);
83    }
84    for r in &result.file_references {
85        mark(&r.loc, Kind::Reference);
86    }
87    for r in &result.cross_file_references {
88        mark(&r.loc, Kind::Reference);
89    }
90    for r in &result.resources {
91        mark(&r.loc, Kind::Reference);
92    }
93    for r in &result.inter_jot_resources {
94        mark(&r.loc, Kind::Reference);
95    }
96    for u in &result.urls {
97        mark(&u.loc, Kind::Url);
98    }
99    for b in &result.bookmarks {
100        mark(&b.loc, Kind::Bookmark);
101    }
102
103    // Tags and categories: paint every textual occurrence of each
104    // parsed name (todo-scoped ones included — occurrences inside a
105    // todo span override its wash).
106    let mut names: Vec<(String, Kind)> = Vec::new();
107    let mut collect = |tags: &[String], cats: &[String]| {
108        for t in tags {
109            names.push((format!("..{t}"), Kind::Tag));
110        }
111        for c in cats {
112            names.push((format!("::{c}"), Kind::Category));
113        }
114    };
115    collect(&result.tags, &result.categories);
116    for t in result.todos.iter().chain(&result.done_todos) {
117        collect(&t.tags, &t.categories);
118    }
119    names.sort();
120    names.dedup();
121    for (needle, kind) in &names {
122        for start in occurrences(text, needle) {
123            for slot in paint.iter_mut().skip(start).take(needle.len()) {
124                *slot = Some(*kind);
125            }
126        }
127    }
128
129    let lines = to_lines(text, &paint);
130    Rendered {
131        raw: text.to_string(),
132        lines,
133    }
134}
135
136/// Byte offsets of boundary-respecting occurrences of `needle`
137/// (a `..tag` or `::cat::path` form) in `text`.
138fn occurrences(text: &str, needle: &str) -> Vec<usize> {
139    let bytes = text.as_bytes();
140    let token = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b':' || b == b'.';
141    let mut out = Vec::new();
142    let mut from = 0;
143    while let Some(i) = text[from..].find(needle) {
144        let start = from + i;
145        let end = start + needle.len();
146        let left_ok = start == 0 || !token(bytes[start - 1]);
147        let right_ok = end >= bytes.len() || !token(bytes[end]);
148        if left_ok && right_ok {
149            out.push(start);
150        }
151        from = start + 1;
152    }
153    out
154}
155
156/// Split painted text into per-line span runs. Paint marks come
157/// from parser byte offsets and needle matches, so run boundaries
158/// always fall on UTF-8 character boundaries.
159fn to_lines(text: &str, paint: &[Option<Kind>]) -> Vec<Line<'static>> {
160    let mut lines = Vec::new();
161    let mut offset = 0;
162    for raw_line in text.split('\n') {
163        let mut spans: Vec<Span<'static>> = Vec::new();
164        let mut run_start = 0;
165        let mut run_kind: Option<Kind> = None;
166        let line_paint = &paint[offset..offset + raw_line.len()];
167        for (i, k) in line_paint.iter().enumerate() {
168            if *k != run_kind {
169                if i > run_start {
170                    spans.push(span(&raw_line[run_start..i], run_kind));
171                }
172                run_start = i;
173                run_kind = *k;
174            }
175        }
176        if raw_line.len() > run_start {
177            spans.push(span(&raw_line[run_start..], run_kind));
178        }
179        lines.push(Line::from(spans));
180        offset += raw_line.len() + 1;
181    }
182    // split('\n') yields one trailing empty line for newline-
183    // terminated text; drop it so line counts match the content.
184    if text.ends_with('\n') {
185        lines.pop();
186    }
187    lines
188}
189
190fn span(s: &str, kind: Option<Kind>) -> Span<'static> {
191    match kind {
192        Some(k) => Span::styled(s.to_string(), style(k)),
193        None => Span::raw(s.to_string()),
194    }
195}