src/highlight.rs 11.4 K raw
1
//! Syntax highlighting for the response and body views.
2
//!
3
//! A hand-rolled tokenizer rather than a syntax-definition crate: cielago only
4
//! ever shows JSON, the occasional XML/HTML error page, and plain text, and
5
//! `syntect`-class dependencies dwarf the rest of the binary.
6
//!
7
//! Highlighting is line-oriented — every token type here (JSON strings
8
//! included, since they cannot contain a raw newline) starts and ends on one
9
//! line — so a line can be rendered without scanning the ones before it.
10
11
use ratatui::style::{Color, Modifier, Style};
12
use ratatui::text::{Line, Span};
13
14
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15
pub enum Syntax {
16
    Json,
17
    Xml,
18
    Plain,
19
}
20
21
/// Guess the syntax from the first non-blank character, the same way
22
/// [`crate::app`] picks a temp-file extension for `$EDITOR`.
23
pub fn detect(text: &str) -> Syntax {
24
    match text.trim_start().chars().next() {
25
        Some('{') | Some('[') => Syntax::Json,
26
        Some('<') => Syntax::Xml,
27
        _ => Syntax::Plain,
28
    }
29
}
30
31
const KEY: Color = Color::Cyan;
32
const STRING: Color = Color::Green;
33
const NUMBER: Color = Color::Yellow;
34
const LITERAL: Color = Color::Magenta;
35
const PUNCT: Color = Color::DarkGray;
36
const TAG: Color = Color::Blue;
37
38
/// Highlight `text`, one [`Line`] per input line.
39
///
40
/// `marks_vars` additionally paints `{{variable}}` placeholders — wanted in the
41
/// request body, where they are live template syntax, but not in a response,
42
/// where the same braces are just bytes the server sent.
43
pub fn highlight(text: &str, marks_vars: bool) -> Vec<Line<'static>> {
44
    let syntax = detect(text);
45
    text.split('\n')
46
        .map(|line| match syntax {
47
            Syntax::Json => json_line(line, marks_vars),
48
            Syntax::Xml => xml_line(line, marks_vars),
49
            Syntax::Plain => {
50
                let mut spans = Vec::new();
51
                push_text(&mut spans, line, Style::default(), marks_vars);
52
                Line::from(spans)
53
            }
54
        })
55
        .collect()
56
}
57
58
// ----- JSON -----
59
60
fn json_line(line: &str, marks_vars: bool) -> Line<'static> {
61
    let chars: Vec<char> = line.chars().collect();
62
    let mut spans: Vec<Span<'static>> = Vec::new();
63
    let mut i = 0;
64
65
    while i < chars.len() {
66
        let c = chars[i];
67
        match c {
68
            '"' => {
69
                let start = i;
70
                i = end_of_string(&chars, i);
71
                // A string followed by `:` is an object key.
72
                let is_key = chars[i..]
73
                    .iter()
74
                    .find(|c| !c.is_whitespace())
75
                    .is_some_and(|c| *c == ':');
76
                let color = if is_key { KEY } else { STRING };
77
                let text: String = chars[start..i].iter().collect();
78
                push_text(&mut spans, &text, Style::default().fg(color), marks_vars);
79
            }
80
            '-' | '0'..='9' => {
81
                let start = i;
82
                while i < chars.len() && is_number_char(chars[i]) {
83
                    i += 1;
84
                }
85
                spans.push(span(&chars[start..i], NUMBER));
86
            }
87
            c if c.is_ascii_alphabetic() => {
88
                let start = i;
89
                while i < chars.len() && chars[i].is_ascii_alphanumeric() {
90
                    i += 1;
91
                }
92
                let word: String = chars[start..i].iter().collect();
93
                let color = match word.as_str() {
94
                    "true" | "false" | "null" => LITERAL,
95
                    _ => Color::Reset,
96
                };
97
                spans.push(Span::styled(word, Style::default().fg(color)));
98
            }
99
            '{' | '}' | '[' | ']' | ',' | ':' => {
100
                let start = i;
101
                i += 1;
102
                spans.push(span(&chars[start..i], PUNCT));
103
            }
104
            _ => {
105
                let start = i;
106
                i += 1;
107
                spans.push(Span::raw(chars[start..i].iter().collect::<String>()));
108
            }
109
        }
110
    }
111
    Line::from(spans)
