Skip to main content

jev_repl/
highlight.rs

1//! Small hand-rolled highlighters for the three languages this REPL shows: the JSON going over
2//! the wire, the Rust it generates, and the command line you are typing.
3
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span};
6
7const KEY: Color = Color::Cyan;
8const STRING: Color = Color::Green;
9const NUMBER: Color = Color::Yellow;
10const LITERAL: Color = Color::Magenta;
11const PUNCT: Color = Color::DarkGray;
12const KEYWORD: Color = Color::Magenta;
13const TYPE: Color = Color::Cyan;
14const MACRO: Color = Color::LightBlue;
15const COMMENT: Color = Color::DarkGray;
16
17fn span(text: impl Into<String>, color: Color) -> Span<'static> {
18    Span::styled(text.into(), Style::new().fg(color))
19}
20
21/// Highlight pretty-printed JSON, indented into the transcript.
22pub fn json(text: &str) -> Vec<Line<'static>> {
23    text.lines()
24        .map(|line| {
25            let mut spans = vec![Span::raw("  ")];
26            spans.extend(json_spans(line));
27            Line::from(spans)
28        })
29        .collect()
30}
31
32/// One line of JSON. Keys are told from strings by the colon that follows them.
33pub fn json_spans(line: &str) -> Vec<Span<'static>> {
34    let chars: Vec<char> = line.chars().collect();
35    let mut spans = Vec::new();
36    let mut i = 0;
37    while i < chars.len() {
38        let c = chars[i];
39        match c {
40            '"' => {
41                let (text, next) = read_string(&chars, i);
42                let is_key = chars[next..]
43                    .iter()
44                    .find(|c| !c.is_whitespace())
45                    .is_some_and(|c| *c == ':');
46                spans.push(span(text, if is_key { KEY } else { STRING }));
47                i = next;
48            }
49            '-' | '0'..='9' => {
50                let start = i;
51                while i < chars.len() && (chars[i].is_ascii_digit() || "-+.eE".contains(chars[i])) {
52                    i += 1;
53                }
54                spans.push(span(collect(&chars, start, i), NUMBER));
55            }
56            c if c.is_alphabetic() => {
57                let start = i;
58                while i < chars.len() && chars[i].is_alphabetic() {
59                    i += 1;
60                }
61                let word = collect(&chars, start, i);
62                let color = match word.as_str() {
63                    "true" | "false" | "null" => LITERAL,
64                    _ => Color::Reset,
65                };
66                spans.push(span(word, color));
67            }
68            '{' | '}' | '[' | ']' | ':' | ',' => {
69                spans.push(span(c.to_string(), PUNCT));
70                i += 1;
71            }
72            _ => {
73                let start = i;
74                while i < chars.len() && chars[i].is_whitespace() {
75                    i += 1;
76                }
77                if i == start {
78                    i += 1;
79                }
80                spans.push(Span::raw(collect(&chars, start, i)));
81            }
82        }
83    }
84    spans
85}
86
87/// Highlight generated Rust, indented into the transcript.
88pub fn rust(text: &str) -> Vec<Line<'static>> {
89    text.lines()
90        .map(|line| {
91            let mut spans = vec![Span::raw("  ")];
92            spans.extend(rust_spans(line));
93            Line::from(spans)
94        })
95        .collect()
96}
97
98fn rust_spans(line: &str) -> Vec<Span<'static>> {
99    const KEYWORDS: &[&str] = &[
100        "async", "await", "else", "fn", "for", "if", "impl", "in", "let", "match", "mut", "pub",
101        "return", "struct", "use", "while", "true", "false",
102    ];
103    let chars: Vec<char> = line.chars().collect();
104    let mut spans = Vec::new();
105    let mut i = 0;
106    while i < chars.len() {
107        let c = chars[i];
108        if c == '/' && chars.get(i + 1) == Some(&'/') {
109            spans.push(span(collect(&chars, i, chars.len()), COMMENT));
110            break;
111        }
112        match c {
113            '"' => {
114                let (text, next) = read_string(&chars, i);
115                spans.push(span(text, STRING));
116                i = next;
117            }
118            '0'..='9' => {
119                let start = i;
120                while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
121                    i += 1;
122                }
123                spans.push(span(collect(&chars, start, i), NUMBER));
124            }
125            c if c.is_alphabetic() || c == '_' => {
126                let start = i;
127                while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
128                    i += 1;
129                }
130                let word = collect(&chars, start, i);
131                let macro_call = chars.get(i) == Some(&'!');
132                let color = if macro_call {
133                    i += 1;
134                    MACRO
135                } else if KEYWORDS.contains(&word.as_str()) {
136                    KEYWORD
137                } else if word.starts_with(char::is_uppercase) {
138                    TYPE
139                } else {
140                    Color::Reset
141                };
142                spans.push(span(
143                    if macro_call { format!("{word}!") } else { word },
144                    color,
145                ));
146            }
147            '(' | ')' | '{' | '}' | '[' | ']' | ';' | ',' | '.' | ':' | '?' | '&' | '<' | '>' => {
148                spans.push(span(c.to_string(), PUNCT));
149                i += 1;
150            }
151            _ => {
152                spans.push(Span::raw(c.to_string()));
153                i += 1;
154            }
155        }
156    }
157    spans
158}
159
160/// Highlight the input line: the command, the question name, the `|` separators, and the
161/// `label=description` / `yes:` criteria inside them.
162pub fn command(input: &str, known: impl Fn(&str) -> bool) -> Vec<Span<'static>> {
163    if input.is_empty() {
164        return Vec::new();
165    }
166    if !input.starts_with(':') {
167        // Bare text becomes the state.
168        return vec![Span::raw(input.to_owned())];
169    }
170    let (cmd, rest) = match input.split_once(char::is_whitespace) {
171        Some((c, r)) => (c, Some(r)),
172        None => (input, None),
173    };
174    let cmd_style = if known(cmd) {
175        Style::new()
176            .fg(Color::LightBlue)
177            .add_modifier(Modifier::BOLD)
178    } else {
179        Style::new().fg(Color::Red)
180    };
181    let mut spans = vec![Span::styled(cmd.to_owned(), cmd_style)];
182    let Some(rest) = rest else { return spans };
183    spans.push(Span::raw(" "));
184
185    let takes_name = matches!(
186        cmd,
187        ":noul" | ":choice" | ":score" | ":raw" | ":rm" | ":drop"
188    );
189    let mut body = rest;
190    if takes_name {
191        if let Some((name, tail)) = rest.split_once(char::is_whitespace) {
192            spans.push(Span::styled(
193                name.to_owned(),
194                Style::new().add_modifier(Modifier::BOLD),
195            ));
196            spans.push(Span::raw(" "));
197            body = tail;
198        } else {
199            spans.push(Span::styled(
200                rest.to_owned(),
201                Style::new().add_modifier(Modifier::BOLD),
202            ));
203            return spans;
204        }
205    }
206
207    for (i, part) in body.split('|').enumerate() {
208        if i > 0 {
209            spans.push(span("|", PUNCT));
210        }
211        if part.trim_start().starts_with('{') || part.trim_start().starts_with('[') {
212            spans.extend(json_spans(part));
213            continue;
214        }
215        match part.split_once('=') {
216            Some((label, desc)) if i > 0 => {
217                spans.push(span(label.to_owned(), LITERAL));
218                spans.push(span("=", PUNCT));
219                spans.push(Span::raw(desc.to_owned()));
220            }
221            _ => match part.split_once(':') {
222                Some((tag, desc)) if matches!(tag.trim(), "yes" | "no" | "true" | "false") => {
223                    spans.push(span(tag.to_owned(), KEY));
224                    spans.push(span(":", PUNCT));
225                    spans.push(Span::raw(desc.to_owned()));
226                }
227                _ => spans.push(Span::raw(part.to_owned())),
228            },
229        }
230    }
231    spans
232}
233
234fn read_string(chars: &[char], start: usize) -> (String, usize) {
235    let mut i = start + 1;
236    while i < chars.len() {
237        match chars[i] {
238            '\\' => i += 2,
239            '"' => {
240                i += 1;
241                break;
242            }
243            _ => i += 1,
244        }
245    }
246    let end = i.min(chars.len());
247    (collect(chars, start, end), end)
248}
249
250fn collect(chars: &[char], start: usize, end: usize) -> String {
251    chars[start..end.min(chars.len())].iter().collect()
252}