Skip to main content

kimun_notes/components/
query_highlight.rs

1//! Query **syntax highlighting** (spec §9): maps core's query token spans
2//! onto theme roles. Reused by every query input — the FIND drawer now, the
3//! telescope modal in phase 08 — so the coloring rules live exactly once.
4//!
5//! Core's lexer ([`kimun_core::query_token_spans`]) owns tokenization; this
6//! module only assigns styles, plus two presentation-layer overlays the core
7//! grammar doesn't know: `{variable}` placeholders and the leading `?`
8//! saved-search sigil.
9
10use kimun_core::QueryTokenClass;
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13
14use crate::settings::themes::Theme;
15
16/// Style for a token class, per the spec §9 role table mapped onto the real
17/// grammar: field keys yellow, tag values aqua, note targets blue, quoted
18/// green, date/number purple, negation red, plain terms fg.
19fn class_style(class: QueryTokenClass, theme: &Theme) -> Style {
20    match class {
21        QueryTokenClass::Negation => Style::default().fg(theme.red.to_ratatui()),
22        QueryTokenClass::FieldKey => Style::default().fg(theme.yellow.to_ratatui()),
23        QueryTokenClass::LinkValue => Style::default().fg(theme.blue.to_ratatui()),
24        QueryTokenClass::TagValue => Style::default().fg(theme.aqua.to_ratatui()),
25        QueryTokenClass::Quoted => Style::default().fg(theme.green.to_ratatui()),
26        QueryTokenClass::Date | QueryTokenClass::Number => {
27            Style::default().fg(theme.purple.to_ratatui())
28        }
29        QueryTokenClass::Term => Style::default().fg(theme.fg.to_ratatui()),
30        QueryTokenClass::Unterminated => Style::default()
31            .fg(theme.red.to_ratatui())
32            .add_modifier(Modifier::UNDERLINED),
33    }
34}
35
36/// Build the styled line for a query string. `base` carries the background
37/// (and the style for any uncovered whitespace).
38pub fn highlight_line(query: &str, theme: &Theme, base: Style) -> Line<'static> {
39    // Presentation-layer overlay: a leading `?` expands a saved search —
40    // style the sigil and hand the rest to the lexer-driven path with offset.
41    if let Some(rest) = query.strip_prefix('?') {
42        let mut spans = vec![Span::styled(
43            "?".to_string(),
44            base.patch(Style::default().fg(theme.gray.to_ratatui())),
45        )];
46        spans.push(Span::styled(
47            rest.to_string(),
48            base.patch(Style::default().fg(theme.aqua.to_ratatui())),
49        ));
50        return Line::from(spans);
51    }
52
53    let token_spans = kimun_core::query_token_spans(query);
54    let mut spans: Vec<Span<'static>> = Vec::with_capacity(token_spans.len() * 2 + 1);
55    let mut pos = 0usize;
56    for ts in token_spans {
57        if ts.range.start > pos {
58            spans.push(Span::styled(query[pos..ts.range.start].to_string(), base));
59        }
60        let text = &query[ts.range.clone()];
61        let style = base.patch(class_style(ts.class, theme));
62        // `{variable}` placeholders (e.g. `{note}`) are presentation-layer:
63        // restyle them inside whatever value span they sit in.
64        push_with_variables(&mut spans, text, style, base, theme);
65        pos = ts.range.end;
66    }
67    if pos < query.len() {
68        spans.push(Span::styled(query[pos..].to_string(), base));
69    }
70    if spans.is_empty() {
71        spans.push(Span::styled(String::new(), base));
72    }
73    Line::from(spans)
74}
75
76/// Split `{var}` placeholders out of `text`, styling them as variables and
77/// the rest with `style`.
78fn push_with_variables(
79    spans: &mut Vec<Span<'static>>,
80    text: &str,
81    style: Style,
82    base: Style,
83    theme: &Theme,
84) {
85    let var_style = base.patch(
86        Style::default()
87            .fg(theme.accent.to_ratatui())
88            .add_modifier(Modifier::ITALIC),
89    );
90    let mut rest = text;
91    while let Some(open) = rest.find('{') {
92        if let Some(close_rel) = rest[open..].find('}') {
93            let close = open + close_rel + 1;
94            if open > 0 {
95                spans.push(Span::styled(rest[..open].to_string(), style));
96            }
97            spans.push(Span::styled(rest[open..close].to_string(), var_style));
98            rest = &rest[close..];
99        } else {
100            break;
101        }
102    }
103    if !rest.is_empty() {
104        spans.push(Span::styled(rest.to_string(), style));
105    }
106}
107
108/// The lowercase emphasis needles a query implies: its plain text terms,
109/// labels in their in-body `#tag` form, and link targets — what a result's
110/// content actually matched on. Shared by the telescope preview and the
111/// editor's arrive-from-query emphasis.
112pub fn emphasis_needles(query: &str) -> Vec<String> {
113    let terms = kimun_core::SearchTerms::from_query_string(query);
114    let mut needles: Vec<String> = terms
115        .terms
116        .iter()
117        .map(|t| t.to_lowercase())
118        .chain(
119            terms
120                .labels
121                .iter()
122                .map(|l| format!("#{}", l.to_lowercase())),
123        )
124        .chain(terms.links.iter().map(|l| l.to_lowercase()))
125        .chain(terms.forward_links.iter().map(|l| l.to_lowercase()))
126        .filter(|t| !t.is_empty() && !t.contains('{'))
127        .collect();
128    needles.sort_unstable();
129    needles.dedup();
130    needles
131}
132
133/// The one-line reason for the query's parse problem, if any — surfaced in
134/// the FIND header. The lenient grammar's only real error is an unterminated
135/// quote.
136pub fn error_reason(query: &str) -> Option<&'static str> {
137    kimun_core::query_has_unterminated_quote(query).then_some("unterminated quote")
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn texts(line: &Line) -> Vec<String> {
145        line.spans.iter().map(|s| s.content.to_string()).collect()
146    }
147
148    #[test]
149    fn highlights_round_trip_the_text() {
150        let theme = Theme::default();
151        let q = r#"-#wip <{note} "two words" 2026-04-01 plain"#;
152        let line = highlight_line(q, &theme, Style::default());
153        assert_eq!(texts(&line).concat(), q);
154    }
155
156    #[test]
157    fn variable_is_styled_inside_value() {
158        let theme = Theme::default();
159        let line = highlight_line("<{note}", &theme, Style::default());
160        let texts = texts(&line);
161        assert!(texts.contains(&"{note}".to_string()), "got {texts:?}");
162    }
163
164    #[test]
165    fn saved_search_sigil_styles_whole_input() {
166        let theme = Theme::default();
167        let line = highlight_line("?todo", &theme, Style::default());
168        assert_eq!(texts(&line), vec!["?".to_string(), "todo".to_string()]);
169    }
170
171    #[test]
172    fn error_reason_only_for_unterminated() {
173        assert_eq!(error_reason(r#"a "open"#), Some("unterminated quote"));
174        assert_eq!(error_reason(r#"a "closed""#), None);
175    }
176}