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::{Fingerprint, HighlightSpan, SyntaxTheme};
9use lru::LruCache;
10use std::{
11 fmt,
12 num::NonZeroUsize,
13 ops::Range,
14 sync::{Arc, OnceLock},
15};
16const DEFAULT_CAPACITY: usize = 512;
17const DEFAULT_MAX_DOCUMENTS: usize = 32;
18const DEFAULT_STREAM_BYTES: usize = 8 * 1024 * 1024;
19const SOURCE_KEY_DOMAIN: &[u8] = b"syntax-source-v1";
20const DOCUMENT_KEY_DOMAIN: &[u8] = b"syntax-document-v1";
21
22#[must_use]
24pub fn empty_spans() -> Arc<[HighlightSpan]> {
25 static EMPTY: OnceLock<Arc<[HighlightSpan]>> = OnceLock::new();
26 Arc::clone(EMPTY.get_or_init(|| Arc::from(Vec::<HighlightSpan>::new())))
27}
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct HighlightStats {
32 pub calls: u64,
33 pub hits: u64,
34 pub misses: u64,
35 pub evictions: u64,
36 pub bytes: usize,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub struct CacheKey {
43 fingerprint: Fingerprint,
44}
45
46impl CacheKey {
47 #[must_use]
48 pub const fn fingerprint(self) -> Fingerprint {
49 self.fingerprint
50 }
51
52 fn source(theme: Fingerprint, language: &str, source: &str) -> Self {
53 Self::new([
54 SOURCE_KEY_DOMAIN,
55 theme.as_bytes().as_slice(),
56 language.as_bytes(),
57 source.as_bytes(),
58 ])
59 }
60
61 fn document(theme: Fingerprint, language: &str, sequence: SourceSequenceId) -> Self {
62 let sequence = Fingerprint::from(sequence);
63 Self::new([
64 DOCUMENT_KEY_DOMAIN,
65 theme.as_bytes().as_slice(),
66 language.as_bytes(),
67 sequence.as_bytes().as_slice(),
68 ])
69 }
70
71 fn new<const N: usize>(fields: [&[u8]; N]) -> Self {
72 Self {
73 fingerprint: Fingerprint::of(fields),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct CacheConfig {
81 pub max_entries: usize,
82 pub max_documents: usize,
83 pub max_stream_bytes: usize,
84}
85
86impl Default for CacheConfig {
87 fn default() -> Self {
88 Self {
89 max_entries: DEFAULT_CAPACITY,
90 max_documents: DEFAULT_MAX_DOCUMENTS,
91 max_stream_bytes: DEFAULT_STREAM_BYTES,
92 }
93 }
94}
95
96impl From<usize> for CacheConfig {
97 fn from(max_entries: usize) -> Self {
98 Self {
99 max_entries,
100 ..Self::default()
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
106pub struct CacheUsage {
107 pub span_entries: usize,
108 pub document_entries: usize,
109}
110
111#[derive(Debug, Clone, Default)]
112pub struct SyntaxStream {
113 hint: String,
114 source: String,
115 revision: u64,
116 theme_revision: Option<Fingerprint>,
117 highlights: Arc<DocumentHighlights>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121pub enum SyntaxStreamError {
122 #[error("syntax stream input requires {attempted} bytes, exceeding the {limit}-byte limit")]
123 InputLimit { limit: usize, attempted: usize },
124}
125
126#[derive(Debug, Clone)]
127pub struct SyntaxStreamUpdate {
128 pub base_revision: u64,
129 pub revision: u64,
130 pub changed_lines: Range<usize>,
131 pub highlights: Arc<DocumentHighlights>,
132}
133
134impl SyntaxStream {
135 #[must_use]
136 pub fn new<'a>(hint: impl Into<LanguageHint<'a>>) -> Self {
137 Self {
138 hint: hint.into().as_str().to_owned(),
139 ..Self::default()
140 }
141 }
142
143 #[must_use]
144 pub fn source(&self) -> &str {
145 &self.source
146 }
147
148 #[must_use]
149 pub const fn revision(&self) -> u64 {
150 self.revision
151 }
152
153 #[must_use]
154 pub fn highlights(&self) -> &Arc<DocumentHighlights> {
155 &self.highlights
156 }
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct DocumentHighlights {
162 lines: Vec<Arc<[HighlightSpan]>>,
163}
164
165impl DocumentHighlights {
166 #[must_use]
167 pub fn line(&self, index: usize) -> Option<&[HighlightSpan]> {
168 self.lines.get(index).map(AsRef::as_ref)
169 }
170
171 #[must_use]
172 pub fn line_shared(&self, index: usize) -> Option<Arc<[HighlightSpan]>> {
173 self.lines.get(index).cloned()
174 }
175
176 #[must_use]
177 pub fn line_count(&self) -> usize {
178 self.lines.len()
179 }
180
181 fn from_spans(spans: &[HighlightSpan], text: &str) -> Self {
182 let starts = if text.is_empty() {
183 Vec::new()
184 } else {
185 let mut starts = vec![0];
186 starts.extend(
187 text.bytes()
188 .enumerate()
189 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
190 );
191 if text.ends_with('\n') {
192 starts.pop();
193 }
194 starts
195 };
196 let mut lines = Vec::with_capacity(starts.len());
197 let mut first_span = 0;
198 for (line, &start) in starts.iter().enumerate() {
199 let next = starts.get(line + 1).copied().unwrap_or(text.len());
200 let mut end = next;
201 if end > start && text.as_bytes()[end - 1] == b'\n' {
202 end -= 1;
203 }
204 if end > start && text.as_bytes()[end - 1] == b'\r' {
205 end -= 1;
206 }
207 while spans
208 .get(first_span)
209 .is_some_and(|span| span.range.end <= start)
210 {
211 first_span += 1;
212 }
213 let projected = spans[first_span..]
214 .iter()
215 .take_while(|span| span.range.start < end)
216 .filter_map(|span| {
217 let from = span.range.start.max(start);
218 let to = span.range.end.min(end);
219 (from < to).then_some(HighlightSpan {
220 range: from - start..to - start,
221 foreground: span.foreground,
222 font_style: span.font_style,
223 })
224 })
225 .collect::<Vec<_>>();
226 lines.push(Arc::from(projected));
227 }
228 Self { lines }
229 }
230}
231
232pub struct SyntaxHighlighter {
234 highlighter: Highlighter,
235 config: CacheConfig,
236 cache: Option<LruCache<CacheKey, Arc<[HighlightSpan]>>>,
237 documents: Option<LruCache<CacheKey, Arc<DocumentHighlights>>>,
238 stats: HighlightStats,
239}
240
241impl fmt::Debug for SyntaxHighlighter {
242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 f.debug_struct("SyntaxHighlighter")
244 .field("config", &self.config)
245 .field("entries", &self.cache.as_ref().map_or(0, LruCache::len))
246 .field(
247 "documents",
248 &self.documents.as_ref().map_or(0, LruCache::len),
249 )
250 .field("stats", &self.stats)
251 .finish_non_exhaustive()
252 }
253}
254
255impl Default for SyntaxHighlighter {
256 fn default() -> Self {
257 Self::new(DEFAULT_CAPACITY)
258 }
259}
260
261impl SyntaxHighlighter {
262 #[must_use]
264 pub fn new(config: impl Into<CacheConfig>) -> Self {
265 let syntax_config = Config {
266 max_injection_depth: 3,
267 ..Config::default()
268 };
269 let config = config.into();
270 let document_entries = if config.max_entries == 0 {
271 0
272 } else {
273 config.max_documents
274 };
275 Self {
276 highlighter: Highlighter::with_config(syntax_config),
277 config,
278 cache: NonZeroUsize::new(config.max_entries).map(LruCache::new),
279 documents: NonZeroUsize::new(document_entries).map(LruCache::new),
280 stats: HighlightStats::default(),
281 }
282 }
283
284 #[must_use]
285 pub const fn stats(&self) -> HighlightStats {
286 self.stats
287 }
288
289 pub fn reset_stats(&mut self) {
290 self.stats = HighlightStats::default();
291 }
292
293 pub fn take_stats(&mut self) -> HighlightStats {
295 std::mem::take(&mut self.stats)
296 }
297
298 #[must_use]
299 pub const fn config(&self) -> CacheConfig {
300 self.config
301 }
302
303 #[must_use]
304 pub fn with_theme<'a>(&'a mut self, theme: &'a SyntaxTheme) -> ThemedHighlighter<'a> {
305 ThemedHighlighter {
306 highlighter: self,
307 theme,
308 }
309 }
310
311 #[must_use]
312 pub fn cache_usage(&self) -> CacheUsage {
313 CacheUsage {
314 span_entries: self.cache.as_ref().map_or(0, LruCache::len),
315 document_entries: self.documents.as_ref().map_or(0, LruCache::len),
316 }
317 }
318
319 pub fn clear_cache(&mut self) {
320 if let Some(cache) = &mut self.cache {
321 cache.clear();
322 }
323 if let Some(documents) = &mut self.documents {
324 documents.clear();
325 }
326 }
327
328 fn highlight_source(
329 &mut self,
330 theme: &SyntaxTheme,
331 hint: LanguageHint<'_>,
332 text: &str,
333 ) -> Arc<[HighlightSpan]> {
334 self.stats.calls += 1;
335 let language = resolve_language(hint, text);
336 let id = language.unwrap_or("plain");
337 let key = CacheKey::source(theme.revision(), id, text);
338 if let Some(spans) = self.cache.as_mut().and_then(|cache| cache.get(&key)) {
339 self.stats.hits += 1;
340 return Arc::clone(spans);
341 }
342 self.stats.misses += 1;
343 let Some(language) = language else {
344 let spans = empty_spans();
345 self.store(key, Arc::clone(&spans));
346 return spans;
347 };
348 self.stats.bytes = self.stats.bytes.saturating_add(text.len());
349 let spans = highlight_source(&mut self.highlighter, theme, language, text)
350 .map_or_else(empty_spans, Arc::from);
351 self.store(key, Arc::clone(&spans));
352 spans
353 }
354
355 fn highlight_lines<'line, T>(
356 &mut self,
357 theme: &SyntaxTheme,
358 hint: LanguageHint<'_>,
359 lines: T,
360 ) -> Vec<Vec<HighlightSpan>>
361 where
362 T: IntoIterator<Item = &'line str>,
363 {
364 self.stats.calls += 1;
365 let selected: Vec<(usize, &str)> = lines.into_iter().enumerate().collect();
366 let window = JoinedLines::new(selected);
367 let Some(language) = resolve_language(hint, &window.source) else {
368 return vec![Vec::new(); window.lines.len()];
369 };
370 self.stats.bytes = self.stats.bytes.saturating_add(window.source.len());
371 highlight_source(&mut self.highlighter, theme, language, &window.source)
372 .as_deref()
373 .map_or_else(
374 || vec![Vec::new(); window.lines.len()],
375 |spans| window.split(spans),
376 )
377 }
378
379 fn store(&mut self, key: CacheKey, spans: Arc<[HighlightSpan]>) {
380 if let Some(cache) = &mut self.cache
381 && let Some((evicted, _)) = cache.push(key, spans)
382 && evicted != key
383 {
384 self.stats.evictions += 1;
385 }
386 }
387
388 fn store_document(&mut self, key: CacheKey, highlights: Arc<DocumentHighlights>) {
389 if let Some(documents) = &mut self.documents
390 && let Some((evicted, _)) = documents.push(key, highlights)
391 && evicted != key
392 {
393 self.stats.evictions += 1;
394 }
395 }
396}
397
398pub struct ThemedHighlighter<'a> {
400 highlighter: &'a mut SyntaxHighlighter,
401 theme: &'a SyntaxTheme,
402}
403
404impl ThemedHighlighter<'_> {
405 pub fn highlight_document<'a>(
407 &mut self,
408 sequence: SourceSequenceId,
409 language: impl Into<LanguageHint<'a>>,
410 text: &str,
411 ) -> Arc<DocumentHighlights> {
412 self.highlighter.stats.calls += 1;
413 let resolved = resolve_language(language.into(), text);
414 let key = CacheKey::document(self.theme.revision(), resolved.unwrap_or("plain"), sequence);
415 if let Some(highlights) = self
416 .highlighter
417 .documents
418 .as_mut()
419 .and_then(|documents| documents.get(&key))
420 {
421 self.highlighter.stats.hits += 1;
422 return Arc::clone(highlights);
423 }
424 self.parse_document(key, resolved, text)
425 }
426
427 pub fn highlight_document_lines<'a, 'line>(
430 &mut self,
431 sequence: SourceSequenceId,
432 language: impl Into<LanguageHint<'a>>,
433 lines: impl IntoIterator<Item = &'line str>,
434 ) -> Arc<DocumentHighlights> {
435 self.highlighter.stats.calls += 1;
436 let mut lines = lines.into_iter().peekable();
437 let resolved = resolve_language(language.into(), lines.peek().copied().unwrap_or_default());
438 let key = CacheKey::document(self.theme.revision(), resolved.unwrap_or("plain"), sequence);
439 if let Some(highlights) = self
440 .highlighter
441 .documents
442 .as_mut()
443 .and_then(|documents| documents.get(&key))
444 {
445 self.highlighter.stats.hits += 1;
446 return Arc::clone(highlights);
447 }
448 let mut text = String::new();
449 for line in lines {
450 text.push_str(line);
451 text.push('\n');
452 }
453 self.parse_document(key, resolved, &text)
454 }
455
456 fn parse_document(
457 &mut self,
458 key: CacheKey,
459 resolved: Option<&str>,
460 text: &str,
461 ) -> Arc<DocumentHighlights> {
462 self.highlighter.stats.misses += 1;
463 let highlights = Arc::new(self.project(resolved, text));
464 self.highlighter
465 .store_document(key, Arc::clone(&highlights));
466 highlights
467 }
468
469 fn project(&mut self, resolved: Option<&str>, text: &str) -> DocumentHighlights {
470 let spans = resolved
471 .and_then(|language| {
472 self.highlighter.stats.bytes += text.len();
473 highlight_source(
474 &mut self.highlighter.highlighter,
475 self.theme,
476 language,
477 text,
478 )
479 })
480 .unwrap_or_default();
481 DocumentHighlights::from_spans(&spans, text)
482 }
483
484 pub fn highlight_source<'a>(
486 &mut self,
487 language: impl Into<LanguageHint<'a>>,
488 text: &str,
489 ) -> Arc<[HighlightSpan]> {
490 self.highlighter
491 .highlight_source(self.theme, language.into(), text)
492 }
493
494 pub fn highlight_lines<'line, 'hint, T>(
496 &mut self,
497 language: impl Into<LanguageHint<'hint>>,
498 lines: T,
499 ) -> Vec<Vec<HighlightSpan>>
500 where
501 T: IntoIterator<Item = &'line str>,
502 {
503 self.highlighter
504 .highlight_lines(self.theme, language.into(), lines)
505 }
506
507 pub fn append<'line>(
508 &mut self,
509 stream: &mut SyntaxStream,
510 lines: impl IntoIterator<Item = &'line str>,
511 ) -> Result<SyntaxStreamUpdate, SyntaxStreamError> {
512 let limit = self.highlighter.config.max_stream_bytes;
513 let mut appended = String::new();
514 for line in lines {
515 let newline = usize::from(!line.ends_with('\n'));
516 let attempted = stream.source.len() + appended.len() + line.len() + newline;
517 if attempted > limit {
518 return Err(SyntaxStreamError::InputLimit { limit, attempted });
519 }
520 appended.push_str(line);
521 if newline != 0 {
522 appended.push('\n');
523 }
524 }
525 let base_revision = stream.revision;
526 let theme_revision = self.theme.revision();
527 if appended.is_empty() && stream.theme_revision == Some(theme_revision) {
528 let end = stream.highlights.line_count();
529 return Ok(SyntaxStreamUpdate {
530 base_revision,
531 revision: stream.revision,
532 changed_lines: end..end,
533 highlights: Arc::clone(&stream.highlights),
534 });
535 }
536 if !appended.is_empty() {
537 stream.source.push_str(&appended);
538 stream.revision = stream.revision.wrapping_add(1);
539 }
540 self.highlighter.stats.calls += 1;
541 let resolved = resolve_language(stream.hint.as_str(), &stream.source);
542 let highlights = Arc::new(self.project(resolved, &stream.source));
543 let first_changed = stream
544 .highlights
545 .lines
546 .iter()
547 .zip(&highlights.lines)
548 .position(|(before, after)| before != after)
549 .unwrap_or_else(|| stream.highlights.line_count().min(highlights.line_count()));
550 stream.highlights = Arc::clone(&highlights);
551 stream.theme_revision = Some(theme_revision);
552 Ok(SyntaxStreamUpdate {
553 base_revision,
554 revision: stream.revision,
555 changed_lines: first_changed..highlights.line_count(),
556 highlights,
557 })
558 }
559}
560
561fn highlight_source(
562 highlighter: &mut Highlighter,
563 theme: &SyntaxTheme,
564 language: &str,
565 source: &str,
566) -> Option<Vec<HighlightSpan>> {
567 let raw_spans = highlighter.highlight_spans(language, source).ok()?;
568 let tokens = spans_to_flat_tokens(source, raw_spans);
569 let mut spans = Vec::with_capacity(tokens.len());
570 for token in tokens {
571 let Ok(start) = usize::try_from(token.start) else {
572 continue;
573 };
574 let Ok(end) = usize::try_from(token.end) else {
575 continue;
576 };
577 if start >= end
578 || end > source.len()
579 || !source.is_char_boundary(start)
580 || !source.is_char_boundary(end)
581 {
582 continue;
583 }
584 let Some(capture) = diff_capture_name(token.tag) else {
585 continue;
586 };
587 let Some(style) = theme.style(capture) else {
588 continue;
589 };
590 push_merged(
591 &mut spans,
592 HighlightSpan {
593 range: start..end,
594 foreground: style.foreground,
595 font_style: style.font_style,
596 },
597 );
598 }
599 Some(spans)
600}
601
602fn diff_capture_name(tag: &str) -> Option<&'static str> {
603 Some(match tag_to_name(tag)? {
604 "title" => "markup.heading",
605 "strong" => "markup.bold",
606 "emphasis" => "markup.italic",
607 "link" => "markup.link",
608 "literal" => "markup.raw",
609 "strikethrough" => "markup.strikethrough",
610 name => name,
611 })
612}
613
614fn push_merged(spans: &mut Vec<HighlightSpan>, span: HighlightSpan) {
615 if let Some(last) = spans.last_mut()
616 && last.range.end == span.range.start
617 && last.foreground == span.foreground
618 && last.font_style == span.font_style
619 {
620 last.range.end = span.range.end;
621 } else {
622 spans.push(span);
623 }
624}
625
626struct JoinedLines<'a> {
627 source: String,
628 lines: Vec<(usize, &'a str, usize, usize)>,
630}
631
632impl<'a> JoinedLines<'a> {
633 fn new(selected: Vec<(usize, &'a str)>) -> Self {
634 let mut source = String::new();
635 let mut lines = Vec::with_capacity(selected.len());
636 for (index, line) in selected {
637 let start = source.len();
638 source.push_str(line);
639 let end = source.len();
640 if !line.ends_with('\n') {
641 source.push('\n');
642 }
643 lines.push((index, line, start, end));
644 }
645 Self { source, lines }
646 }
647
648 fn split(&self, spans: &[HighlightSpan]) -> Vec<Vec<HighlightSpan>> {
653 let mut next = 0;
654 self.lines
655 .iter()
656 .map(|(_, line, start, end)| {
657 while spans.get(next).is_some_and(|span| span.range.end <= *start) {
658 next += 1;
659 }
660 let mut result = Vec::new();
661 for span in &spans[next..] {
662 if span.range.start >= *end {
663 break;
664 }
665 let overlap_start = span.range.start.max(*start);
666 let overlap_end = span.range.end.min(*end);
667 if overlap_start >= overlap_end {
668 continue;
669 }
670 let local_start = overlap_start - start;
671 let local_end = overlap_end - start;
672 if local_end <= line.len()
673 && line.is_char_boundary(local_start)
674 && line.is_char_boundary(local_end)
675 {
676 push_merged(
677 &mut result,
678 HighlightSpan {
679 range: local_start..local_end,
680 foreground: span.foreground,
681 font_style: span.font_style,
682 },
683 );
684 }
685 }
686 result
687 })
688 .collect()
689 }
690}