1use std::path::Path;
14use std::sync::OnceLock;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum TokenClass {
19 Keyword,
20 Type,
21 Function,
22 String,
23 Comment,
24 Number,
25 Constant,
26 Preprocessor,
27 Tag,
28 Attribute,
29 Heading,
30 Link,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Span {
37 pub start: usize,
38 pub end: usize,
39 pub class: TokenClass,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Language {
45 Rust,
46 CLike,
47 JavaScript,
48 TypeScript,
49 Python,
50 Shell,
51 Go,
52 Json,
53 KeyValue,
54 Yaml,
55 Markup,
56 Css,
57 Markdown,
58 Plain,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Palette {
65 pub keyword: String,
66 pub type_: String,
67 pub function: String,
68 pub string: String,
69 pub comment: String,
70 pub number: String,
71 pub constant: String,
72 pub preprocessor: String,
73 pub tag: String,
74 pub attribute: String,
75 pub heading: String,
76 pub link: String,
77}
78
79impl Palette {
80 pub fn dark() -> Self {
83 Self {
84 keyword: "#7aa2f7".to_owned(),
85 type_: "#2ac3de".to_owned(),
86 function: "#7dcfff".to_owned(),
87 string: "#9ece6a".to_owned(),
88 comment: "#565f89".to_owned(),
89 number: "#ff9e64".to_owned(),
90 constant: "#bb9af7".to_owned(),
91 preprocessor: "#e0af68".to_owned(),
92 tag: "#f7768e".to_owned(),
93 attribute: "#bb9af7".to_owned(),
94 heading: "#7aa2f7".to_owned(),
95 link: "#73daca".to_owned(),
96 }
97 }
98
99 pub fn color(&self, class: TokenClass) -> &str {
102 match class {
103 TokenClass::Keyword => &self.keyword,
104 TokenClass::Type => &self.type_,
105 TokenClass::Function => &self.function,
106 TokenClass::String => &self.string,
107 TokenClass::Comment => &self.comment,
108 TokenClass::Number => &self.number,
109 TokenClass::Constant => &self.constant,
110 TokenClass::Preprocessor => &self.preprocessor,
111 TokenClass::Tag => &self.tag,
112 TokenClass::Attribute => &self.attribute,
113 TokenClass::Heading => &self.heading,
114 TokenClass::Link => &self.link,
115 }
116 }
117}
118
119static PALETTE: OnceLock<Palette> = OnceLock::new();
122
123pub fn set_palette(palette: Palette) {
125 let _ = PALETTE.set(palette);
126}
127
128pub fn palette() -> &'static Palette {
130 PALETTE.get_or_init(Palette::dark)
131}
132
133pub fn language_for(path: &Path) -> Language {
135 let ext = path
136 .extension()
137 .and_then(|ext| ext.to_str())
138 .map(str::to_ascii_lowercase);
139 match ext.as_deref() {
140 Some("rs") => Language::Rust,
141 Some("c" | "h" | "cc" | "hh" | "cpp" | "hpp" | "cxx" | "hxx") => Language::CLike,
142 Some("js" | "mjs" | "cjs" | "jsx") => Language::JavaScript,
143 Some("ts" | "mts" | "cts" | "tsx") => Language::TypeScript,
144 Some("py" | "pyw") => Language::Python,
145 Some("sh" | "bash" | "zsh" | "fish" | "ksh") => Language::Shell,
146 Some("go") => Language::Go,
147 Some("json") => Language::Json,
148 Some("toml" | "ini" | "conf" | "cfg" | "env" | "properties") => Language::KeyValue,
149 Some("yaml" | "yml") => Language::Yaml,
150 Some("html" | "htm" | "xhtml" | "xml" | "svg") => Language::Markup,
151 Some("css") => Language::Css,
152 Some("md" | "markdown") => Language::Markdown,
153 _ => Language::Plain,
154 }
155}
156
157pub fn highlight(language: Language, text: &str) -> Vec<Span> {
160 let chars: Vec<char> = text.chars().collect();
161 match language {
162 Language::Plain => Vec::new(),
163 Language::Rust => scan_code(&chars, &RUST),
164 Language::CLike => scan_code(&chars, &C_LIKE),
165 Language::JavaScript => scan_code(&chars, &JAVASCRIPT),
166 Language::TypeScript => scan_code(&chars, &TYPESCRIPT),
167 Language::Python => scan_code(&chars, &PYTHON),
168 Language::Shell => scan_code(&chars, &SHELL),
169 Language::Go => scan_code(&chars, &GO),
170 Language::Json => scan_json(&chars),
171 Language::KeyValue => scan_key_value(&chars),
172 Language::Yaml => scan_yaml(&chars),
173 Language::Markup => scan_markup(&chars),
174 Language::Css => scan_css(&chars),
175 Language::Markdown => scan_markdown(&chars),
176 }
177}
178
179#[derive(Clone, Copy)]
185struct Syntax {
186 line_comments: &'static [&'static str],
187 block_comment: Option<(&'static str, &'static str)>,
188 quotes: &'static [char],
190 keywords: &'static [&'static str],
191 types: &'static [&'static str],
192 constants: &'static [&'static str],
193 hash_line: bool,
195 hash_bracket: bool,
197 at_line: bool,
199 rust_strings: bool,
201 python_strings: bool,
203}
204
205fn scan_code(chars: &[char], syntax: &Syntax) -> Vec<Span> {
206 let mut spans = Vec::new();
207 let mut i = 0;
208 while i < chars.len() {
209 let c = chars[i];
210
211 if syntax.line_comments.iter().any(|p| matches_at(chars, i, p)) {
213 let end = line_end(chars, i);
214 spans.push(Span { start: i, end, class: TokenClass::Comment });
215 i = end;
216 continue;
217 }
218 if let Some((open, close)) = syntax.block_comment
220 && matches_at(chars, i, open)
221 {
222 let start = i;
223 i += char_len(open);
224 while i < chars.len() && !matches_at(chars, i, close) {
225 i += 1;
226 }
227 i = (i + char_len(close)).min(chars.len());
228 spans.push(Span { start, end: i, class: TokenClass::Comment });
229 continue;
230 }
231 if syntax.rust_strings {
233 if let Some((start, end)) = rust_string_span(chars, i) {
234 spans.push(Span { start, end, class: TokenClass::String });
235 i = end;
236 continue;
237 }
238 if c == '\''
240 && chars.get(i + 1).is_some_and(|n| is_ident_start(*n))
241 && chars.get(i + 2) != Some(&'\'')
242 {
243 let end = ident_end(chars, i + 1);
244 spans.push(Span { start: i, end, class: TokenClass::Type });
245 i = end;
246 continue;
247 }
248 }
249 if syntax.python_strings
251 && let Some((start, end)) = python_string_span(chars, i)
252 {
253 spans.push(Span { start, end, class: TokenClass::String });
254 i = end;
255 continue;
256 }
257 if syntax.quotes.contains(&c) {
259 let end = string_end(chars, i, c);
260 spans.push(Span { start: i, end, class: TokenClass::String });
261 i = end;
262 continue;
263 }
264 if syntax.hash_bracket && c == '#' && chars.get(i + 1) == Some(&'[') {
266 let end = bracketed_end(chars, i + 1);
267 spans.push(Span { start: i, end, class: TokenClass::Preprocessor });
268 i = end;
269 continue;
270 }
271 if syntax.hash_line && c == '#' && only_space_before(chars, i) {
273 let end = line_end(chars, i);
274 spans.push(Span { start: i, end, class: TokenClass::Preprocessor });
275 i = end;
276 continue;
277 }
278 if syntax.at_line && c == '@' && only_space_before(chars, i) {
280 let end = ident_end(chars, i + 1);
281 spans.push(Span { start: i, end, class: TokenClass::Preprocessor });
282 i = end;
283 continue;
284 }
285 if c.is_ascii_digit() && !chars.get(i.wrapping_sub(1)).is_some_and(|p| is_ident_char(*p)) {
287 let end = number_end(chars, i);
288 spans.push(Span { start: i, end, class: TokenClass::Number });
289 i = end;
290 continue;
291 }
292 if is_ident_start(c) {
294 let end = ident_end(chars, i);
295 let word: String = chars[i..end].iter().collect();
296 let class = if syntax.constants.contains(&word.as_str()) {
297 Some(TokenClass::Constant)
298 } else if syntax.keywords.contains(&word.as_str()) {
299 Some(TokenClass::Keyword)
300 } else if syntax.types.contains(&word.as_str()) {
301 Some(TokenClass::Type)
302 } else if chars.get(end) == Some(&'(') {
303 Some(TokenClass::Function)
304 } else {
305 None
306 };
307 if let Some(class) = class {
308 spans.push(Span { start: i, end, class });
309 }
310 i = end;
311 continue;
312 }
313 i += 1;
314 }
315 spans
316}
317
318fn rust_string_span(chars: &[char], i: usize) -> Option<(usize, usize)> {
319 let (prefix, quote) = match chars.get(i) {
320 Some('r') => (1, true),
321 Some('b') if chars.get(i + 1) == Some(&'r') => (2, true),
322 Some('b') => (1, false),
323 _ => return None,
324 };
325 if !quote {
326 if chars.get(i + prefix) == Some(&'"') {
328 let start = i;
329 let end = string_end(chars, i + prefix, '"');
330 return Some((start, end));
331 }
332 return None;
333 }
334 let mut j = i + prefix;
335 let mut hashes = 0;
336 while chars.get(j) == Some(&'#') {
337 hashes += 1;
338 j += 1;
339 }
340 if chars.get(j) != Some(&'"') {
341 return None;
342 }
343 j += 1;
344 let closing = format!("\"{}", "#".repeat(hashes));
345 while j < chars.len() && !matches_at(chars, j, &closing) {
346 j += 1;
347 }
348 let end = (j + char_len(&closing)).min(chars.len());
349 Some((i, end))
350}
351
352fn python_string_span(chars: &[char], i: usize) -> Option<(usize, usize)> {
353 let mut j = i;
355 while chars.get(j).is_some_and(|c| matches!(c, 'r' | 'b' | 'f' | 'u' | 'R' | 'B' | 'F' | 'U')) {
356 j += 1;
357 }
358 let quote = *chars.get(j)?;
359 if quote != '"' && quote != '\'' {
360 return None;
361 }
362 let triple = chars.get(j + 1) == Some("e) && chars.get(j + 2) == Some("e);
363 let delim = if triple {
364 quote.to_string().repeat(3)
365 } else {
366 quote.to_string()
367 };
368 let mut k = j + char_len(&delim);
369 while k < chars.len() {
370 if chars[k] == '\\' {
371 k += 2;
372 continue;
373 }
374 if matches_at(chars, k, &delim) {
375 return Some((i, k + char_len(&delim)));
376 }
377 if !triple && chars[k] == '\n' {
378 return None;
379 }
380 k += 1;
381 }
382 Some((i, chars.len()))
383}
384
385fn string_end(chars: &[char], start: usize, quote: char) -> usize {
388 let mut i = start + 1;
389 while i < chars.len() {
390 match chars[i] {
391 '\\' => i += 2,
392 c if c == quote => return i + 1,
393 '\n' => return i,
394 _ => i += 1,
395 }
396 }
397 chars.len()
398}
399
400fn number_end(chars: &[char], start: usize) -> usize {
401 let mut i = start;
402 let mut seen_dot = false;
403 while i < chars.len() {
404 let c = chars[i];
405 if c == '.' {
406 if chars.get(i + 1) == Some(&'.') || seen_dot {
408 break;
409 }
410 seen_dot = true;
411 i += 1;
412 } else if c.is_alphanumeric() || c == '_' {
413 i += 1;
414 } else {
415 break;
416 }
417 }
418 i
419}
420
421fn bracketed_end(chars: &[char], open: usize) -> usize {
422 let mut depth = 0;
423 let mut i = open;
424 while i < chars.len() {
425 match chars[i] {
426 '[' => depth += 1,
427 ']' => {
428 depth -= 1;
429 if depth == 0 {
430 return i + 1;
431 }
432 }
433 _ => {}
434 }
435 i += 1;
436 }
437 chars.len()
438}
439
440fn scan_json(chars: &[char]) -> Vec<Span> {
445 let mut spans = Vec::new();
446 let mut i = 0;
447 while i < chars.len() {
448 let c = chars[i];
449 if c == '"' {
450 let end = string_end(chars, i, '"');
451 let after = skip_space(chars, end);
452 let class = if chars.get(after) == Some(&':') {
453 TokenClass::Attribute
454 } else {
455 TokenClass::String
456 };
457 spans.push(Span { start: i, end, class });
458 i = end;
459 continue;
460 }
461 if c.is_ascii_digit()
462 || (c == '-' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit()))
463 {
464 let end = number_end(chars, i + usize::from(c == '-'));
465 spans.push(Span { start: i, end, class: TokenClass::Number });
466 i = end;
467 continue;
468 }
469 if is_ident_start(c) {
470 let end = ident_end(chars, i);
471 let word: String = chars[i..end].iter().collect();
472 if matches!(word.as_str(), "true" | "false" | "null") {
473 spans.push(Span { start: i, end, class: TokenClass::Constant });
474 }
475 i = end;
476 continue;
477 }
478 i += 1;
479 }
480 spans
481}
482
483fn scan_key_value(chars: &[char]) -> Vec<Span> {
485 let mut spans = Vec::new();
486 let mut i = 0;
487 while i < chars.len() {
488 let c = chars[i];
489 if c == '#' || c == ';' {
490 let end = line_end(chars, i);
491 spans.push(Span { start: i, end, class: TokenClass::Comment });
492 i = end;
493 continue;
494 }
495 if c == '[' && only_space_before(chars, i) {
496 let end = chars[i..]
497 .iter()
498 .position(|&c| c == ']')
499 .map(|p| i + p + 1)
500 .unwrap_or(chars.len());
501 spans.push(Span { start: i, end, class: TokenClass::Tag });
502 i = end;
503 continue;
504 }
505 if c == '"' || c == '\'' {
506 let end = string_end(chars, i, c);
507 let class = if key_position(chars, i) {
509 TokenClass::Attribute
510 } else {
511 TokenClass::String
512 };
513 spans.push(Span { start: i, end, class });
514 i = end;
515 continue;
516 }
517 if is_ident_start(c) {
518 let end = ident_end(chars, i);
519 let after = skip_space(chars, end);
520 if chars.get(after) == Some(&'=') && key_position(chars, i) {
521 spans.push(Span { start: i, end, class: TokenClass::Attribute });
522 } else {
523 let word: String = chars[i..end].iter().collect();
524 if matches!(word.as_str(), "true" | "false") {
525 spans.push(Span { start: i, end, class: TokenClass::Constant });
526 }
527 }
528 i = end;
529 continue;
530 }
531 if c.is_ascii_digit() {
532 let end = number_end(chars, i);
533 spans.push(Span { start: i, end, class: TokenClass::Number });
534 i = end;
535 continue;
536 }
537 i += 1;
538 }
539 spans
540}
541
542fn scan_yaml(chars: &[char]) -> Vec<Span> {
544 let mut spans = Vec::new();
545 let mut i = 0;
546 while i < chars.len() {
547 let c = chars[i];
548 if c == '#' {
549 let end = line_end(chars, i);
550 spans.push(Span { start: i, end, class: TokenClass::Comment });
551 i = end;
552 continue;
553 }
554 if c == '"' || c == '\'' {
555 let end = string_end(chars, i, c);
556 spans.push(Span { start: i, end, class: TokenClass::String });
557 i = end;
558 continue;
559 }
560 if is_ident_start(c) && key_position(chars, i) {
561 let end = ident_end(chars, i);
562 let after = skip_space(chars, end);
563 if chars.get(after) == Some(&':') {
564 spans.push(Span { start: i, end, class: TokenClass::Attribute });
565 }
566 i = end;
567 continue;
568 }
569 if c.is_ascii_digit() {
570 let end = number_end(chars, i);
571 spans.push(Span { start: i, end, class: TokenClass::Number });
572 i = end;
573 continue;
574 }
575 i += 1;
576 }
577 spans
578}
579
580fn key_position(chars: &[char], i: usize) -> bool {
583 let mut j = i;
584 while j > 0 {
585 let c = chars[j - 1];
586 if c == '\n' {
587 break;
588 }
589 if !c.is_whitespace() && c != '-' {
590 return false;
591 }
592 j -= 1;
593 }
594 true
595}
596
597fn scan_markup(chars: &[char]) -> Vec<Span> {
602 let mut spans = Vec::new();
603 let mut i = 0;
604 while i < chars.len() {
605 if matches_at(chars, i, "<!--") {
606 let start = i;
607 i += 4;
608 while i < chars.len() && !matches_at(chars, i, "-->") {
609 i += 1;
610 }
611 i = (i + 3).min(chars.len());
612 spans.push(Span { start, end: i, class: TokenClass::Comment });
613 continue;
614 }
615 if chars[i] == '<' {
616 i = scan_tag(chars, i, &mut spans);
617 continue;
618 }
619 if chars[i] == '&'
620 && let Some(end) = chars[i..].iter().position(|&c| c == ';').map(|p| i + p + 1)
621 && end - i <= 12
622 {
623 spans.push(Span { start: i, end, class: TokenClass::Constant });
624 i = end;
625 continue;
626 }
627 i += 1;
628 }
629 spans
630}
631
632fn scan_tag(chars: &[char], open: usize, spans: &mut Vec<Span>) -> usize {
636 let mut i = open + 1;
637 if chars.get(i) == Some(&'/') {
638 i += 1;
639 }
640 if chars.get(i) == Some(&'!') {
641 let mut k = i + 1;
643 while k < chars.len() && chars[k] != '>' {
644 k += 1;
645 }
646 k = (k + 1).min(chars.len());
647 spans.push(Span { start: open, end: k, class: TokenClass::Preprocessor });
648 return k;
649 }
650 let name_start = i;
651 while i < chars.len() && is_tag_name_char(chars[i]) {
652 i += 1;
653 }
654 if i > name_start {
655 spans.push(Span { start: name_start, end: i, class: TokenClass::Tag });
656 }
657 while i < chars.len() && chars[i] != '>' {
658 if matches_at(chars, i, "/>") {
659 return i + 2;
660 }
661 if chars[i].is_whitespace() || chars[i] == '/' {
662 i += 1;
663 continue;
664 }
665 if chars[i] == '"' || chars[i] == '\'' {
666 let end = string_end(chars, i, chars[i]);
667 spans.push(Span { start: i, end, class: TokenClass::String });
668 i = end;
669 continue;
670 }
671 let attr_start = i;
672 while i < chars.len() && is_tag_name_char(chars[i]) {
673 i += 1;
674 }
675 if i > attr_start {
676 spans.push(Span { start: attr_start, end: i, class: TokenClass::Attribute });
677 } else {
678 i += 1;
679 }
680 }
681 (i + 1).min(chars.len())
682}
683
684fn is_tag_name_char(c: char) -> bool {
685 c.is_alphanumeric() || matches!(c, '-' | '_' | ':' | '.')
686}
687
688fn scan_css(chars: &[char]) -> Vec<Span> {
693 let mut spans = Vec::new();
694 let mut i = 0;
695 let mut in_block = false;
696 while i < chars.len() {
697 let c = chars[i];
698 if matches_at(chars, i, "/*") {
699 let start = i;
700 i += 2;
701 while i < chars.len() && !matches_at(chars, i, "*/") {
702 i += 1;
703 }
704 i = (i + 2).min(chars.len());
705 spans.push(Span { start, end: i, class: TokenClass::Comment });
706 continue;
707 }
708 if c == '"' || c == '\'' {
709 let end = string_end(chars, i, c);
710 spans.push(Span { start: i, end, class: TokenClass::String });
711 i = end;
712 continue;
713 }
714 if c == '{' {
715 in_block = true;
716 i += 1;
717 continue;
718 }
719 if c == '}' {
720 in_block = false;
721 i += 1;
722 continue;
723 }
724 if c == '@' {
725 let end = ident_end(chars, i + 1);
726 spans.push(Span { start: i, end, class: TokenClass::Keyword });
727 i = end;
728 continue;
729 }
730 if c == '#' && chars.get(i + 1).is_some_and(|d| d.is_ascii_hexdigit()) {
731 let mut end = i + 1;
732 while end < chars.len() && chars[end].is_ascii_hexdigit() {
733 end += 1;
734 }
735 spans.push(Span { start: i, end, class: TokenClass::Number });
736 i = end;
737 continue;
738 }
739 if !in_block {
740 if is_ident_start(c) || c == '.' || c == '#' || c == ':' {
741 let start = i;
742 while i < chars.len()
743 && !matches!(chars[i], '{' | '}' | '\n' | ',' | '(' | ')')
744 && !chars[i].is_whitespace()
745 && !matches_at(chars, i, "/*")
746 {
747 i += 1;
748 }
749 if i > start {
750 spans.push(Span { start, end: i, class: TokenClass::Tag });
751 }
752 continue;
753 }
754 } else if is_ident_start(c) {
755 let end = ident_end(chars, i);
756 let after = skip_space(chars, end);
757 if chars.get(after) == Some(&':') {
758 spans.push(Span { start: i, end, class: TokenClass::Attribute });
759 }
760 i = end;
761 continue;
762 }
763 i += 1;
764 }
765 spans
766}
767
768fn scan_markdown(chars: &[char]) -> Vec<Span> {
773 let mut spans = Vec::new();
774 let mut line_start = 0;
775 let mut in_fence = false;
776 while line_start < chars.len() {
777 let end = line_end(chars, line_start);
778 let line = &chars[line_start..end];
779 if is_fence(line) {
780 spans.push(Span { start: line_start, end, class: TokenClass::Keyword });
781 in_fence = !in_fence;
782 line_start = end + 1;
783 continue;
784 }
785 if in_fence {
786 spans.push(Span { start: line_start, end, class: TokenClass::String });
787 line_start = end + 1;
788 continue;
789 }
790 let indent = line.iter().take_while(|c| **c == ' ').count();
792 if indent <= 3 {
793 let hashes = line[indent..].iter().take_while(|c| **c == '#').count();
794 if (1..=6).contains(&hashes) && line.get(indent + hashes).is_none_or(|c| *c == ' ') {
795 spans.push(Span { start: line_start, end, class: TokenClass::Heading });
796 line_start = end + 1;
797 continue;
798 }
799 if line.get(indent) == Some(&'>') {
800 spans.push(Span { start: line_start, end, class: TokenClass::Comment });
801 line_start = end + 1;
802 continue;
803 }
804 }
805 scan_markdown_inline(chars, line_start, end, &mut spans);
806 line_start = end + 1;
807 }
808 spans
809}
810
811fn is_fence(line: &[char]) -> bool {
812 matches_at(line, 0, "```") || matches_at(line, 0, "~~~")
813}
814
815fn scan_markdown_inline(chars: &[char], start: usize, end: usize, spans: &mut Vec<Span>) {
816 let mut i = start;
817 while i < end {
818 if chars[i] == '`'
819 && let Some(close) = (i + 1..end).find(|&k| chars[k] == '`')
820 {
821 spans.push(Span { start: i, end: close + 1, class: TokenClass::String });
822 i = close + 1;
823 continue;
824 }
825 if chars[i] == '['
826 && let Some(close) = (i + 1..end).find(|&k| chars[k] == ']')
827 && chars.get(close + 1) == Some(&'(')
828 && let Some(paren) = (close + 2..end).find(|&k| chars[k] == ')')
829 {
830 spans.push(Span { start: i, end: paren + 1, class: TokenClass::Link });
831 i = paren + 1;
832 continue;
833 }
834 i += 1;
835 }
836}
837
838fn char_len(s: &str) -> usize {
844 s.chars().count()
845}
846
847fn line_end(chars: &[char], from: usize) -> usize {
848 let mut i = from;
849 while i < chars.len() && chars[i] != '\n' {
850 i += 1;
851 }
852 i
853}
854
855fn skip_space(chars: &[char], from: usize) -> usize {
856 let mut i = from;
857 while i < chars.len() && chars[i].is_whitespace() {
858 i += 1;
859 }
860 i
861}
862
863fn only_space_before(chars: &[char], i: usize) -> bool {
865 let mut j = i;
866 while j > 0 {
867 if chars[j - 1] == '\n' {
868 break;
869 }
870 if !chars[j - 1].is_whitespace() {
871 return false;
872 }
873 j -= 1;
874 }
875 true
876}
877
878fn matches_at(chars: &[char], i: usize, pattern: &str) -> bool {
879 for (j, pc) in (i..).zip(pattern.chars()) {
880 if chars.get(j) != Some(&pc) {
881 return false;
882 }
883 }
884 true
885}
886
887fn is_ident_start(c: char) -> bool {
888 c == '_' || c.is_alphabetic()
889}
890
891fn is_ident_char(c: char) -> bool {
892 c == '_' || c.is_alphanumeric()
893}
894
895fn ident_end(chars: &[char], start: usize) -> usize {
896 let mut i = start;
897 while i < chars.len() && is_ident_char(chars[i]) {
898 i += 1;
899 }
900 i
901}
902
903const RUST: Syntax = Syntax {
908 line_comments: &["//"],
909 block_comment: Some(("/*", "*/")),
910 quotes: &['"'],
911 keywords: &[
912 "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
913 "extern", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut",
914 "pub", "ref", "return", "static", "struct", "super", "trait", "type", "unsafe", "use",
915 "where", "while", "yield", "macro_rules",
916 ],
917 types: &[
918 "bool", "char", "f32", "f64", "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16",
919 "u32", "u64", "u128", "usize", "str", "String", "Vec", "Option", "Result", "Box", "Rc",
920 "Arc", "RefCell", "HashMap", "HashSet", "BTreeMap", "Cow", "PathBuf", "Path",
921 ],
922 constants: &["true", "false", "None", "Self"],
923 hash_line: false,
924 hash_bracket: true,
925 at_line: false,
926 rust_strings: true,
927 python_strings: false,
928};
929
930const C_LIKE: Syntax = Syntax {
931 line_comments: &["//"],
932 block_comment: Some(("/*", "*/")),
933 quotes: &['"', '\''],
934 keywords: &[
935 "auto", "break", "case", "const", "continue", "default", "do", "else", "enum", "extern",
936 "for", "goto", "if", "inline", "register", "restrict", "return", "sizeof", "static",
937 "struct", "switch", "typedef", "union", "volatile", "while", "class", "namespace",
938 "template", "typename", "using", "public", "private", "protected", "virtual", "override",
939 "new", "delete", "this", "operator", "friend", "constexpr", "try", "catch", "throw",
940 "noexcept", "nullptr", "true", "false",
941 ],
942 types: &[
943 "void", "bool", "char", "short", "int", "long", "float", "double", "signed", "unsigned",
944 "size_t", "ssize_t", "int8_t", "int16_t", "int32_t", "int64_t", "uint8_t", "uint16_t",
945 "uint32_t", "uint64_t", "intptr_t", "uintptr_t", "wchar_t", "auto",
946 ],
947 constants: &["NULL", "nullptr", "true", "false"],
948 hash_line: true,
949 hash_bracket: false,
950 at_line: false,
951 rust_strings: false,
952 python_strings: false,
953};
954
955const JAVASCRIPT: Syntax = Syntax {
956 line_comments: &["//"],
957 block_comment: Some(("/*", "*/")),
958 quotes: &['"', '\'', '`'],
959 keywords: &[
960 "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete",
961 "do", "else", "export", "extends", "finally", "for", "function", "if", "import", "in",
962 "instanceof", "let", "new", "return", "super", "switch", "this", "throw", "try",
963 "typeof", "var", "void", "while", "with", "yield", "async", "await", "of", "static",
964 "get", "set", "as", "from",
965 ],
966 types: &["number", "string", "boolean", "object", "symbol", "bigint", "undefined"],
967 constants: &["true", "false", "null", "undefined", "NaN", "Infinity"],
968 hash_line: false,
969 hash_bracket: false,
970 at_line: false,
971 rust_strings: false,
972 python_strings: false,
973};
974
975const TYPESCRIPT: Syntax = Syntax {
976 keywords: &[
977 "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete",
978 "do", "else", "export", "extends", "finally", "for", "function", "if", "import", "in",
979 "instanceof", "let", "new", "return", "super", "switch", "this", "throw", "try",
980 "typeof", "var", "void", "while", "with", "yield", "async", "await", "of", "static",
981 "get", "set", "as", "from", "interface", "type", "enum", "namespace", "declare",
982 "abstract", "implements", "private", "public", "protected", "readonly", "keyof", "infer",
983 "is", "asserts", "satisfies", "override",
984 ],
985 ..JAVASCRIPT
986};
987
988const PYTHON: Syntax = Syntax {
989 line_comments: &["#"],
990 block_comment: None,
991 quotes: &['"', '\''],
992 keywords: &[
993 "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",
994 "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is",
995 "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with",
996 "yield", "match", "case",
997 ],
998 types: &["int", "float", "str", "bool", "list", "dict", "set", "tuple", "bytes", "object"],
999 constants: &["True", "False", "None", "self", "cls"],
1000 hash_line: false,
1001 hash_bracket: false,
1002 at_line: true,
1003 rust_strings: false,
1004 python_strings: true,
1005};
1006
1007const SHELL: Syntax = Syntax {
1008 line_comments: &["#"],
1009 block_comment: None,
1010 quotes: &['"', '\''],
1011 keywords: &[
1012 "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac",
1013 "in", "function", "select", "time", "coproc", "local", "export", "readonly", "declare",
1014 "unset", "shift", "return", "continue", "break", "source", "alias",
1015 ],
1016 types: &["echo", "cd", "pwd", "printf", "read", "set", "test", "trap", "eval", "exec"],
1017 constants: &["true", "false"],
1018 hash_line: false,
1019 hash_bracket: false,
1020 at_line: false,
1021 rust_strings: false,
1022 python_strings: false,
1023};
1024
1025const GO: Syntax = Syntax {
1026 line_comments: &["//"],
1027 block_comment: Some(("/*", "*/")),
1028 quotes: &['"', '\'', '`'],
1029 keywords: &[
1030 "break", "case", "chan", "const", "continue", "default", "defer", "else", "fallthrough",
1031 "for", "func", "go", "goto", "if", "import", "interface", "map", "package", "range",
1032 "return", "select", "struct", "switch", "type", "var",
1033 ],
1034 types: &[
1035 "bool", "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16",
1036 "uint32", "uint64", "uintptr", "byte", "rune", "float32", "float64", "complex64",
1037 "complex128", "error", "any",
1038 ],
1039 constants: &["true", "false", "nil", "iota"],
1040 hash_line: false,
1041 hash_bracket: false,
1042 at_line: false,
1043 rust_strings: false,
1044 python_strings: false,
1045};
1046
1047#[cfg(test)]
1048mod tests {
1049 use super::*;
1050
1051 fn classes(spans: &[Span]) -> Vec<TokenClass> {
1052 spans.iter().map(|s| s.class).collect()
1053 }
1054
1055 fn text_of(src: &str, spans: &[Span]) -> Vec<String> {
1056 let chars: Vec<char> = src.chars().collect();
1057 spans
1058 .iter()
1059 .map(|s| chars[s.start..s.end].iter().collect())
1060 .collect()
1061 }
1062
1063 #[test]
1064 fn detects_languages_by_extension() {
1065 assert_eq!(language_for(Path::new("a.rs")), Language::Rust);
1066 assert_eq!(language_for(Path::new("a.PY")), Language::Python);
1067 assert_eq!(language_for(Path::new("a.toml")), Language::KeyValue);
1068 assert_eq!(language_for(Path::new("a.bin")), Language::Plain);
1069 assert_eq!(language_for(Path::new("noext")), Language::Plain);
1070 }
1071
1072 #[test]
1073 fn rust_highlights_keywords_strings_comments_attributes() {
1074 let src = "// hi\n#[derive(Debug)]\nfn main() { let s = \"x\"; let n = 42; }";
1075 let spans = highlight(Language::Rust, src);
1076 let found = text_of(src, &spans);
1077 assert!(found.iter().any(|t| t == "// hi"));
1078 assert!(found.iter().any(|t| t == "#[derive(Debug)]"));
1079 assert!(found.iter().any(|t| t == "fn"));
1080 assert!(found.iter().any(|t| t == "\"x\""));
1081 assert!(found.iter().any(|t| t == "42"));
1082 assert!(classes(&spans).contains(&TokenClass::Keyword));
1083 assert!(classes(&spans).contains(&TokenClass::Preprocessor));
1084 }
1085
1086 #[test]
1087 fn python_highlights_decorators_and_triple_strings() {
1088 let src = "@decorator\ndef f():\n x = \"\"\"multi\nline\"\"\"\n return 1";
1089 let spans = highlight(Language::Python, src);
1090 let found = text_of(src, &spans);
1091 assert!(found.iter().any(|t| t == "@decorator"));
1092 assert!(found.iter().any(|t| t.contains("multi")));
1093 assert!(found.iter().any(|t| t == "def"));
1094 }
1095
1096 #[test]
1097 fn json_distinguishes_keys_from_values() {
1098 let src = "{ \"name\": \"value\", \"n\": 1 }";
1099 let spans = highlight(Language::Json, src);
1100 let key = spans
1101 .iter()
1102 .find(|s| s.class == TokenClass::Attribute)
1103 .map(|s| text_of(src, std::slice::from_ref(s)).remove(0));
1104 assert_eq!(key.as_deref(), Some("\"name\""));
1105 }
1106
1107 #[test]
1108 fn toml_highlights_sections_and_keys() {
1109 let src = "[panel]\nwidth = 300\nname = \"tree\"\n";
1110 let spans = highlight(Language::KeyValue, src);
1111 let found = text_of(src, &spans);
1112 assert!(found.iter().any(|t| t == "[panel]"));
1113 assert!(found.iter().any(|t| t == "width"));
1114 assert!(found.iter().any(|t| t == "300"));
1115 assert!(found.iter().any(|t| t == "\"tree\""));
1116 }
1117
1118 #[test]
1119 fn yaml_highlights_keys() {
1120 let src = "name: value\nlist:\n - one\n";
1121 let spans = highlight(Language::Yaml, src);
1122 let found = text_of(src, &spans);
1123 assert!(found.iter().any(|t| t == "name"));
1124 assert!(found.iter().any(|t| t == "list"));
1125 }
1126
1127 #[test]
1128 fn markup_highlights_tags_and_attributes() {
1129 let src = "<a href=\"x\">text</a>";
1130 let spans = highlight(Language::Markup, src);
1131 let found = text_of(src, &spans);
1132 assert!(found.iter().any(|t| t == "a"));
1133 assert!(found.iter().any(|t| t == "href"));
1134 assert!(found.iter().any(|t| t == "\"x\""));
1135 }
1136
1137 #[test]
1138 fn css_highlights_at_rules_and_properties() {
1139 let src = "/* c */\n.foo { color: #fff; }";
1140 let spans = highlight(Language::Css, src);
1141 let found = text_of(src, &spans);
1142 assert!(found.iter().any(|t| t == "/* c */"));
1143 assert!(found.iter().any(|t| t == "color"));
1144 assert!(found.iter().any(|t| t == "#fff"));
1145 }
1146
1147 #[test]
1148 fn markdown_highlights_headings_code_and_links() {
1149 let src = "# Title\n\nSee [docs](http://x) and `code`.\n";
1150 let spans = highlight(Language::Markdown, src);
1151 let found = text_of(src, &spans);
1152 assert!(found.iter().any(|t| t == "# Title"));
1153 assert!(found.iter().any(|t| t == "[docs](http://x)"));
1154 assert!(found.iter().any(|t| t == "`code`"));
1155 }
1156
1157 #[test]
1158 fn spans_are_sorted_bounded_and_non_overlapping() {
1159 let src = "fn f() { // c\n \"s\" 1 }";
1160 let spans = highlight(Language::CLike, src);
1161 let mut last = 0;
1162 for span in &spans {
1163 assert!(span.start < span.end, "empty span: {span:?}");
1164 assert!(span.start >= last, "overlap/unsorted at {span:?}");
1165 last = span.end;
1166 }
1167 assert!(spans.iter().all(|s| s.end <= src.chars().count()));
1168 }
1169
1170 #[test]
1171 fn plain_language_has_no_spans() {
1172 assert!(highlight(Language::Plain, "anything at all").is_empty());
1173 }
1174}