112
}
113
114
/// Index just past the closing quote of the string starting at `i`, or the end
115
/// of the line for an unterminated one.
116
fn end_of_string(chars: &[char], i: usize) -> usize {
117
    let mut i = i + 1;
118
    while i < chars.len() {
119
        match chars[i] {
120
            '\\' => i += 2,
121
            '"' => return (i + 1).min(chars.len()),
122
            _ => i += 1,
123
        }
124
    }
125
    chars.len()
126
}
127
128
fn is_number_char(c: char) -> bool {
129
    c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')
130
}
131
132
// ----- XML / HTML -----
133
134
/// Markup is coloured structurally: everything between `<` and `>` is a tag,
135
/// with its name, attribute names and quoted values distinguished; anything
136
/// else is text.
137
fn xml_line(line: &str, marks_vars: bool) -> Line<'static> {
138
    let chars: Vec<char> = line.chars().collect();
139
    let mut spans: Vec<Span<'static>> = Vec::new();
140
    let mut i = 0;
141
142
    while i < chars.len() {
143
        if chars[i] != '<' {
144
            let start = i;
145
            while i < chars.len() && chars[i] != '<' {
146
                i += 1;
147
            }
148
            let text: String = chars[start..i].iter().collect();
149
            push_text(&mut spans, &text, Style::default(), marks_vars);
150
            continue;
151
        }
152
153
        // `<` … `>`: opening punctuation plus the tag name, then attributes.
154
        let start = i;
155
        i += 1;
156
        while i < chars.len() && matches!(chars[i], '/' | '!' | '?') {
157
            i += 1;
158
        }
159
        while i < chars.len() && !chars[i].is_whitespace() && !matches!(chars[i], '>' | '/') {
160
            i += 1;
161
        }
162
        spans.push(span(&chars[start..i], TAG));
163
164
        while i < chars.len() && chars[i] != '>' {
165
            match chars[i] {
166
                '"' | '\'' => {
167
                    let quote = chars[i];
168
                    let start = i;
169
                    i += 1;
170
                    while i < chars.len() && chars[i] != quote {
171
                        i += 1;
172
                    }
173
                    i = (i + 1).min(chars.len());
174
                    let text: String = chars[start..i].iter().collect();
175
                    push_text(&mut spans, &text, Style::default().fg(STRING), marks_vars);
176
                }
177
                c if c.is_whitespace() || c == '=' || c == '/' => {
178
                    let start = i;
179
                    i += 1;
180
                    spans.push(span(&chars[start..i], PUNCT));
181
                }
182
                _ => {
183
                    let start = i;
184
                    while i < chars.len()
185
                        && !chars[i].is_whitespace()
186
                        && !"=>/\"'".contains(chars[i])
187
                    {
188
                        i += 1;
189
                    }
190
                    spans.push(span(&chars[start..i], KEY));
191
                }
192
            }
193
        }
194
        if i < chars.len() {
195
            spans.push(span(&chars[i..i + 1], TAG));
196
            i += 1;
197
        }
198
    }
199
    Line::from(spans)
200
}
201
202
// ----- shared -----
203
204
fn span(chars: &[char], color: Color) -> Span<'static> {
205
    Span::styled(chars.iter().collect::<String>(), Style::default().fg(color))
