1use crate::diff::Line;
8
9#[derive(Clone, Copy)]
10struct StringKind {
11 delim: &'static str,
12 escapes: bool,
13 multiline: bool,
14}
15
16struct Profile {
17 line_comments: &'static [&'static str],
18 block_comment: Option<(&'static str, &'static str)>,
19 strings: &'static [StringKind],
20 keywords: &'static [&'static str],
21 literals: &'static [&'static str],
22 fn_call: bool,
23}
24
25#[derive(Clone, Copy)]
26enum State {
27 Neutral,
28 BlockComment(&'static str),
29 String(StringKind),
30}
31
32struct Scanner {
33 profile: &'static Profile,
34 state: State,
35}
36
37impl Scanner {
38 fn new(profile: &'static Profile) -> Self {
39 Self { profile, state: State::Neutral }
40 }
41
42 fn line(&mut self, input: &str) -> String {
43 let mut out = String::with_capacity(input.len());
44 let mut pos = 0;
45 while pos < input.len() {
46 match self.state {
47 State::BlockComment(end) => {
48 let close = input[pos..].find(end).map(|i| pos + i + end.len());
49 let stop = close.unwrap_or(input.len());
50 token(&mut out, "tok-com", &input[pos..stop]);
51 pos = stop;
52 if close.is_some() {
53 self.state = State::Neutral;
54 }
55 }
56 State::String(kind) => {
57 let (stop, closed) = string_end(input, pos, kind, false);
58 token(&mut out, "tok-str", &input[pos..stop]);
59 pos = stop;
60 if closed {
61 self.state = State::Neutral;
62 }
63 }
64 State::Neutral => {
65 if self.profile.line_comments.iter().any(|marker| input[pos..].starts_with(*marker)) {
66 token(&mut out, "tok-com", &input[pos..]);
67 break;
68 }
69 if let Some((start, end)) = self.profile.block_comment.filter(|(start, _)| input[pos..].starts_with(start)) {
70 let close = input[pos + start.len()..].find(end).map(|i| pos + start.len() + i + end.len());
71 let stop = close.unwrap_or(input.len());
72 token(&mut out, "tok-com", &input[pos..stop]);
73 pos = stop;
74 if close.is_none() {
75 self.state = State::BlockComment(end);
76 }
77 continue;
78 }
79 if let Some(kind) = self.profile.strings.iter().find(|kind| input[pos..].starts_with(kind.delim)).copied() {
80 let (stop, closed) = string_end(input, pos, kind, true);
81 token(&mut out, "tok-str", &input[pos..stop]);
82 pos = stop;
83 if !closed && kind.multiline {
84 self.state = State::String(kind);
85 }
86 continue;
87 }
88 let ch = input[pos..].chars().next().expect("pos is within the input");
89 if ch.is_ascii_digit() || (ch == '.' && input[pos + 1..].starts_with(|c: char| c.is_ascii_digit())) {
90 let stop = number_end(input, pos);
91 token(&mut out, "tok-num", &input[pos..stop]);
92 pos = stop;
93 continue;
94 }
95 if ident_start(ch) {
96 let stop = ident_end(input, pos);
97 let word = &input[pos..stop];
98 let class = if self.profile.keywords.binary_search(&word).is_ok() {
99 Some("tok-kw")
100 } else if self.profile.literals.binary_search(&word).is_ok() {
101 Some("tok-lit")
102 } else if self.profile.fn_call && input[stop..].trim_start().starts_with('(') {
103 Some("tok-fn")
104 } else {
105 None
106 };
107 if let Some(class) = class {
108 token(&mut out, class, word);
109 } else {
110 escape_into(&mut out, word);
111 }
112 pos = stop;
113 continue;
114 }
115 escape_char(&mut out, ch);
116 pos += ch.len_utf8();
117 }
118 }
119 }
120 out
121 }
122}
123
124fn string_end(input: &str, start: usize, kind: StringKind, opening: bool) -> (usize, bool) {
125 let mut pos = start + if opening { kind.delim.len() } else { 0 };
126 let mut escaped = false;
127 while pos < input.len() {
128 if !escaped && input[pos..].starts_with(kind.delim) {
129 return (pos + kind.delim.len(), true);
130 }
131 let ch = input[pos..].chars().next().expect("pos is within the input");
132 escaped = kind.escapes && ch == '\\' && !escaped;
133 pos += ch.len_utf8();
134 }
135 (input.len(), false)
136}
137
138fn ident_start(ch: char) -> bool {
139 ch == '_' || ch.is_alphabetic()
140}
141
142fn ident_end(input: &str, start: usize) -> usize {
143 input[start..]
144 .char_indices()
145 .find(|(i, ch)| *i > 0 && !(*ch == '_' || ch.is_alphanumeric()))
146 .map_or(input.len(), |(i, _)| start + i)
147}
148
149fn number_end(input: &str, start: usize) -> usize {
150 let mut end = start;
151 let mut previous = '\0';
152 for (i, ch) in input[start..].char_indices() {
153 let allowed = ch.is_ascii_alphanumeric()
154 || matches!(ch, '_' | '.')
155 || (matches!(ch, '+' | '-') && matches!(previous, 'e' | 'E' | 'p' | 'P'));
156 if !allowed {
157 break;
158 }
159 end = start + i + ch.len_utf8();
160 previous = ch;
161 }
162 end
163}
164
165fn token(out: &mut String, class: &str, text: &str) {
166 out.push_str("<span class=\"");
167 out.push_str(class);
168 out.push_str("\">");
169 escape_into(out, text);
170 out.push_str("</span>");
171}
172
173fn escape_into(out: &mut String, text: &str) {
174 for ch in text.chars() {
175 escape_char(out, ch);
176 }
177}
178
179fn escape_char(out: &mut String, ch: char) {
180 match ch {
181 '&' => out.push_str("&"),
182 '<' => out.push_str("<"),
183 '>' => out.push_str(">"),
184 '"' => out.push_str("""),
185 '\'' => out.push_str("'"),
186 _ => out.push(ch),
187 }
188}
189
190pub fn highlight_lines(path: &str, lines: &[&str]) -> Option<Vec<String>> {
193 let mut scanner = Scanner::new(profile_for(path)?);
194 Some(lines.iter().map(|line| scanner.line(line)).collect())
195}
196
197pub fn highlight_hunk(path: &str, lines: &[Line]) -> Option<Vec<String>> {
200 let profile = profile_for(path)?;
201 let mut old = Scanner::new(profile);
202 let mut new = Scanner::new(profile);
203 Some(
204 lines
205 .iter()
206 .map(|line| match line {
207 Line::Add { text, .. } => new.line(text),
208 Line::Del { text, .. } => old.line(text),
209 Line::Ctx { text, .. } => {
210 old.line(text);
211 new.line(text)
212 }
213 Line::Meta { text } => {
214 let mut html = String::new();
215 escape_into(&mut html, text);
216 html
217 }
218 })
219 .collect(),
220 )
221}
222
223const DQ: StringKind = StringKind { delim: "\"", escapes: true, multiline: false };
224const SQ: StringKind = StringKind { delim: "'", escapes: true, multiline: false };
225const RAW: StringKind = StringKind { delim: "`", escapes: false, multiline: true };
226const TRIPLE_DQ: StringKind = StringKind { delim: "\"\"\"", escapes: true, multiline: true };
227const TRIPLE_SQ: StringKind = StringKind { delim: "'''", escapes: true, multiline: true };
228
229const C_LIKE: Profile = Profile {
230 line_comments: &["//"],
231 block_comment: Some(("/*", "*/")),
232 strings: &[DQ, SQ],
233 keywords: &[
234 "alignas",
235 "alignof",
236 "asm",
237 "auto",
238 "bool",
239 "break",
240 "case",
241 "catch",
242 "char",
243 "class",
244 "const",
245 "constexpr",
246 "continue",
247 "default",
248 "delete",
249 "do",
250 "double",
251 "else",
252 "enum",
253 "explicit",
254 "export",
255 "extern",
256 "float",
257 "for",
258 "friend",
259 "goto",
260 "if",
261 "inline",
262 "int",
263 "long",
264 "namespace",
265 "new",
266 "operator",
267 "private",
268 "protected",
269 "public",
270 "register",
271 "return",
272 "short",
273 "signed",
274 "sizeof",
275 "static",
276 "struct",
277 "switch",
278 "template",
279 "this",
280 "throw",
281 "try",
282 "typedef",
283 "typename",
284 "union",
285 "unsigned",
286 "using",
287 "virtual",
288 "void",
289 "volatile",
290 "while",
291 ],
292 literals: &["NULL", "false", "nullptr", "true"],
293 fn_call: true,
294};
295const RUST: Profile = Profile {
296 line_comments: &["//"],
297 block_comment: Some(("/*", "*/")),
298 strings: &[DQ, SQ],
299 keywords: &[
300 "Self", "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "fn",
301 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "self", "static",
302 "struct", "super", "trait", "type", "unsafe", "use", "where", "while",
303 ],
304 literals: &["None", "false", "true"],
305 fn_call: true,
306};
307const JAVA: Profile = Profile {
308 line_comments: &["//"],
309 block_comment: Some(("/*", "*/")),
310 strings: &[DQ, SQ],
311 keywords: &[
312 "abstract",
313 "assert",
314 "boolean",
315 "break",
316 "byte",
317 "case",
318 "catch",
319 "char",
320 "class",
321 "const",
322 "continue",
323 "default",
324 "do",
325 "double",
326 "else",
327 "enum",
328 "extends",
329 "final",
330 "finally",
331 "float",
332 "for",
333 "goto",
334 "if",
335 "implements",
336 "import",
337 "instanceof",
338 "int",
339 "interface",
340 "long",
341 "native",
342 "new",
343 "package",
344 "private",
345 "protected",
346 "public",
347 "return",
348 "short",
349 "static",
350 "strictfp",
351 "super",
352 "switch",
353 "synchronized",
354 "this",
355 "throw",
356 "throws",
357 "transient",
358 "try",
359 "void",
360 "volatile",
361 "while",
362 ],
363 literals: &["false", "null", "true"],
364 fn_call: true,
365};
366const KOTLIN: Profile = Profile {
367 line_comments: &["//"],
368 block_comment: Some(("/*", "*/")),
369 strings: &[TRIPLE_DQ, DQ, SQ],
370 keywords: &[
371 "as",
372 "break",
373 "by",
374 "catch",
375 "class",
376 "constructor",
377 "continue",
378 "data",
379 "do",
380 "else",
381 "enum",
382 "finally",
383 "for",
384 "fun",
385 "if",
386 "import",
387 "in",
388 "interface",
389 "is",
390 "object",
391 "package",
392 "private",
393 "protected",
394 "public",
395 "return",
396 "sealed",
397 "super",
398 "this",
399 "throw",
400 "try",
401 "typealias",
402 "val",
403 "var",
404 "when",
405 "while",
406 ],
407 literals: &["false", "null", "true"],
408 fn_call: true,
409};
410const GO: Profile = Profile {
411 line_comments: &["//"],
412 block_comment: Some(("/*", "*/")),
413 strings: &[RAW, DQ, SQ],
414 keywords: &[
415 "break",
416 "case",
417 "chan",
418 "const",
419 "continue",
420 "default",
421 "defer",
422 "fallthrough",
423 "for",
424 "func",
425 "go",
426 "goto",
427 "if",
428 "import",
429 "interface",
430 "map",
431 "package",
432 "range",
433 "return",
434 "select",
435 "struct",
436 "switch",
437 "type",
438 "var",
439 ],
440 literals: &["false", "nil", "true"],
441 fn_call: true,
442};
443const PYTHON: Profile = Profile {
444 line_comments: &["#"],
445 block_comment: None,
446 strings: &[TRIPLE_DQ, TRIPLE_SQ, DQ, SQ],
447 keywords: &[
448 "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except",
449 "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise",
450 "return", "try", "while", "with", "yield",
451 ],
452 literals: &["False", "None", "True"],
453 fn_call: true,
454};
455const JS: Profile = Profile {
456 line_comments: &["//"],
457 block_comment: Some(("/*", "*/")),
458 strings: &[RAW, DQ, SQ],
459 keywords: &[
460 "async",
461 "await",
462 "break",
463 "case",
464 "catch",
465 "class",
466 "const",
467 "continue",
468 "debugger",
469 "default",
470 "delete",
471 "do",
472 "else",
473 "export",
474 "extends",
475 "finally",
476 "for",
477 "from",
478 "function",
479 "get",
480 "if",
481 "import",
482 "in",
483 "instanceof",
484 "let",
485 "new",
486 "of",
487 "return",
488 "set",
489 "static",
490 "super",
491 "switch",
492 "throw",
493 "try",
494 "typeof",
495 "var",
496 "void",
497 "while",
498 "with",
499 "yield",
500 ],
501 literals: &["false", "null", "true", "undefined"],
502 fn_call: true,
503};
504const RUBY: Profile = Profile {
505 line_comments: &["#"],
506 block_comment: None,
507 strings: &[DQ, SQ],
508 keywords: &[
509 "BEGIN", "END", "alias", "begin", "break", "case", "class", "def", "defined", "do", "else", "elsif", "end",
510 "ensure", "for", "if", "in", "module", "next", "redo", "rescue", "retry", "return", "self", "super", "then",
511 "undef", "unless", "until", "when", "while", "yield",
512 ],
513 literals: &["false", "nil", "true"],
514 fn_call: true,
515};
516const SHELL: Profile = Profile {
517 line_comments: &["#"],
518 block_comment: None,
519 strings: &[DQ, SQ],
520 keywords: &[
521 "case", "do", "done", "elif", "else", "esac", "fi", "for", "function", "if", "in", "select", "then", "time",
522 "until", "while",
523 ],
524 literals: &["false", "true"],
525 fn_call: true,
526};
527const SQL: Profile = Profile {
528 line_comments: &["--"],
529 block_comment: Some(("/*", "*/")),
530 strings: &[SQ, DQ],
531 keywords: &[
532 "ADD",
533 "ALTER",
534 "AND",
535 "AS",
536 "ASC",
537 "BEGIN",
538 "BETWEEN",
539 "BY",
540 "CASE",
541 "CREATE",
542 "DELETE",
543 "DESC",
544 "DISTINCT",
545 "DROP",
546 "ELSE",
547 "END",
548 "EXISTS",
549 "FROM",
550 "FULL",
551 "GROUP",
552 "HAVING",
553 "IN",
554 "INDEX",
555 "INNER",
556 "INSERT",
557 "INTO",
558 "IS",
559 "JOIN",
560 "LEFT",
561 "LIKE",
562 "LIMIT",
563 "NOT",
564 "ON",
565 "OR",
566 "ORDER",
567 "OUTER",
568 "PRIMARY",
569 "REFERENCES",
570 "RIGHT",
571 "SELECT",
572 "SET",
573 "TABLE",
574 "THEN",
575 "UNION",
576 "UNIQUE",
577 "UPDATE",
578 "VALUES",
579 "WHEN",
580 "WHERE",
581 ],
582 literals: &["FALSE", "NULL", "TRUE"],
583 fn_call: true,
584};
585const CSS: Profile = Profile {
586 line_comments: &[],
587 block_comment: Some(("/*", "*/")),
588 strings: &[DQ, SQ],
589 keywords: &[],
590 literals: &[],
591 fn_call: true,
592};
593const TOML: Profile = Profile {
594 line_comments: &["#"],
595 block_comment: None,
596 strings: &[TRIPLE_DQ, TRIPLE_SQ, DQ, SQ],
597 keywords: &[],
598 literals: &["false", "true"],
599 fn_call: false,
600};
601const YAML: Profile = Profile {
602 line_comments: &["#"],
603 block_comment: None,
604 strings: &[DQ, SQ],
605 keywords: &[],
606 literals: &["false", "null", "true", "~"],
607 fn_call: false,
608};
609const JSON: Profile = Profile {
610 line_comments: &[],
611 block_comment: None,
612 strings: &[DQ],
613 keywords: &[],
614 literals: &["false", "null", "true"],
615 fn_call: false,
616};
617
618fn profile_for(path: &str) -> Option<&'static Profile> {
619 let ext = path.rsplit_once('.')?.1.to_ascii_lowercase();
620 match ext.as_str() {
621 "rs" => Some(&RUST),
622 "c" | "cc" | "cpp" | "h" | "hpp" => Some(&C_LIKE),
623 "java" => Some(&JAVA),
624 "kt" | "kts" => Some(&KOTLIN),
625 "go" => Some(&GO),
626 "py" => Some(&PYTHON),
627 "js" | "jsx" | "mjs" | "ts" | "tsx" => Some(&JS),
628 "rb" => Some(&RUBY),
629 "bash" | "sh" | "zsh" => Some(&SHELL),
630 "sql" => Some(&SQL),
631 "css" => Some(&CSS),
632 "toml" => Some(&TOML),
633 "yaml" | "yml" => Some(&YAML),
634 "json" => Some(&JSON),
635 _ => None,
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 fn one(path: &str, line: &str) -> String {
644 highlight_lines(path, &[line]).expect("test path has a profile").remove(0)
645 }
646
647 #[test]
648 fn profile_smoke_tests_cover_every_language_family() {
649 let cases: [(&str, &str, &[&str]); 14] = [
650 (
651 "x.rs",
652 "fn main() { call(1, \"x\"); true } // hi",
653 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
654 ),
655 (
656 "x.cpp",
657 "return call(0xff, \"x\", true); // hi",
658 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
659 ),
660 (
661 "x.java",
662 "class X { call(1, \"x\", true); } // hi",
663 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
664 ),
665 (
666 "x.kt",
667 "fun main() = call(1, \"x\", true) // hi",
668 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
669 ),
670 (
671 "x.go",
672 "func main() { call(1, \"x\", nil) } // hi",
673 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
674 ),
675 (
676 "x.py",
677 "def f(): return call(1, \"x\", None) # hi",
678 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
679 ),
680 (
681 "x.tsx",
682 "const x = call(1, \"x\", undefined); // hi",
683 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
684 ),
685 (
686 "x.rb",
687 "def f; call(1, \"x\", nil); end # hi",
688 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
689 ),
690 (
691 "x.sh",
692 "if call(1, \"x\", true); then echo ok; fi # hi",
693 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
694 ),
695 (
696 "x.sql",
697 "SELECT call(1, 'x', NULL) FROM t -- hi",
698 &["tok-kw", "tok-fn", "tok-num", "tok-str", "tok-lit", "tok-com"],
699 ),
700 ("x.css", "a { width: calc(12px); content: \"x\"; } /* hi */", &["tok-fn", "tok-num", "tok-str", "tok-com"]),
701 ("x.toml", "value = \"x\"; count = 1; enabled = true # hi", &["tok-str", "tok-num", "tok-lit", "tok-com"]),
702 ("x.yaml", "value: \"x\", count: 1, enabled: true # hi", &["tok-str", "tok-num", "tok-lit", "tok-com"]),
703 ("x.json", "{\"value\": 1, \"ok\": true}", &["tok-str", "tok-num", "tok-lit"]),
704 ];
705 for (path, source, expected) in cases {
706 let html = one(path, source);
707 for class in expected {
708 assert!(html.contains(class), "{path} should contain {class}: {html}");
709 }
710 }
711 }
712
713 #[test]
714 fn profile_word_lists_stay_sorted_for_binary_search() {
715 for profile in [&RUST, &C_LIKE, &JAVA, &KOTLIN, &GO, &PYTHON, &JS, &RUBY, &SHELL, &SQL, &CSS, &TOML, &YAML, &JSON] {
716 assert!(profile.keywords.windows(2).all(|pair| pair[0] <= pair[1]));
717 assert!(profile.literals.windows(2).all(|pair| pair[0] <= pair[1]));
718 }
719 }
720
721 #[test]
722 fn state_carries_across_multiline_constructs() {
723 let rust = highlight_lines("x.rs", &["/* open", "still */ let x = 1;"]).expect("known profile");
724 assert!(rust[1].starts_with("<span class=\"tok-com\">still */</span> <span class=\"tok-kw\">let</span>"));
725 let python = highlight_lines("x.py", &["\"\"\"open", "still", "\"\"\" + 2"]).expect("known profile");
726 assert!(python[1].starts_with("<span class=\"tok-str\">still</span>"));
727 assert!(python[2].starts_with("<span class=\"tok-str\">"""</span>"));
728 }
729
730 #[test]
731 fn hunk_keeps_old_and_new_state_separate() {
732 let lines = vec![
733 Line::Del { old: 1, text: "/* old opens".into() },
734 Line::Add { new: 1, text: "new is plain */".into() },
735 Line::Del { old: 2, text: "old closes */".into() },
736 Line::Add { new: 2, text: "let value = true;".into() },
737 ];
738 let html = highlight_hunk("x.rs", &lines).expect("known profile");
739 assert!(html[2].starts_with("<span class=\"tok-com\">"));
740 assert!(html[3].starts_with("<span class=\"tok-kw\">let</span>"));
741 }
742
743 #[test]
744 fn unknown_and_deliberately_plain_paths_fall_back() {
745 assert_eq!(highlight_lines("README.md", &["# heading"]), None);
746 assert_eq!(highlight_lines("page.html", &["<b>x</b>"]), None);
747 }
748
749 #[test]
750 fn hostile_input_is_escaped_and_spans_preserve_exact_escaped_text() {
751 let source = "const x = \"</span><script>&'\\\"\"; // <evil>";
752 let html = one("x.js", source);
753 assert!(!html.contains("<script>"));
754 let stripped = html
755 .replace("<span class=\"tok-com\">", "")
756 .replace("<span class=\"tok-str\">", "")
757 .replace("<span class=\"tok-num\">", "")
758 .replace("<span class=\"tok-kw\">", "")
759 .replace("<span class=\"tok-lit\">", "")
760 .replace("<span class=\"tok-fn\">", "")
761 .replace("</span>", "");
762 let mut escaped = String::new();
763 escape_into(&mut escaped, source);
764 assert_eq!(stripped, escaped);
765 }
766}