| 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 & 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 | } |