206
}
207
208
/// Push `text` in `style`, breaking out `{{variable}}` placeholders when
209
/// `marks_vars` is set so template syntax stands out from literal content.
210
fn push_text(spans: &mut Vec<Span<'static>>, text: &str, style: Style, marks_vars: bool) {
211
    if text.is_empty() {
212
        return;
213
    }
214
    if !marks_vars {
215
        spans.push(Span::styled(text.to_string(), style));
216
        return;
217
    }
218
    let var_style = Style::default()
219
        .fg(LITERAL)
220
        .add_modifier(Modifier::BOLD | Modifier::ITALIC);
221
    let mut rest = text;
222
    while let Some(start) = rest.find("{{") {
223
        let Some(end) = rest[start + 2..].find("}}") else {
224
            break;
225
        };
226
        if start > 0 {
227
            spans.push(Span::styled(rest[..start].to_string(), style));
228
        }
229
        let stop = start + 2 + end + 2;
230
        spans.push(Span::styled(rest[start..stop].to_string(), var_style));
231
        rest = &rest[stop..];
232
    }
233
    if !rest.is_empty() {
234
        spans.push(Span::styled(rest.to_string(), style));
235
    }
236
}
237
238
#[cfg(test)]
239
mod tests {
240
    use super::*;
241
242
    /// (text, fg colour) pairs, for asserting on what a line renders as.
243
    fn tokens(line: &Line<'static>) -> Vec<(String, Option<Color>)> {
244
        line.spans
245
            .iter()
246
            .map(|s| (s.content.to_string(), s.style.fg))
247
            .collect()
248
    }
249
250
    fn colored(line: &Line<'static>, text: &str) -> Option<Color> {
251
        tokens(line)
252
            .into_iter()
253
            .find(|(t, _)| t == text)
254
            .and_then(|(_, c)| c)
255
    }
256
257
    #[test]
258
    fn detects_syntax_from_first_char() {
259
        assert_eq!(detect("  {\"a\": 1}"), Syntax::Json);
260
        assert_eq!(detect("[1]"), Syntax::Json);
261
        assert_eq!(detect("<html>"), Syntax::Xml);
262
        assert_eq!(detect("plain words"), Syntax::Plain);
263
        assert_eq!(detect(""), Syntax::Plain);
264
    }
265
266
    #[test]
267
    fn json_keys_and_values_differ() {
268
        let lines = highlight(
269
            "{\n  \"name\": \"ada\",\n  \"n\": -1.5e3,\n  \"ok\": true\n}",
270
            false,
271
        );
272
        assert_eq!(lines.len(), 5);
273
        assert_eq!(colored(&lines[1], "\"name\""), Some(KEY));
274
        assert_eq!(colored(&lines[1], "\"ada\""), Some(STRING));
275
        assert_eq!(colored(&lines[2], "-1.5e3"), Some(NUMBER));
276
        assert_eq!(colored(&lines[3], "true"), Some(LITERAL));
277
        assert_eq!(colored(&lines[0], "{"), Some(PUNCT));
278
    }
279
280
    #[test]
281
    fn json_strings_keep_escapes_and_colons_inside() {
282
        let lines = highlight("{\"a\": \"x\\\": y\"}", false);
283
        assert_eq!(colored(&lines[0], "\"a\""), Some(KEY));
284
        // The escaped quote must not end the string early, and the `:` inside
285
        // it must not promote the value to a key.
286
        assert_eq!(colored(&lines[0], "\"x\\\": y\""), Some(STRING));
287
    }
288
289
    #[test]
290
    fn unterminated_json_string_does_not_panic() {
291
        let lines = highlight("{\"a\": \"oops", false);
292
        assert_eq!(colored(&lines[0], "\"oops"), Some(STRING));
293
    }
294
295
    #[test]
296
    fn every_character_survives_highlighting() {
297
        for text in [
298
            "{\"a\": [1, 2, {\"b\": null}], \"c\": \"é☃\"}",
299
            "<a href=\"/x\">hi &amp; bye</a>",
300
            "not markup at all",
301
        ] {
302
            let rendered: String = highlight(text, true)
303
                .iter()
304
                .map(|l| {
305
                    l.spans
306
                        .iter()
307
                        .map(|s| s.content.as_ref())
308
                        .collect::<String>()
309
                })
310
                .collect::<Vec<_>>()
311
                .join("\n");
312
            assert_eq!(rendered, text);
313
        }
314
    }
315
316
    #[test]
317
    fn xml_tags_attributes_and_text() {
318
        let lines = highlight("<a href=\"/x\">hi</a>", false);
319
        assert_eq!(colored(&lines[0], "<a"), Some(TAG));
320
        assert_eq!(colored(&lines[0], "href"), Some(KEY));
321
        assert_eq!(colored(&lines[0], "\"/x\""), Some(STRING));
322
        assert_eq!(colored(&lines[0], "hi"), None);
323
        assert_eq!(colored(&lines[0], "</a"), Some(TAG));
324
    }
325
326
    #[test]
327
    fn variables_are_marked_only_when_asked() {
328
        let body = "{\"id\": \"{{uuid}}\"}";
329
        let marked = highlight(body, true);
330
        assert_eq!(colored(&marked[0], "{{uuid}}"), Some(LITERAL));
331
        assert_eq!(colored(&marked[0], "\""), Some(STRING));
332
333
        let plain = highlight(body, false);
334
        assert_eq!(colored(&plain[0], "\"{{uuid}}\""), Some(STRING));
335
    }
336
337
    #[test]
338
    fn unclosed_variable_is_left_alone() {
339
        let lines = highlight("value {{oops", true);
340
        assert_eq!(tokens(&lines[0]).len(), 1);
341
    }
342
}