1use crate::language::{LanguageHint, resolve_language};
4use arborium::{Config, Highlighter};
5use arborium_highlight::spans_to_flat_tokens;
6use arborium_theme::tag_to_name;
7use clankerdiff_fingerprint::SourceSequenceId;
8use clankerdiff_theme::{DiffTheme, Fingerprint, HighlightSpan, SyntaxTheme};
9use std::{
10 collections::{HashMap, VecDeque},
11 fmt,
12 ops::Range,
13 sync::{Arc, OnceLock},
14};
15const DEFAULT_CAPACITY: usize = 512;
16const DEFAULT_MAX_DOCUMENTS: usize = 32;
17const DEFAULT_STREAM_BYTES: usize = 8 * 1024 * 1024;
18const SOURCE_KEY_DOMAIN: &[u8] = b"syntax-source-v1";
19const DOCUMENT_KEY_DOMAIN: &[u8] = b"syntax-document-v1";
20
21#[must_use]
23pub fn empty_spans() -> Arc<[HighlightSpan]> {
24 static EMPTY: OnceLock<Arc<[HighlightSpan]>> = OnceLock::new();
25 Arc::clone(EMPTY.get_or_init(|| Arc::from(Vec::<HighlightSpan>::new())))
26}
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub struct HighlightStats {
31 pub calls: u64,
32 pub hits: u64,
33 pub misses: u64,
34 pub evictions: u64,
35 pub bytes: usize,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub struct CacheKey {
42 fingerprint: Fingerprint,
43}
44
45impl CacheKey {
46 #[must_use]
47 pub const fn fingerprint(self) -> Fingerprint {
48 self.fingerprint
49 }
50
51 fn source(theme: Fingerprint, language: &str, source: &str) -> Self {
52 Self::new([
53 SOURCE_KEY_DOMAIN,
54 theme.as_bytes().as_slice(),
55 language.as_bytes(),
56 source.as_bytes(),
57 ])
58 }
59
60 fn document(theme: Fingerprint, language: &str, sequence: SourceSequenceId) -> Self {
61 let sequence = Fingerprint::from(sequence);
62 Self::new([
63 DOCUMENT_KEY_DOMAIN,
64 theme.as_bytes().as_slice(),
65 language.as_bytes(),
66 sequence.as_bytes().as_slice(),
67 ])
68 }
69
70 fn new<const N: usize>(fields: [&[u8]; N]) -> Self {
71 Self {
72 fingerprint: Fingerprint::of(fields),
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct CacheConfig {
80 pub max_entries: usize,
81 pub max_documents: usize,
82 pub max_stream_bytes: usize,
83}
84
85impl Default for CacheConfig {
86 fn default() -> Self {
87 Self {
88 max_entries: DEFAULT_CAPACITY,
89 max_documents: DEFAULT_MAX_DOCUMENTS,
90 max_stream_bytes: DEFAULT_STREAM_BYTES,
91 }
92 }
93}
94
95impl From<usize> for CacheConfig {
96 fn from(max_entries: usize) -> Self {
97 Self {
98 max_entries,
99 ..Self::default()
100 }
101 }
102}
103
104#[derive(Debug, Clone, Default)]
105pub struct SyntaxStream {
106 hint: String,
107 source: String,
108 revision: u64,
109 theme_revision: Option<Fingerprint>,
110 highlights: Arc<DocumentHighlights>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
114pub enum SyntaxStreamError {
115 #[error("syntax stream input requires {attempted} bytes, exceeding the {limit}-byte limit")]
116 InputLimit { limit: usize, attempted: usize },
117}
118
119#[derive(Debug, Clone)]
120pub struct SyntaxStreamUpdate {
121 pub base_revision: u64,
122 pub revision: u64,
123 pub changed_lines: Range<usize>,
124 pub highlights: Arc<DocumentHighlights>,
125}
126
127impl SyntaxStream {
128 #[must_use]
129 pub fn new<'a>(hint: impl Into<LanguageHint<'a>>) -> Self {
130 Self {
131 hint: hint.into().as_str().to_owned(),
132 ..Self::default()
133 }
134 }
135
136 #[must_use]
137 pub fn source(&self) -> &str {
138 &self.source
139 }
140
141 #[must_use]
142 pub const fn revision(&self) -> u64 {
143 self.revision
144 }
145
146 #[must_use]
147 pub fn highlights(&self) -> &Arc<DocumentHighlights> {
148 &self.highlights
149 }
150}
151
152#[derive(Debug, Clone, Default)]
154pub struct DocumentHighlights {
155 lines: Vec<Arc<[HighlightSpan]>>,
156}
157
158impl DocumentHighlights {
159 #[must_use]
160 pub fn line(&self, index: usize) -> Option<&[HighlightSpan]> {
161 self.lines.get(index).map(AsRef::as_ref)
162 }
163
164 #[must_use]
165 pub fn line_shared(&self, index: usize) -> Option<Arc<[HighlightSpan]>> {
166 self.lines.get(index).cloned()
167 }
168
169 #[must_use]
170 pub fn line_count(&self) -> usize {
171 self.lines.len()
172 }
173
174 fn from_spans(spans: &[HighlightSpan], text: &str) -> Self {
175 let starts = if text.is_empty() {
176 Vec::new()
177 } else {
178 let mut starts = vec![0];
179 starts.extend(
180 text.bytes()
181 .enumerate()
182 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
183 );
184 if text.ends_with('\n') {
185 starts.pop();
186 }
187 starts
188 };
189 let mut lines = Vec::with_capacity(starts.len());
190 let mut first_span = 0;
191 for (line, &start) in starts.iter().enumerate() {
192 let next = starts.get(line + 1).copied().unwrap_or(text.len());
193 let mut end = next;
194 if end > start && text.as_bytes()[end - 1] == b'\n' {
195 end -= 1;
196 }
197 if end > start && text.as_bytes()[end - 1] == b'\r' {
198 end -= 1;
199 }
200 while spans
201 .get(first_span)
202 .is_some_and(|span| span.range.end <= start)
203 {
204 first_span += 1;
205 }
206 let projected = spans[first_span..]
207 .iter()
208 .take_while(|span| span.range.start < end)
209 .filter_map(|span| {
210 let from = span.range.start.max(start);
211 let to = span.range.end.min(end);
212 (from < to).then_some(HighlightSpan {
213 range: from - start..to - start,
214 foreground: span.foreground,
215 font_style: span.font_style,
216 })
217 })
218 .collect::<Vec<_>>();
219 lines.push(Arc::from(projected));
220 }
221 Self { lines }
222 }
223}
224
225pub struct SyntaxHighlighter {
227 highlighter: Highlighter,
228 config: CacheConfig,
229 cache: HashMap<CacheKey, Arc<[HighlightSpan]>>,
230 order: VecDeque<CacheKey>,
231 documents: HashMap<CacheKey, Arc<DocumentHighlights>>,
232 document_order: VecDeque<CacheKey>,
233 stats: HighlightStats,
234}
235
236impl fmt::Debug for SyntaxHighlighter {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.debug_struct("SyntaxHighlighter")
239 .field("config", &self.config)
240 .field("entries", &self.cache.len())
241 .field("documents", &self.documents.len())
242 .field("stats", &self.stats)
243 .finish_non_exhaustive()
244 }
245}
246
247impl Default for SyntaxHighlighter {
248 fn default() -> Self {
249 Self::new(DEFAULT_CAPACITY)
250 }
251}
252
253impl SyntaxHighlighter {
254 #[must_use]
256 pub fn new(config: impl Into<CacheConfig>) -> Self {
257 let syntax_config = Config {
258 max_injection_depth: 3,
259 ..Config::default()
260 };
261 Self {
262 highlighter: Highlighter::with_config(syntax_config),
263 config: config.into(),
264 cache: HashMap::new(),
265 order: VecDeque::new(),
266 documents: HashMap::new(),
267 document_order: VecDeque::new(),
268 stats: HighlightStats::default(),
269 }
270 }
271
272 #[must_use]
273 pub const fn stats(&self) -> HighlightStats {
274 self.stats
275 }
276
277 pub fn reset_stats(&mut self) {
278 self.stats = HighlightStats::default();
279 }
280
281 pub fn take_stats(&mut self) -> HighlightStats {
283 std::mem::take(&mut self.stats)
284 }
285
286 #[must_use]
287 pub const fn config(&self) -> CacheConfig {
288 self.config
289 }
290
291 #[must_use]
292 pub fn with_theme<'a>(&'a mut self, theme: &'a SyntaxTheme) -> ThemedHighlighter<'a> {
293 ThemedHighlighter {
294 highlighter: self,
295 theme,
296 }
297 }
298
299 pub fn clear_cache(&mut self) {
300 self.cache.clear();
301 self.order.clear();
302 self.documents.clear();
303 self.document_order.clear();
304 }
305
306 fn highlight_source(
307 &mut self,
308 theme: &SyntaxTheme,
309 hint: LanguageHint<'_>,
310 text: &str,
311 ) -> Arc<[HighlightSpan]> {
312 self.stats.calls += 1;
313 let language = resolve_language(hint, text);
314 let id = language.unwrap_or("plain");
315 let key = CacheKey::source(theme.revision(), id, text);
316 if let Some(spans) = self.cache.get(&key) {
317 self.stats.hits += 1;
318 return Arc::clone(spans);
319 }
320 self.stats.misses += 1;
321 let Some(language) = language else {
322 let spans = empty_spans();
323 self.store(key, Arc::clone(&spans));
324 return spans;
325 };
326 self.stats.bytes = self.stats.bytes.saturating_add(text.len());
327 let spans = highlight_source(&mut self.highlighter, theme, language, text)
328 .map_or_else(empty_spans, Arc::from);
329 self.store(key, Arc::clone(&spans));
330 spans
331 }
332
333 fn highlight_lines<'line, T>(
334 &mut self,
335 theme: &SyntaxTheme,
336 hint: LanguageHint<'_>,
337 lines: T,
338 ) -> Vec<Vec<HighlightSpan>>
339 where
340 T: IntoIterator<Item = &'line str>,
341 {
342 self.stats.calls += 1;
343 let selected: Vec<(usize, &str)> = lines.into_iter().enumerate().collect();
344 let window = JoinedLines::new(selected);
345 let Some(language) = resolve_language(hint, &window.source) else {
346 return vec![Vec::new(); window.lines.len()];
347 };
348 self.stats.bytes = self.stats.bytes.saturating_add(window.source.len());
349 highlight_source(&mut self.highlighter, theme, language, &window.source)
350 .as_deref()
351 .map_or_else(
352 || vec![Vec::new(); window.lines.len()],
353 |spans| window.split(spans),
354 )
355 }
356
357 fn store(&mut self, key: CacheKey, spans: Arc<[HighlightSpan]>) {
358 if self.config.max_entries == 0 {
359 return;
360 }
361 if self.cache.insert(key, spans).is_some() {
362 return;
363 }
364 self.order.push_back(key);
365 while self.cache.len() > self.config.max_entries {
366 let Some(evicted) = self.order.pop_front() else {
367 break;
368 };
369 if self.cache.remove(&evicted).is_some() {
370 self.stats.evictions += 1;
371 }
372 }
373 }
374
375 fn store_document(&mut self, key: CacheKey, highlights: Arc<DocumentHighlights>) {
376 if self.config.max_entries == 0 || self.config.max_documents == 0 {
377 return;
378 }
379 if self.documents.insert(key, highlights).is_some() {
380 return;
381 }
382 self.document_order.push_back(key);
383 while self.documents.len() > self.config.max_documents {
384 let Some(evicted) = self.document_order.pop_front() else {
385 break;
386 };
387 if self.documents.remove(&evicted).is_some() {
388 self.stats.evictions += 1;
389 }
390 }
391 }
392}
393
394pub struct ThemedHighlighter<'a> {
396 highlighter: &'a mut SyntaxHighlighter,
397 theme: &'a SyntaxTheme,
398}
399
400impl ThemedHighlighter<'_> {
401 pub fn highlight_document<'a>(
403 &mut self,
404 sequence: SourceSequenceId,
405 language: impl Into<LanguageHint<'a>>,
406 text: &str,
407 ) -> Arc<DocumentHighlights> {
408 self.highlighter.stats.calls += 1;
409 let resolved = resolve_language(language.into(), text);
410 let key = CacheKey::document(self.theme.revision(), resolved.unwrap_or("plain"), sequence);
411 if let Some(highlights) = self.highlighter.documents.get(&key) {
412 self.highlighter.stats.hits += 1;
413 return Arc::clone(highlights);
414 }
415 self.parse_document(key, resolved, text)
416 }
417
418 pub fn highlight_document_lines<'a, 'line>(
421 &mut self,
422 sequence: SourceSequenceId,
423 language: impl Into<LanguageHint<'a>>,
424 lines: impl IntoIterator<Item = &'line str>,
425 ) -> Arc<DocumentHighlights> {
426 self.highlighter.stats.calls += 1;
427 let mut lines = lines.into_iter().peekable();
428 let resolved = resolve_language(language.into(), lines.peek().copied().unwrap_or_default());
429 let key = CacheKey::document(self.theme.revision(), resolved.unwrap_or("plain"), sequence);
430 if let Some(highlights) = self.highlighter.documents.get(&key) {
431 self.highlighter.stats.hits += 1;
432 return Arc::clone(highlights);
433 }
434 let mut text = String::new();
435 for line in lines {
436 text.push_str(line);
437 text.push('\n');
438 }
439 self.parse_document(key, resolved, &text)
440 }
441
442 fn parse_document(
443 &mut self,
444 key: CacheKey,
445 resolved: Option<&str>,
446 text: &str,
447 ) -> Arc<DocumentHighlights> {
448 self.highlighter.stats.misses += 1;
449 let highlights = Arc::new(self.project(resolved, text));
450 self.highlighter
451 .store_document(key, Arc::clone(&highlights));
452 highlights
453 }
454
455 fn project(&mut self, resolved: Option<&str>, text: &str) -> DocumentHighlights {
456 let spans = resolved
457 .and_then(|language| {
458 self.highlighter.stats.bytes += text.len();
459 highlight_source(
460 &mut self.highlighter.highlighter,
461 self.theme,
462 language,
463 text,
464 )
465 })
466 .unwrap_or_default();
467 DocumentHighlights::from_spans(&spans, text)
468 }
469
470 pub fn highlight_source<'a>(
472 &mut self,
473 language: impl Into<LanguageHint<'a>>,
474 text: &str,
475 ) -> Arc<[HighlightSpan]> {
476 self.highlighter
477 .highlight_source(self.theme, language.into(), text)
478 }
479
480 pub fn highlight_lines<'line, 'hint, T>(
482 &mut self,
483 language: impl Into<LanguageHint<'hint>>,
484 lines: T,
485 ) -> Vec<Vec<HighlightSpan>>
486 where
487 T: IntoIterator<Item = &'line str>,
488 {
489 self.highlighter
490 .highlight_lines(self.theme, language.into(), lines)
491 }
492
493 pub fn append<'line>(
494 &mut self,
495 stream: &mut SyntaxStream,
496 lines: impl IntoIterator<Item = &'line str>,
497 ) -> Result<SyntaxStreamUpdate, SyntaxStreamError> {
498 let limit = self.highlighter.config.max_stream_bytes;
499 let mut appended = String::new();
500 for line in lines {
501 let newline = usize::from(!line.ends_with('\n'));
502 let attempted = stream.source.len() + appended.len() + line.len() + newline;
503 if attempted > limit {
504 return Err(SyntaxStreamError::InputLimit { limit, attempted });
505 }
506 appended.push_str(line);
507 if newline != 0 {
508 appended.push('\n');
509 }
510 }
511 let base_revision = stream.revision;
512 let theme_revision = self.theme.revision();
513 if appended.is_empty() && stream.theme_revision == Some(theme_revision) {
514 let end = stream.highlights.line_count();
515 return Ok(SyntaxStreamUpdate {
516 base_revision,
517 revision: stream.revision,
518 changed_lines: end..end,
519 highlights: Arc::clone(&stream.highlights),
520 });
521 }
522 if !appended.is_empty() {
523 stream.source.push_str(&appended);
524 stream.revision = stream.revision.wrapping_add(1);
525 }
526 self.highlighter.stats.calls += 1;
527 let resolved = resolve_language(stream.hint.as_str(), &stream.source);
528 let highlights = Arc::new(self.project(resolved, &stream.source));
529 let first_changed = stream
530 .highlights
531 .lines
532 .iter()
533 .zip(&highlights.lines)
534 .position(|(before, after)| before != after)
535 .unwrap_or_else(|| stream.highlights.line_count().min(highlights.line_count()));
536 stream.highlights = Arc::clone(&highlights);
537 stream.theme_revision = Some(theme_revision);
538 Ok(SyntaxStreamUpdate {
539 base_revision,
540 revision: stream.revision,
541 changed_lines: first_changed..highlights.line_count(),
542 highlights,
543 })
544 }
545}
546
547fn highlight_source(
548 highlighter: &mut Highlighter,
549 theme: &DiffTheme,
550 language: &str,
551 source: &str,
552) -> Option<Vec<HighlightSpan>> {
553 let raw_spans = highlighter.highlight_spans(language, source).ok()?;
554 let tokens = spans_to_flat_tokens(source, raw_spans);
555 let mut spans = Vec::with_capacity(tokens.len());
556 for token in tokens {
557 let Ok(start) = usize::try_from(token.start) else {
558 continue;
559 };
560 let Ok(end) = usize::try_from(token.end) else {
561 continue;
562 };
563 if start >= end
564 || end > source.len()
565 || !source.is_char_boundary(start)
566 || !source.is_char_boundary(end)
567 {
568 continue;
569 }
570 let Some(capture) = diff_capture_name(token.tag) else {
571 continue;
572 };
573 let Some(style) = theme.style(capture) else {
574 continue;
575 };
576 push_merged(
577 &mut spans,
578 HighlightSpan {
579 range: start..end,
580 foreground: style.foreground,
581 font_style: style.font_style,
582 },
583 );
584 }
585 Some(spans)
586}
587
588fn diff_capture_name(tag: &str) -> Option<&'static str> {
589 Some(match tag_to_name(tag)? {
590 "title" => "markup.heading",
591 "strong" => "markup.bold",
592 "emphasis" => "markup.italic",
593 "link" => "markup.link",
594 "literal" => "markup.raw",
595 "strikethrough" => "markup.strikethrough",
596 name => name,
597 })
598}
599
600fn push_merged(spans: &mut Vec<HighlightSpan>, span: HighlightSpan) {
601 if let Some(last) = spans.last_mut()
602 && last.range.end == span.range.start
603 && last.foreground == span.foreground
604 && last.font_style == span.font_style
605 {
606 last.range.end = span.range.end;
607 } else {
608 spans.push(span);
609 }
610}
611
612struct JoinedLines<'a> {
613 source: String,
614 lines: Vec<(usize, &'a str, usize, usize)>,
616}
617
618impl<'a> JoinedLines<'a> {
619 fn new(selected: Vec<(usize, &'a str)>) -> Self {
620 let mut source = String::new();
621 let mut lines = Vec::with_capacity(selected.len());
622 for (index, line) in selected {
623 let start = source.len();
624 source.push_str(line);
625 let end = source.len();
626 if !line.ends_with('\n') {
627 source.push('\n');
628 }
629 lines.push((index, line, start, end));
630 }
631 Self { source, lines }
632 }
633
634 fn split(&self, spans: &[HighlightSpan]) -> Vec<Vec<HighlightSpan>> {
639 let mut next = 0;
640 self.lines
641 .iter()
642 .map(|(_, line, start, end)| {
643 while spans.get(next).is_some_and(|span| span.range.end <= *start) {
644 next += 1;
645 }
646 let mut result = Vec::new();
647 for span in &spans[next..] {
648 if span.range.start >= *end {
649 break;
650 }
651 let overlap_start = span.range.start.max(*start);
652 let overlap_end = span.range.end.min(*end);
653 if overlap_start >= overlap_end {
654 continue;
655 }
656 let local_start = overlap_start - start;
657 let local_end = overlap_end - start;
658 if local_end <= line.len()
659 && line.is_char_boundary(local_start)
660 && line.is_char_boundary(local_end)
661 {
662 push_merged(
663 &mut result,
664 HighlightSpan {
665 range: local_start..local_end,
666 foreground: span.foreground,
667 font_style: span.font_style,
668 },
669 );
670 }
671 }
672 result
673 })
674 .collect()
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 fn assert_valid_spans(source: &str, spans: &[HighlightSpan]) {
683 let mut previous_end = 0;
684 for span in spans {
685 assert!(span.range.start < span.range.end, "empty span: {span:?}");
686 assert!(
687 span.range.end <= source.len(),
688 "out-of-bounds span: {span:?}"
689 );
690 assert!(source.is_char_boundary(span.range.start));
691 assert!(source.is_char_boundary(span.range.end));
692 assert!(
693 span.range.start >= previous_end,
694 "overlapping or unordered span: {span:?}"
695 );
696 previous_end = span.range.end;
697 }
698 }
699
700 #[test]
701 fn aliases_and_utf8_ranges() {
702 let theme = DiffTheme::default();
703 let mut highlighter = SyntaxHighlighter::new(2);
704 let source = "let café = 1;\n";
705 let spans = highlighter
706 .with_theme(&theme)
707 .highlight_source("rs", source);
708 assert!(!spans.is_empty());
709 assert!(
710 spans
711 .iter()
712 .all(|span| source.is_char_boundary(span.range.start)
713 && source.is_char_boundary(span.range.end))
714 );
715 let _ = highlighter
716 .with_theme(&theme)
717 .highlight_source("RUST", source);
718 assert_eq!(highlighter.stats().hits, 1);
719 }
720
721 #[test]
722 fn complete_documents_are_parsed_once_and_projected_by_line() {
723 let theme = DiffTheme::default();
724 let mut highlighter = SyntaxHighlighter::default();
725 let source = "/* alpha\r\nbeta */\nlet café = 1;\n";
726 let sequence = SourceSequenceId::from_lines(source.split_terminator('\n'));
727
728 let first = highlighter.with_theme(&theme).highlight_document(
729 sequence,
730 LanguageHint::Path("src/lib.rs"),
731 source,
732 );
733 assert_eq!(first.line_count(), 3);
734 assert!(!first.line(0).unwrap().is_empty());
735 assert!(!first.line(1).unwrap().is_empty());
736 assert!(
737 first
738 .line(2)
739 .unwrap()
740 .iter()
741 .all(|span| span.range.end <= "let café = 1;".len())
742 );
743 let parsed_bytes = highlighter.stats().bytes;
744
745 let second = highlighter.with_theme(&theme).highlight_document(
746 sequence,
747 LanguageHint::Path("src/lib.rs"),
748 source,
749 );
750 assert!(Arc::ptr_eq(&first, &second));
751 assert_eq!(highlighter.stats().hits, 1);
752 assert_eq!(highlighter.stats().bytes, parsed_bytes);
753
754 let empty = highlighter.with_theme(&theme).highlight_document(
755 SourceSequenceId::from_lines([]),
756 "rust",
757 "",
758 );
759 assert_eq!(empty.line_count(), 0);
760 }
761
762 #[test]
763 fn line_sequences_parse_once_and_keep_multiline_context() {
764 let theme = DiffTheme::default();
765 let mut highlighter = SyntaxHighlighter::default();
766 let lines = ["/* alpha", "beta", "gamma */", "let x = 1;"];
767 let sequence = SourceSequenceId::from_lines(lines);
768
769 let first = highlighter.with_theme(&theme).highlight_document_lines(
770 sequence,
771 LanguageHint::Path("src/lib.rs"),
772 lines,
773 );
774 assert_eq!(first.line_count(), 4);
775 for (index, line) in lines.iter().enumerate() {
776 let spans = first.line(index).unwrap();
777 assert!(!spans.is_empty(), "line {index}");
778 assert!(spans.iter().all(|span| span.range.end <= line.len()));
779 }
780
781 let second = highlighter.with_theme(&theme).highlight_document_lines(
782 sequence,
783 LanguageHint::Path("src/lib.rs"),
784 lines,
785 );
786 assert!(Arc::ptr_eq(&first, &second));
787 assert_eq!(highlighter.stats().misses, 1);
788 assert_eq!(highlighter.stats().hits, 1);
789 }
790
791 #[test]
792 fn supported_language_bundle_highlights_representative_sources() {
793 let cases = [
794 ("rust", "fn main() {}"),
795 ("js", "const x = true;"),
796 ("jsx", "const view = <Panel title=\"Hi\" />;"),
797 ("typescript", "const x: number = 1;"),
798 ("tsx", "const view = <Panel title=\"Hi\" />;"),
799 ("py", "def f(): return 1"),
800 ("sh", "echo hi"),
801 ("c", "int main(void) {}"),
802 ("cpp", "class C {};"),
803 ("go", "package main"),
804 ("json", "{\"x\": true}"),
805 ("jsonc", "{\"x\": true /* comment */}"),
806 ("toml", "x = 1"),
807 ("yml", "x: true"),
808 ("html", "<b>x</b>"),
809 ("css", "b { color: red; }"),
810 ("md", "# Heading"),
811 ];
812 let theme = DiffTheme::default();
813 let mut highlighter = SyntaxHighlighter::new(cases.len());
814 for (language, source) in cases {
815 let spans = highlighter
816 .with_theme(&theme)
817 .highlight_source(language, source);
818 assert!(!spans.is_empty(), "{language}");
819 assert_valid_spans(source, &spans);
820 }
821 }
822
823 #[test]
824 fn unknown_language_is_plain_text_and_cached() {
825 let mut highlighter = SyntaxHighlighter::new(2);
826 let theme = DiffTheme::default();
827 assert!(
828 highlighter
829 .with_theme(&theme)
830 .highlight_source("binary.zzz", "abc")
831 .is_empty()
832 );
833 assert!(
834 highlighter
835 .with_theme(&theme)
836 .highlight_source("binary.zzz", "abc")
837 .is_empty()
838 );
839 assert_eq!(highlighter.stats().hits, 1);
840 assert_eq!(highlighter.stats().bytes, 0);
841 }
842
843 #[test]
844 fn fifo_zero_capacity_and_theme_revision_behave_as_before() {
845 let theme = DiffTheme::default();
846 let mut fifo = SyntaxHighlighter::new(1);
847 fifo.with_theme(&theme)
848 .highlight_source("rust", "fn a() {}");
849 fifo.with_theme(&theme)
850 .highlight_source("rust", "fn b() {}");
851 assert_eq!(fifo.stats().evictions, 1);
852 let mut zero = SyntaxHighlighter::new(0);
853 zero.with_theme(&theme)
854 .highlight_source("rust", "fn a() {}");
855 zero.with_theme(&theme)
856 .highlight_source("rust", "fn a() {}");
857 assert_eq!(zero.stats().misses, 2);
858 let mut themes = SyntaxHighlighter::new(8);
859 themes
860 .with_theme(&theme)
861 .highlight_source("rust", "fn main() {}");
862 themes
863 .with_theme(&DiffTheme::ayu().unwrap())
864 .highlight_source("rust", "fn main() {}");
865 assert_eq!(themes.stats().misses, 2);
866 }
867
868 #[test]
869 fn multiline_and_synthetic_newlines_are_clipped() {
870 let theme = DiffTheme::default();
871 let mut highlighter = SyntaxHighlighter::new(0);
872 let lines = ["/*", " café */"];
873 let rendered = highlighter
874 .with_theme(&theme)
875 .highlight_lines("rust", lines);
876 assert_eq!(rendered.len(), 2);
877 assert!(rendered.iter().all(|spans| !spans.is_empty()));
878 for (line, spans) in lines.into_iter().zip(rendered) {
879 assert!(spans.iter().all(|span| span.range.end <= line.len()
880 && line.is_char_boundary(span.range.start)
881 && line.is_char_boundary(span.range.end)));
882 }
883 }
884
885 #[test]
886 fn html_and_markdown_injections_highlight_embedded_languages() {
887 let theme = DiffTheme::default();
888 let mut highlighter = SyntaxHighlighter::new(4);
889 let html = "<p>café</p><script>const π = 3.14;</script><style>b{color:red}</style>";
890 let html_spans = highlighter
891 .with_theme(&theme)
892 .highlight_source("html", html);
893 assert_valid_spans(html, &html_spans);
894 for needle in ["const π", "color:red"] {
895 let start = html.find(needle).unwrap();
896 let end = start + needle.len();
897 assert!(
898 html_spans
899 .iter()
900 .any(|span| span.range.start < end && span.range.end > start),
901 "no injected highlight for {needle}"
902 );
903 }
904
905 let markdown = "# Title\n\n```rust\nfn main() {}\n```\n";
906 let markdown_spans = highlighter
907 .with_theme(&theme)
908 .highlight_source("markdown", markdown);
909 assert_valid_spans(markdown, &markdown_spans);
910 let start = markdown.find("fn main").unwrap();
911 let end = start + "fn main".len();
912 assert!(
913 markdown_spans
914 .iter()
915 .any(|span| span.range.start < end && span.range.end > start),
916 "no injected Rust highlight in Markdown"
917 );
918 }
919}