1use crate::theme::{BLACK, DARK_GRAY};
9use egui::text::LayoutJob;
10use egui::{Color32, FontId, Stroke, TextFormat};
11use std::ops::Range;
12
13const FUNCTIONS: &[&str] = &[
16 "SUM", "AVG", "MIN", "MAX", "ABS", "ROUND", "FLOOR", "CEIL", "SQRT", "NEG", "MIN2", "MAX2",
17 "CALL", "SQL", "OVER", "TRUE", "FALSE", "AND", "OR", "NOT",
18];
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum TokenKind {
23 Ident,
26 Function,
28 Number,
29 String,
30 Date,
32 Operator,
33 Whitespace,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Token {
39 pub kind: TokenKind,
40 pub start: usize,
41 pub end: usize,
42}
43
44fn char_at(text: &str, i: usize) -> char {
53 text[i..]
54 .chars()
55 .next()
56 .expect("i < len implies a char here")
57}
58
59pub fn scan(text: &str) -> Vec<Token> {
67 let bytes = text.as_bytes();
68 let mut out = Vec::new();
69 let mut i = 0;
70 while i < bytes.len() {
71 let start = i;
72 let c = char_at(text, i);
73 let kind = match c {
74 c if c.is_whitespace() => {
75 i += c.len_utf8();
76 while i < bytes.len() {
77 let w = char_at(text, i);
78 if w.is_whitespace() {
79 i += w.len_utf8();
80 } else {
81 break;
82 }
83 }
84 TokenKind::Whitespace
85 }
86 '"' => {
87 i += 1;
90 while i < bytes.len() && bytes[i] != b'"' {
91 i += 1;
92 }
93 if i < bytes.len() {
94 i += 1; }
96 TokenKind::String
97 }
98 '#' => {
99 i += 1;
100 while i < bytes.len() && bytes[i] != b'#' {
101 i += 1;
102 }
103 if i < bytes.len() {
104 i += 1; }
106 TokenKind::Date
107 }
108 c if c.is_ascii_digit() => {
109 while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
110 i += 1;
111 }
112 TokenKind::Number
113 }
114 c if c.is_alphabetic() || c == '_' => {
115 i += c.len_utf8();
116 while i < bytes.len() {
117 let w = char_at(text, i);
118 if w.is_alphanumeric() || w == '_' {
119 i += w.len_utf8();
120 } else {
121 break;
122 }
123 }
124 let word = &text[start..i];
125 if FUNCTIONS.iter().any(|f| word.eq_ignore_ascii_case(f)) {
126 TokenKind::Function
127 } else {
128 TokenKind::Ident
129 }
130 }
131 _ => {
132 let two = text.get(i..i + 2);
136 if matches!(
137 two,
138 Some("<=") | Some(">=") | Some("<>") | Some("==") | Some("!=")
139 ) {
140 i += 2;
141 } else {
142 i += c.len_utf8();
143 }
144 TokenKind::Operator
145 }
146 };
147 out.push(Token {
148 kind,
149 start,
150 end: i,
151 });
152 }
153 out
154}
155
156fn color_for(kind: TokenKind) -> Color32 {
160 match kind {
161 TokenKind::Function | TokenKind::Number | TokenKind::Ident | TokenKind::Whitespace => BLACK,
162 TokenKind::String | TokenKind::Date | TokenKind::Operator => DARK_GRAY,
163 }
164}
165
166pub const ERROR_COLOR: Color32 = BLACK;
169
170const ERROR_UNDERLINE: Stroke = Stroke {
172 width: 2.0,
173 color: ERROR_COLOR,
174};
175
176pub fn highlight_formula(text: &str, font: FontId, error: Option<Range<usize>>) -> LayoutJob {
183 let mut job = LayoutJob::default();
184 if text.is_empty() {
185 job.append("", 0.0, TextFormat::simple(font, BLACK));
188 return job;
189 }
190 let (lo, hi) = error.map_or((0, 0), |r| {
191 let fix = |mut i: usize| {
192 i = i.min(text.len());
193 while !text.is_char_boundary(i) {
194 i -= 1;
195 }
196 i
197 };
198 (fix(r.start), fix(r.end))
199 });
200 for t in scan(text) {
201 let fmt = TextFormat {
202 font_id: font.clone(),
203 color: color_for(t.kind),
204 ..Default::default()
205 };
206 let err = TextFormat {
207 underline: ERROR_UNDERLINE,
208 color: ERROR_COLOR,
209 ..fmt.clone()
210 };
211 let a = lo.clamp(t.start, t.end);
213 let b = hi.clamp(a, t.end);
214 for (s, e, f) in [(t.start, a, &fmt), (a, b, &err), (b, t.end, &fmt)] {
215 if s < e {
216 job.append(&text[s..e], 0.0, f.clone());
217 }
218 }
219 }
220 job
221}
222
223#[cfg(test)]
224#[cfg_attr(coverage_nightly, coverage(off))]
225mod tests {
226 use super::*;
227
228 fn kinds(text: &str) -> Vec<(TokenKind, &str)> {
229 scan(text)
230 .into_iter()
231 .filter(|t| t.kind != TokenKind::Whitespace)
232 .map(|t| (t.kind, &text[t.start..t.end]))
233 .collect()
234 }
235
236 #[test]
237 fn identifies_idents_and_operator() {
238 let toks = kinds("Price * Quantity");
239 assert_eq!(
240 toks,
241 vec![
242 (TokenKind::Ident, "Price"),
243 (TokenKind::Operator, "*"),
244 (TokenKind::Ident, "Quantity"),
245 ]
246 );
247 }
248
249 #[test]
250 fn known_function_names_are_distinct_from_idents() {
251 let toks = kinds("SUM(Revenue OVER Time)");
252 assert_eq!(toks[0], (TokenKind::Function, "SUM"));
253 assert_eq!(toks[1], (TokenKind::Operator, "("));
254 assert_eq!(toks[2], (TokenKind::Ident, "Revenue"));
255 assert_eq!(toks[3], (TokenKind::Function, "OVER"));
256 assert_eq!(toks[4], (TokenKind::Ident, "Time"));
257 assert_eq!(toks[5], (TokenKind::Operator, ")"));
258
259 let abs = kinds("ABS(x)");
260 assert_eq!(abs[0], (TokenKind::Function, "ABS"));
261 assert_eq!(abs[1].0, TokenKind::Operator);
262 assert_eq!(abs[2], (TokenKind::Ident, "x"));
263 }
264
265 #[test]
266 fn string_and_number_and_date_literals() {
267 let toks = kinds(r#"Name == "Widget A""#);
268 assert_eq!(toks[0], (TokenKind::Ident, "Name"));
269 assert_eq!(toks[1], (TokenKind::Operator, "=="));
270 assert_eq!(toks[2], (TokenKind::String, "\"Widget A\""));
271
272 let toks = kinds("3.14 + 2");
273 assert_eq!(toks[0], (TokenKind::Number, "3.14"));
274 assert_eq!(toks[1], (TokenKind::Operator, "+"));
275 assert_eq!(toks[2], (TokenKind::Number, "2"));
276
277 let toks = kinds("Date > #2025-01-01#");
278 assert_eq!(toks[0], (TokenKind::Ident, "Date"));
279 assert_eq!(toks[1], (TokenKind::Operator, ">"));
280 assert_eq!(toks[2], (TokenKind::Date, "#2025-01-01#"));
281 }
282
283 #[test]
284 fn scan_covers_every_byte_with_no_gaps() {
285 let text = "Price * Quantity - SUM(X OVER Y)";
286 let toks = scan(text);
287 let mut pos = 0;
288 for t in &toks {
289 assert_eq!(t.start, pos);
290 pos = t.end;
291 }
292 assert_eq!(pos, text.len());
293 }
294
295 fn assert_tiles(text: &str) {
299 let toks = scan(text);
300 let mut pos = 0;
301 for t in &toks {
302 assert_eq!(t.start, pos, "gap/overlap before {t:?} in {text:?}");
303 assert!(t.end > t.start, "empty token {t:?} in {text:?}");
304 assert!(
305 text.is_char_boundary(t.start),
306 "{:?}: start {} not a char boundary",
307 text,
308 t.start
309 );
310 assert!(
311 text.is_char_boundary(t.end),
312 "{:?}: end {} not a char boundary",
313 text,
314 t.end
315 );
316 pos = t.end;
317 }
318 assert_eq!(pos, text.len(), "tokens do not cover all of {text:?}");
319 }
320
321 #[test]
322 fn unicode_input_never_panics_and_tokens_stay_on_char_boundaries() {
323 for text in [
326 "é",
327 "café",
328 "年度売上", "年度 * 売上", "🚀", "Price 🚀 Quantity", "Prîx * Qté", "SUM(Recéttes OVER Année)",
334 "\u{0301}", "e\u{0301}x", "ééé",
337 "\"café\" == Nom", "#2025-01-é1#", "3.14 ≤ é", "é<=é", "é\"unterminated", "é#unterminated",
343 " é", ] {
345 assert_tiles(text);
346 let job = highlight_formula(text, FontId::default(), Some(1..2));
348 assert_eq!(job.text, text, "rendered text differs for {text:?}");
349 }
350 }
351
352 #[test]
353 fn multibyte_identifier_is_one_token() {
354 let toks = kinds("café");
356 assert_eq!(toks, vec![(TokenKind::Ident, "café")]);
357 let toks = scan("🚀");
360 assert_eq!(
361 toks,
362 vec![Token {
363 kind: TokenKind::Operator,
364 start: 0,
365 end: 4
366 }]
367 );
368 }
369
370 #[test]
371 fn error_span_inside_a_multibyte_char_does_not_panic() {
372 let job = highlight_formula("é + 1", FontId::default(), Some(1..2));
374 assert_eq!(job.text, "é + 1");
375 }
376
377 #[test]
378 fn error_span_marks_the_token() {
379 let text = "Price * Widgets";
380 let job = highlight_formula(text, FontId::default(), Some(8..15));
382 let bad = job
383 .sections
384 .iter()
385 .find(|s| &job.text[s.byte_range.clone()] == "Widgets")
386 .expect("Widgets token present");
387 assert_eq!(bad.format.color, ERROR_COLOR);
388 assert_eq!(bad.format.underline, ERROR_UNDERLINE);
389 let price = job
392 .sections
393 .iter()
394 .find(|s| &job.text[s.byte_range.clone()] == "Price")
395 .expect("Price token present");
396 assert_eq!(price.format.underline, Stroke::NONE);
397 }
398
399 #[test]
402 fn error_span_marks_exactly_its_bytes() {
403 let marked = |text: &str, r| {
404 let job = highlight_formula(text, FontId::default(), Some(r));
405 assert_eq!(job.text, text);
406 job.sections
407 .iter()
408 .filter(|s| s.format.underline == ERROR_UNDERLINE)
409 .map(|s| &text[s.byte_range.clone()])
410 .collect::<String>()
411 };
412 assert_eq!(marked("Price * Widgets", 0..7), "Price *");
413 assert_eq!(marked("Price * Widgets", 9..11), "id");
414 assert_eq!(marked("Price", 3..99), "ce");
415 assert_eq!(marked("é + 1", 0..1), "");
416 assert_eq!(marked("é + 1", 0..2), "é");
417 assert_eq!(marked("é + 1", 1..2), "é", "a mid-char start moves back");
418 }
419
420 #[test]
421 fn no_error_span_leaves_all_tokens_unmarked() {
422 let job = highlight_formula("Price * Quantity", FontId::default(), None);
423 assert!(job
424 .sections
425 .iter()
426 .all(|s| s.format.underline == Stroke::NONE));
427 }
428
429 #[test]
430 fn empty_text_keeps_one_section_for_the_row_height() {
431 let job = highlight_formula("", FontId::default(), Some(0..3));
432 assert_eq!(job.sections.len(), 1);
433 assert_eq!(job.sections[0].format.underline, Stroke::NONE);
434 }
435
436 #[test]
437 fn token_colors_are_grayscale() {
438 let job = highlight_formula(
439 r#"SUM(x) + 1 == "s" #2025-01-01#"#,
440 FontId::default(),
441 Some(0..3),
442 );
443 for s in &job.sections {
444 let c = s.format.color;
445 assert!(c == BLACK || c == DARK_GRAY, "{c:?} is not a palette gray");
446 }
447 }
448
449 #[test]
450 fn whitespace_runs_and_underscored_idents_are_single_tokens() {
451 let toks: Vec<_> = scan("_a_b \t x ")
452 .into_iter()
453 .map(|t| (t.kind, t.start, t.end))
454 .collect();
455 assert_eq!(
456 toks,
457 vec![
458 (TokenKind::Ident, 0, 4),
459 (TokenKind::Whitespace, 4, 8),
460 (TokenKind::Ident, 8, 9),
461 (TokenKind::Whitespace, 9, 11),
462 ]
463 );
464 }
465}