1use crate::ThemeError;
7
8use std::sync::Arc;
9use std::{cmp::Ordering, collections::HashMap};
10
11#[cfg(feature = "bundled-themes")]
12use std::sync::OnceLock;
13use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
14
15use serde::Deserialize;
16
17use crate::{HighlightScopeTable, ScopeStackId, ThemeRule};
18
19#[cfg(feature = "bundled-themes")]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum BuiltinTextMateTheme {
22 Dark,
23 DarkHighContrast,
24 Light,
25 LightHighContrast,
26}
27
28#[cfg(feature = "bundled-themes")]
29impl BuiltinTextMateTheme {
30 pub fn from_name(name: &str) -> Option<Self> {
31 Self::all()
32 .iter()
33 .copied()
34 .find(|theme| theme.name() == name)
35 }
36
37 pub const fn name(self) -> &'static str {
38 match self {
39 Self::Dark => "github-dark",
40 Self::DarkHighContrast => "github-dark-high-contrast",
41 Self::Light => "github-light",
42 Self::LightHighContrast => "github-light-high-contrast",
43 }
44 }
45
46 pub fn get(self) -> &'static TextMateTheme {
47 builtin_theme(self)
48 }
49
50 pub const fn all() -> &'static [Self] {
51 &[
52 Self::Dark,
53 Self::DarkHighContrast,
54 Self::Light,
55 Self::LightHighContrast,
56 ]
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub struct RgbColor {
63 pub red: u8,
65 pub green: u8,
67 pub blue: u8,
69}
70
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
73pub struct FontModifiers(u8);
74
75impl FontModifiers {
76 pub const BOLD: Self = Self(0b0001);
78 pub const ITALIC: Self = Self(0b0010);
80 pub const UNDERLINED: Self = Self(0b0100);
82 pub const CROSSED_OUT: Self = Self(0b1000);
84
85 pub const fn empty() -> Self {
87 Self(0)
88 }
89
90 pub const fn contains(self, other: Self) -> bool {
92 self.0 & other.0 == other.0
93 }
94
95 pub const fn is_empty(self) -> bool {
97 self.0 == 0
98 }
99
100 fn insert(&mut self, other: Self) {
101 self.0 |= other.0;
102 }
103}
104
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
107pub struct Style {
108 pub foreground: Option<RgbColor>,
110 pub background: Option<RgbColor>,
112 pub modifiers: FontModifiers,
114}
115
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
118pub struct ResolvedThemeStyle {
119 pub foreground_matched: bool,
121 pub background_matched: bool,
123 pub modifiers_matched: bool,
125 pub style: Style,
127}
128
129#[cfg(any(feature = "diagnostics", test))]
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ThemeMatch<'a> {
133 pub selector: Option<&'a str>,
135 pub score: Option<ThemeSelectorScore>,
137 pub source_order: Option<usize>,
139 pub foreground_matched: bool,
141 pub background_matched: bool,
143 pub modifiers_matched: bool,
145 pub style: Style,
147}
148
149struct ScopeResolution<'a> {
150 #[cfg_attr(not(any(feature = "diagnostics", test)), allow(dead_code))]
152 representative: Option<&'a CompiledThemeRule>,
153 foreground_matched: bool,
154 background_matched: bool,
155 modifiers_matched: bool,
156 style: Style,
157}
158
159#[cfg(any(feature = "diagnostics", test))]
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ThemeSelectorScore {
163 pub target_depth: usize,
165 pub parent_lengths: Vec<usize>,
167 pub parent_count: usize,
169 pub source_order: usize,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub(crate) struct TextMateTheme {
175 name: String,
176 generation: u64,
177 colors: HashMap<String, RgbColor>,
178 default_style: Style,
179 rules: Vec<CompiledThemeRule>,
180 candidates_by_head: HashMap<String, Vec<usize>>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184struct CompiledThemeRule {
185 selector_text: String,
186 target: String,
187 parents: Vec<String>,
189 foreground: Option<RgbColor>,
190 background: Option<RgbColor>,
191 modifiers: Option<FontModifiers>,
192 source_order: usize,
193}
194
195pub(crate) trait ThemeScopeStack {
196 fn len(&self) -> usize;
197 fn scope_name(&self, index: usize) -> Option<&str>;
198}
199
200struct TableScopeStack<'a> {
201 table: &'a HighlightScopeTable,
202 atoms: &'a [crate::ScopeAtomId],
203}
204
205impl ThemeScopeStack for TableScopeStack<'_> {
206 fn len(&self) -> usize {
207 self.atoms.len()
208 }
209
210 fn scope_name(&self, index: usize) -> Option<&str> {
211 self.atoms
212 .get(index)
213 .and_then(|atom| self.table.atom(*atom))
214 }
215}
216
217impl ThemeScopeStack for [Arc<str>] {
218 fn len(&self) -> usize {
219 <[Arc<str>]>::len(self)
220 }
221
222 fn scope_name(&self, index: usize) -> Option<&str> {
223 self.get(index).map(AsRef::as_ref)
224 }
225}
226
227impl ThemeScopeStack for crate::Scopes<'_> {
228 fn len(&self) -> usize {
229 ExactSizeIterator::len(self)
230 }
231 fn scope_name(&self, index: usize) -> Option<&str> {
232 self.name(index)
233 }
234}
235
236impl ThemeScopeStack for [&str] {
237 fn len(&self) -> usize {
238 <[&str]>::len(self)
239 }
240 fn scope_name(&self, index: usize) -> Option<&str> {
241 self.get(index).copied()
242 }
243}
244
245#[derive(Debug, Deserialize)]
246#[serde(rename_all = "camelCase")]
247struct RawTheme {
248 #[serde(default)]
249 name: String,
250 #[serde(default)]
251 colors: HashMap<String, serde_json::Value>,
252 #[serde(default)]
253 token_colors: Vec<RawRule>,
254}
255
256#[derive(Debug, Deserialize)]
257struct RawRule {
258 #[serde(default)]
259 scope: Option<RawScopes>,
260 settings: RawSettings,
261}
262
263#[derive(Debug, Deserialize)]
264#[serde(untagged)]
265enum RawScopes {
266 One(String),
267 Many(Vec<String>),
268}
269
270#[derive(Debug, Deserialize)]
271#[serde(rename_all = "camelCase")]
272struct RawSettings {
273 #[serde(default)]
274 foreground: Option<String>,
275 #[serde(default)]
276 background: Option<String>,
277 #[serde(default)]
278 font_style: Option<String>,
279}
280
281impl TextMateTheme {
282 pub fn from_json(json: &str) -> Result<Self, ThemeError> {
283 let raw: RawTheme = serde_json::from_str(json).map_err(ThemeError::json)?;
284 let editor_background = raw
285 .colors
286 .get("editor.background")
287 .and_then(|value| value.as_str())
288 .map(parse_editor_background)
289 .transpose()?;
290 let colors = raw
291 .colors
292 .iter()
293 .filter_map(|(name, value)| {
294 value
295 .as_str()
296 .and_then(|value| parse_color(value, editor_background).ok())
297 .map(|color| (name.clone(), color))
298 })
299 .collect();
300 let default_style = Style {
301 foreground: parse_optional_color(
302 raw.colors
303 .get("editor.foreground")
304 .and_then(|value| value.as_str()),
305 editor_background,
306 )?,
307 background: editor_background,
308 modifiers: FontModifiers::empty(),
309 };
310 let mut rules = Vec::new();
311 let mut defaults = default_style;
312 for (source_order, raw_rule) in raw.token_colors.into_iter().enumerate() {
313 let foreground =
314 parse_optional_color(raw_rule.settings.foreground.as_deref(), editor_background)?;
315 let background =
316 parse_optional_color(raw_rule.settings.background.as_deref(), editor_background)?;
317 let modifiers = raw_rule
318 .settings
319 .font_style
320 .as_deref()
321 .map(parse_modifiers)
322 .transpose()?;
323 let scopes = match raw_rule.scope {
324 Some(RawScopes::One(scope)) => scope
325 .trim_matches(',')
326 .split(',')
327 .map(str::to_owned)
328 .collect(),
329 Some(RawScopes::Many(scopes)) => scopes
330 .into_iter()
331 .flat_map(|scope| scope.split(',').map(str::to_owned).collect::<Vec<_>>())
332 .collect(),
333 None => Vec::new(),
334 };
335 if scopes.is_empty() {
336 if foreground.is_some() {
337 defaults.foreground = foreground;
338 }
339 if background.is_some() {
340 defaults.background = background;
341 }
342 if let Some(modifiers) = modifiers {
343 defaults.modifiers = modifiers;
344 }
345 continue;
346 }
347 for selector in scopes {
348 let selector = selector.trim();
349 if selector.is_empty() {
350 continue;
351 }
352 let mut parts = selector.split_whitespace().collect::<Vec<_>>();
353 let target = parts.pop().ok_or_else(|| {
354 ThemeError::rule(format!("empty theme selector at rule {source_order}"))
355 })?;
356 validate_scope_pattern(target, source_order)?;
357 for parent in &parts {
358 if *parent != ">" {
359 validate_scope_pattern(parent, source_order)?;
360 }
361 }
362 parts.reverse();
363 rules.push(CompiledThemeRule {
364 selector_text: selector.to_owned(),
365 target: target.to_owned(),
366 parents: parts.into_iter().map(str::to_owned).collect(),
367 foreground,
368 background,
369 modifiers,
370 source_order,
371 });
372 }
373 }
374 let mut candidates_by_head = HashMap::<String, Vec<usize>>::new();
375 for (index, rule) in rules.iter().enumerate() {
376 let head = rule.target.split('.').next().unwrap_or(&rule.target);
377 candidates_by_head
378 .entry(head.to_owned())
379 .or_default()
380 .push(index);
381 }
382 Ok(Self {
383 name: raw.name,
384 generation: next_theme_generation(),
385 colors,
386 default_style: defaults,
387 rules,
388 candidates_by_head,
389 })
390 }
391
392 pub fn from_rules(rules: &[ThemeRule]) -> Result<Self, ThemeError> {
395 let mut compiled = Vec::new();
396 for (source_order, rule) in rules.iter().enumerate() {
397 let foreground = parse_optional_syntax_rule_color(rule.foreground.as_deref())?;
398 let background = parse_optional_syntax_rule_color(rule.background.as_deref())?;
399 let modifiers = rule
400 .font_style
401 .as_deref()
402 .map(parse_modifiers)
403 .transpose()?;
404 if foreground.is_none() && background.is_none() && modifiers.is_none() {
405 return Err(ThemeError::rule(format!(
406 "syntax rule {source_order} must set foreground, background, or font_style"
407 )));
408 }
409 for selector in rule.scope.split(',').map(str::trim) {
410 if selector.is_empty() {
411 return Err(ThemeError::rule(format!(
412 "empty syntax rule selector at rule {source_order}"
413 )));
414 }
415 compiled.push(compile_rule(
416 selector,
417 foreground,
418 background,
419 modifiers,
420 source_order,
421 )?);
422 }
423 }
424 let mut candidates_by_head = HashMap::<String, Vec<usize>>::new();
425 for (index, rule) in compiled.iter().enumerate() {
426 candidates_by_head
427 .entry(selector_head(&rule.target).to_owned())
428 .or_default()
429 .push(index);
430 }
431 Ok(Self {
432 name: "user syntax rules".to_owned(),
433 generation: next_theme_generation(),
434 colors: HashMap::new(),
435 default_style: Style::default(),
436 rules: compiled,
437 candidates_by_head,
438 })
439 }
440
441 pub fn name(&self) -> &str {
442 &self.name
443 }
444
445 pub fn default_style(&self) -> Style {
446 self.default_style
447 }
448
449 #[cfg(feature = "html")]
451 pub(crate) fn rendering_rules(&self) -> impl Iterator<Item = (&str, Style, bool)> {
452 let mut rules = self.rules.iter().collect::<Vec<_>>();
453 rules.sort_by(|a, b| compare_specificity(a, b));
454 rules.into_iter().map(|rule| {
455 (
456 rule.selector_text.as_str(),
457 Style {
458 foreground: rule.foreground,
459 background: rule.background,
460 modifiers: rule.modifiers.unwrap_or_default(),
461 },
462 rule.modifiers.is_some(),
463 )
464 })
465 }
466
467 pub fn color(&self, name: &str) -> Option<RgbColor> {
468 self.colors.get(name).copied()
469 }
470
471 pub(crate) fn resolve_scopes(&self, scopes: crate::Scopes<'_>) -> Style {
472 if let Some(crate::types::ScopeStorage::Table(table)) = scopes.storage.owner.as_deref()
473 && ExactSizeIterator::len(&scopes)
474 == table.stack(scopes.storage.stack).unwrap_or_default().len()
475 {
476 return self.resolve(table, scopes.storage.stack);
477 }
478 self.resolve_scope_stack(&scopes).style
479 }
480
481 pub(crate) fn resolve_names(&self, scopes: &[&str]) -> Style {
482 self.resolve_scope_stack(scopes).style
483 }
484
485 pub fn resolve(&self, table: &HighlightScopeTable, stack: ScopeStackId) -> Style {
486 self.resolve_style(table, stack).style
487 }
488
489 pub(crate) fn resolve_shared_scope_names(&self, scopes: &[Arc<str>]) -> Style {
490 self.resolve_scope_stack(scopes).style
491 }
492
493 pub fn resolve_style(
497 &self,
498 table: &HighlightScopeTable,
499 stack: ScopeStackId,
500 ) -> ResolvedThemeStyle {
501 let (slot, cached) = table.cached_style(self.generation, stack);
502 if let Some(style) = cached {
503 return unpack_style(style);
504 }
505 let scopes = TableScopeStack {
506 table,
507 atoms: table.stack(stack).unwrap_or_default(),
508 };
509 let matched = self.resolve_scope_stack(&scopes);
510 let resolved = ResolvedThemeStyle {
511 foreground_matched: matched.foreground_matched,
512 background_matched: matched.background_matched,
513 modifiers_matched: matched.modifiers_matched,
514 style: matched.style,
515 };
516 table.cache_style(self.generation, stack, slot, pack_style(resolved));
517 resolved
518 }
519
520 #[cfg(test)]
521 pub fn resolve_with_match<'a>(
522 &'a self,
523 table: &HighlightScopeTable,
524 stack: ScopeStackId,
525 ) -> ThemeMatch<'a> {
526 let scopes = TableScopeStack {
527 table,
528 atoms: table.stack(stack).unwrap_or_default(),
529 };
530 self.inspect_scopes(&scopes)
531 }
532
533 #[cfg(any(feature = "diagnostics", test))]
534 pub(crate) fn inspect_scopes<S: ThemeScopeStack + ?Sized>(&self, scopes: &S) -> ThemeMatch<'_> {
535 let resolved = self.resolve_scope_stack(scopes);
536 ThemeMatch {
537 selector: resolved
538 .representative
539 .map(|rule| rule.selector_text.as_str()),
540 score: resolved.representative.map(selector_score),
541 source_order: resolved.representative.map(|rule| rule.source_order),
542 foreground_matched: resolved.foreground_matched,
543 background_matched: resolved.background_matched,
544 modifiers_matched: resolved.modifiers_matched,
545 style: resolved.style,
546 }
547 }
548
549 fn resolve_scope_stack<'a, S>(&'a self, scopes: &S) -> ScopeResolution<'a>
550 where
551 S: ThemeScopeStack + ?Sized,
552 {
553 let mut style = self.default_style;
559 let mut representative: Option<&CompiledThemeRule> = None;
560 let mut foreground_matched = false;
561 let mut background_matched = false;
562 let mut modifiers_matched = false;
563 for depth in 1..=scopes.len() {
564 let Some(scope) = scopes.scope_name(depth - 1) else {
565 continue;
566 };
567 let head = scope.split('.').next().unwrap_or(scope);
568 let mut foreground = None;
569 let mut background = None;
570 let mut modifiers = None;
571 let mut level_representative: Option<&CompiledThemeRule> = None;
572 for rule_index in self.candidates_by_head.get(head).into_iter().flatten() {
573 let rule = &self.rules[*rule_index];
574 if !rule.matches(scopes, depth) {
575 continue;
576 }
577 if level_representative
578 .is_none_or(|current| compare_specificity(rule, current).is_gt())
579 {
580 level_representative = Some(rule);
581 }
582 update_property(&mut foreground, rule, rule.foreground);
583 update_property(&mut background, rule, rule.background);
584 update_property(&mut modifiers, rule, rule.modifiers);
585 }
586 if let Some((_, foreground)) = foreground {
587 style.foreground = Some(foreground);
588 foreground_matched = true;
589 }
590 if let Some((_, background)) = background {
591 style.background = Some(background);
592 background_matched = true;
593 }
594 if let Some((_, modifiers)) = modifiers {
595 style.modifiers = modifiers;
596 modifiers_matched = true;
597 }
598 if level_representative.is_some() {
599 representative = level_representative;
600 }
601 }
602 ScopeResolution {
603 representative,
604 foreground_matched,
605 background_matched,
606 modifiers_matched,
607 style,
608 }
609 }
610}
611
612fn compile_rule(
613 selector: &str,
614 foreground: Option<RgbColor>,
615 background: Option<RgbColor>,
616 modifiers: Option<FontModifiers>,
617 source_order: usize,
618) -> Result<CompiledThemeRule, ThemeError> {
619 let mut parts = selector.split_whitespace().collect::<Vec<_>>();
620 let target = parts
621 .pop()
622 .ok_or_else(|| ThemeError::rule(format!("empty theme selector at rule {source_order}")))?;
623 validate_scope_pattern(target, source_order)?;
624 for parent in &parts {
625 if *parent != ">" {
626 validate_scope_pattern(parent, source_order)?;
627 }
628 }
629 parts.reverse();
630 Ok(CompiledThemeRule {
631 selector_text: selector.to_owned(),
632 target: target.to_owned(),
633 parents: parts.into_iter().map(str::to_owned).collect(),
634 foreground,
635 background,
636 modifiers,
637 source_order,
638 })
639}
640
641fn selector_head(target: &str) -> &str {
642 target.split('.').next().unwrap_or(target)
643}
644
645#[cfg(any(feature = "diagnostics", test))]
646fn selector_score(rule: &CompiledThemeRule) -> ThemeSelectorScore {
647 let parent_lengths = rule
648 .parents
649 .iter()
650 .filter(|scope| scope.as_str() != ">")
651 .map(String::len)
652 .collect::<Vec<_>>();
653 ThemeSelectorScore {
654 target_depth: dot_depth(&rule.target),
655 parent_count: parent_lengths.len(),
656 parent_lengths,
657 source_order: rule.source_order,
658 }
659}
660
661fn next_theme_generation() -> u64 {
662 static NEXT: AtomicU64 = AtomicU64::new(1);
663 NEXT.fetch_add(1, AtomicOrdering::Relaxed)
664}
665
666fn pack_style(resolved: ResolvedThemeStyle) -> u64 {
667 fn color(color: Option<RgbColor>) -> u64 {
668 color.map_or(0, |color| {
669 (1 << 24)
670 | (u64::from(color.red) << 16)
671 | (u64::from(color.green) << 8)
672 | u64::from(color.blue)
673 })
674 }
675 let style = resolved.style;
676 color(style.foreground)
677 | (color(style.background) << 25)
678 | (u64::from(style.modifiers.0) << 50)
679 | (u64::from(resolved.foreground_matched) << 58)
680 | (u64::from(resolved.background_matched) << 59)
681 | (u64::from(resolved.modifiers_matched) << 60)
682}
683
684fn unpack_style(packed: u64) -> ResolvedThemeStyle {
685 fn color(packed: u64) -> Option<RgbColor> {
686 (packed & (1 << 24) != 0).then_some(RgbColor {
687 red: ((packed >> 16) & 0xff) as u8,
688 green: ((packed >> 8) & 0xff) as u8,
689 blue: (packed & 0xff) as u8,
690 })
691 }
692 ResolvedThemeStyle {
693 foreground_matched: packed & (1 << 58) != 0,
694 background_matched: packed & (1 << 59) != 0,
695 modifiers_matched: packed & (1 << 60) != 0,
696 style: Style {
697 foreground: color(packed & 0x1ff_ffff),
698 background: color((packed >> 25) & 0x1ff_ffff),
699 modifiers: FontModifiers(((packed >> 50) & 0xff) as u8),
700 },
701 }
702}
703
704impl CompiledThemeRule {
705 fn matches<S>(&self, stack: &S, depth: usize) -> bool
706 where
707 S: ThemeScopeStack + ?Sized,
708 {
709 let Some(inner) = depth
710 .checked_sub(1)
711 .and_then(|index| stack.scope_name(index))
712 else {
713 return false;
714 };
715 if !scope_matches(inner, &self.target) {
716 return false;
717 }
718 let mut ancestor_index = depth - 1;
719 let mut parent_index = 0;
720 while parent_index < self.parents.len() {
721 let mut direct = false;
722 let mut pattern = self.parents[parent_index].as_str();
723 if pattern == ">" {
724 parent_index += 1;
725 let Some(next) = self.parents.get(parent_index) else {
726 return false;
727 };
728 pattern = next;
729 direct = true;
730 }
731 let mut found = false;
732 while ancestor_index > 0 {
733 ancestor_index -= 1;
734 if stack
735 .scope_name(ancestor_index)
736 .is_some_and(|scope| scope_matches(scope, pattern))
737 {
738 found = true;
739 break;
740 }
741 if direct {
742 return false;
743 }
744 }
745 if !found {
746 return false;
747 }
748 parent_index += 1;
749 }
750 true
751 }
752}
753
754fn update_property<'a, T: Copy>(
755 current: &mut Option<(&'a CompiledThemeRule, T)>,
756 candidate: &'a CompiledThemeRule,
757 value: Option<T>,
758) {
759 let Some(value) = value else { return };
760 if current
761 .as_ref()
762 .is_none_or(|(rule, _)| compare_specificity(candidate, rule).is_gt())
763 {
764 *current = Some((candidate, value));
765 }
766}
767
768fn compare_specificity(a: &CompiledThemeRule, b: &CompiledThemeRule) -> Ordering {
769 let target = dot_depth(&a.target).cmp(&dot_depth(&b.target));
770 if target != Ordering::Equal {
771 return target;
772 }
773 let a_parents = a.parents.iter().filter(|scope| scope.as_str() != ">");
774 let b_parents = b.parents.iter().filter(|scope| scope.as_str() != ">");
775 for (a_parent, b_parent) in a_parents.clone().zip(b_parents.clone()) {
776 let ordering = a_parent.len().cmp(&b_parent.len());
777 if ordering != Ordering::Equal {
778 return ordering;
779 }
780 }
781 let parents = a_parents.count().cmp(&b_parents.count());
782 if parents != Ordering::Equal {
783 return parents;
784 }
785 a.source_order.cmp(&b.source_order)
786}
787
788fn dot_depth(scope: &str) -> usize {
789 scope.bytes().filter(|byte| *byte == b'.').count() + 1
790}
791
792fn scope_matches(scope: &str, pattern: &str) -> bool {
793 scope == pattern
794 || scope
795 .strip_prefix(pattern)
796 .is_some_and(|suffix| suffix.starts_with('.'))
797}
798
799fn validate_scope_pattern(pattern: &str, source_order: usize) -> Result<(), ThemeError> {
800 if pattern.is_empty()
801 || pattern.starts_with('.')
802 || pattern.ends_with('.')
803 || pattern.contains("..")
804 || !pattern.bytes().all(|byte| {
805 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b':' | b'.' | b'*')
806 })
807 {
808 return Err(ThemeError::rule(format!(
809 "unsupported TextMate selector `{pattern}` at rule {source_order}"
810 )));
811 }
812 Ok(())
813}
814
815fn parse_modifiers(value: &str) -> Result<FontModifiers, ThemeError> {
816 let mut modifiers = FontModifiers::empty();
817 for modifier in value.split_whitespace() {
818 match modifier {
819 "normal" | "regular" => {}
821 "bold" => modifiers.insert(FontModifiers::BOLD),
822 "italic" => modifiers.insert(FontModifiers::ITALIC),
823 "underline" => modifiers.insert(FontModifiers::UNDERLINED),
824 "strikethrough" => modifiers.insert(FontModifiers::CROSSED_OUT),
825 unsupported => {
826 return Err(ThemeError::rule(format!(
827 "unsupported TextMate fontStyle `{unsupported}`"
828 )));
829 }
830 }
831 }
832 Ok(modifiers)
833}
834
835fn parse_optional_color(
836 value: Option<&str>,
837 background: Option<RgbColor>,
838) -> Result<Option<RgbColor>, ThemeError> {
839 value
840 .map(|value| parse_color(value, background))
841 .transpose()
842}
843
844fn parse_optional_syntax_rule_color(value: Option<&str>) -> Result<Option<RgbColor>, ThemeError> {
845 value
850 .map(|value| parse_rgba_color(value).map(|(color, _)| color))
851 .transpose()
852}
853
854fn parse_editor_background(value: &str) -> Result<RgbColor, ThemeError> {
855 let (color, alpha) = parse_rgba_color(value)?;
856 if alpha != u8::MAX {
857 return Err(ThemeError::color(
858 value,
859 format!("TextMate editor.background must be opaque, got `{value}`"),
860 ));
861 }
862 Ok(color)
863}
864
865fn parse_color(value: &str, background: Option<RgbColor>) -> Result<RgbColor, ThemeError> {
866 let (color, alpha) = parse_rgba_color(value)?;
867 if alpha == u8::MAX {
868 return Ok(color);
869 }
870 let background = background.ok_or_else(|| {
871 ThemeError::color(
872 value,
873 format!("translucent TextMate color `{value}` requires an opaque editor.background"),
874 )
875 })?;
876 let composite = |foreground: u8, background: u8| {
877 let alpha = u32::from(alpha);
878 ((u32::from(foreground) * alpha
879 + u32::from(background) * (u32::from(u8::MAX) - alpha)
880 + 127)
881 / u32::from(u8::MAX)) as u8
882 };
883 Ok(RgbColor {
884 red: composite(color.red, background.red),
885 green: composite(color.green, background.green),
886 blue: composite(color.blue, background.blue),
887 })
888}
889
890fn parse_rgba_color(value: &str) -> Result<(RgbColor, u8), ThemeError> {
891 let invalid = || ThemeError::color(value, format!("unsupported TextMate color `{value}`"));
892 let hex = value.strip_prefix('#').ok_or_else(invalid)?;
893 if !hex.is_ascii() {
894 return Err(invalid());
895 }
896 let byte = |start| u8::from_str_radix(&hex[start..start + 2], 16).map_err(|_| invalid());
897 let nibble = |index| {
898 u8::from_str_radix(&hex[index..index + 1], 16)
899 .map(|value| value * 0x11)
900 .map_err(|_| invalid())
901 };
902 let (red, green, blue, alpha) = match hex.len() {
903 3 => (nibble(0)?, nibble(1)?, nibble(2)?, u8::MAX),
904 4 => (nibble(0)?, nibble(1)?, nibble(2)?, nibble(3)?),
905 6 => (byte(0)?, byte(2)?, byte(4)?, u8::MAX),
906 8 => (byte(0)?, byte(2)?, byte(4)?, byte(6)?),
907 _ => return Err(invalid()),
908 };
909 Ok((RgbColor { red, green, blue }, alpha))
910}
911
912#[cfg(feature = "bundled-themes")]
913macro_rules! vendored_theme {
914 ($function:ident, $file:literal) => {
915 pub fn $function() -> &'static TextMateTheme {
916 static THEME: OnceLock<TextMateTheme> = OnceLock::new();
917 THEME.get_or_init(|| {
918 TextMateTheme::from_json(include_str!(concat!(
919 env!("CARGO_MANIFEST_DIR"),
920 "/assets/themes/",
921 $file
922 )))
923 .unwrap_or_else(|error| panic!("vendored theme {} is invalid: {error}", $file))
924 })
925 }
926 };
927}
928
929#[cfg(feature = "bundled-themes")]
930vendored_theme!(github_dark, "github-dark.json");
931#[cfg(feature = "bundled-themes")]
932vendored_theme!(github_dark_high_contrast, "github-dark-high-contrast.json");
933#[cfg(feature = "bundled-themes")]
934vendored_theme!(github_light, "github-light.json");
935#[cfg(feature = "bundled-themes")]
936vendored_theme!(
937 github_light_high_contrast,
938 "github-light-high-contrast.json"
939);
940
941#[cfg(feature = "bundled-themes")]
942fn builtin_theme(theme: BuiltinTextMateTheme) -> &'static TextMateTheme {
943 match theme {
944 BuiltinTextMateTheme::Dark => github_dark(),
945 BuiltinTextMateTheme::DarkHighContrast => github_dark_high_contrast(),
946 BuiltinTextMateTheme::Light => github_light(),
947 BuiltinTextMateTheme::LightHighContrast => github_light_high_contrast(),
948 }
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use std::sync::Arc;
955
956 #[test]
957 fn official_theme_loads() {
958 let theme = github_dark_high_contrast();
959 assert_eq!(theme.name(), "GitHub Dark High Contrast");
960 assert_eq!(theme.rules.len(), 87);
961 }
962
963 #[test]
964 fn every_named_builtin_theme_loads() {
965 for theme in BuiltinTextMateTheme::all() {
966 let theme = theme.get();
967 assert!(!theme.name().is_empty());
968 assert!(
969 !theme.rules.is_empty(),
970 "{} has no token rules",
971 theme.name()
972 );
973 }
974 }
975
976 #[test]
977 fn translucent_theme_colors_are_composited_over_editor_background() {
978 let theme = TextMateTheme::from_json(
979 r##"{
980 "colors": {
981 "editor.background": "#102030",
982 "editor.foreground": "#ffffff80",
983 "editor.lineHighlightBackground": "#fff8"
984 },
985 "tokenColors": [{
986 "scope": "markup.raw",
987 "settings": { "background": "#f000" }
988 }]
989 }"##,
990 )
991 .unwrap();
992
993 assert_eq!(
994 theme.default_style().foreground,
995 Some(RgbColor {
996 red: 0x88,
997 green: 0x90,
998 blue: 0x98,
999 })
1000 );
1001 assert_eq!(
1002 theme.color("editor.lineHighlightBackground"),
1003 Some(RgbColor {
1004 red: 0x8f,
1005 green: 0x97,
1006 blue: 0x9e,
1007 })
1008 );
1009 let (table, stack) = HighlightScopeTable::from_scope_names(&["markup.raw"]);
1010 assert_eq!(
1011 theme.resolve(&table, stack).background,
1012 Some(RgbColor {
1013 red: 0x10,
1014 green: 0x20,
1015 blue: 0x30,
1016 })
1017 );
1018 }
1019
1020 #[test]
1021 fn scope_prefix_requires_a_dot_boundary() {
1022 assert!(scope_matches("support.function", "support"));
1023 assert!(!scope_matches("supportive.function", "support"));
1024 }
1025
1026 #[test]
1027 fn reset_font_styles_are_supported() {
1028 assert_eq!(parse_modifiers("").unwrap(), FontModifiers::empty());
1029 assert_eq!(parse_modifiers("normal").unwrap(), FontModifiers::empty());
1030 assert_eq!(parse_modifiers("regular").unwrap(), FontModifiers::empty());
1031 }
1032
1033 #[test]
1034 fn cached_style_encoding_round_trips() {
1035 let style = ResolvedThemeStyle {
1036 foreground_matched: true,
1037 background_matched: false,
1038 modifiers_matched: true,
1039 style: Style {
1040 foreground: Some(RgbColor {
1041 red: 1,
1042 green: 2,
1043 blue: 3,
1044 }),
1045 background: Some(RgbColor {
1046 red: 0xfd,
1047 green: 0xfe,
1048 blue: 0xff,
1049 }),
1050 modifiers: FontModifiers(FontModifiers::BOLD.0 | FontModifiers::CROSSED_OUT.0),
1051 },
1052 };
1053 assert_eq!(unpack_style(pack_style(style)), style);
1054 }
1055
1056 #[test]
1057 fn render_cache_keeps_base_theme_and_scope_override_matches() {
1058 let base = TextMateTheme::from_json(
1059 r##"{"tokenColors":[{"scope":"entity.name","settings":{"foreground":"#123456"}}]}"##,
1060 )
1061 .unwrap();
1062 let overrides = TextMateTheme::from_rules(&[ThemeRule {
1063 scope: "entity.name".to_owned(),
1064 font_style: Some(String::new()),
1065 ..ThemeRule::default()
1066 }])
1067 .unwrap();
1068 let (table, stack) =
1069 HighlightScopeTable::from_scope_names(&["source.test", "entity.name.test"]);
1070
1071 let base_match = base.resolve_style(&table, stack);
1072 let override_match = overrides.resolve_style(&table, stack);
1073 assert!(base_match.foreground_matched);
1074 assert!(!base_match.modifiers_matched);
1075 assert!(!override_match.foreground_matched);
1076 assert!(override_match.modifiers_matched);
1077
1078 let (_, cached_base) = table.cached_style(base.generation, stack);
1079 let (_, cached_override) = table.cached_style(overrides.generation, stack);
1080 assert_eq!(cached_base.map(unpack_style), Some(base_match));
1081 assert_eq!(cached_override.map(unpack_style), Some(override_match));
1082 }
1083
1084 #[test]
1085 fn shared_scope_table_cache_is_correct_across_concurrent_themes() {
1086 let red = Arc::new(
1087 TextMateTheme::from_json(
1088 r##"{"tokenColors":[{"scope":"entity.name","settings":{"foreground":"#ff0000"}}]}"##,
1089 )
1090 .unwrap(),
1091 );
1092 let blue = Arc::new(
1093 TextMateTheme::from_json(
1094 r##"{"tokenColors":[{"scope":"entity.name","settings":{"foreground":"#0000ff"}}]}"##,
1095 )
1096 .unwrap(),
1097 );
1098 let green = Arc::new(
1099 TextMateTheme::from_json(
1100 r##"{"tokenColors":[{"scope":"entity.name","settings":{"foreground":"#00ff00"}}]}"##,
1101 )
1102 .unwrap(),
1103 );
1104 let (table, stack) =
1105 HighlightScopeTable::from_scope_names(&["source.test", "entity.name.test"]);
1106 let table = Arc::new(table);
1107
1108 std::thread::scope(|threads| {
1109 let start = Arc::new(std::sync::Barrier::new(6));
1110 for (theme, expected) in [
1111 (
1112 red,
1113 RgbColor {
1114 red: 0xff,
1115 green: 0,
1116 blue: 0,
1117 },
1118 ),
1119 (
1120 blue,
1121 RgbColor {
1122 red: 0,
1123 green: 0,
1124 blue: 0xff,
1125 },
1126 ),
1127 (
1128 green,
1129 RgbColor {
1130 red: 0,
1131 green: 0xff,
1132 blue: 0,
1133 },
1134 ),
1135 ] {
1136 for _ in 0..2 {
1137 let theme = Arc::clone(&theme);
1138 let table = Arc::clone(&table);
1139 let start = Arc::clone(&start);
1140 threads.spawn(move || {
1141 start.wait();
1142 for _ in 0..2_000 {
1143 assert_eq!(theme.resolve(&table, stack).foreground, Some(expected));
1144 }
1145 });
1146 }
1147 }
1148 });
1149 }
1150
1151 #[test]
1152 fn official_theme_distinguishes_lossy_coarse_classes() {
1153 let theme = github_dark_high_contrast();
1154 let resolve = |scopes: &[&str]| {
1155 let (table, stack) = HighlightScopeTable::from_scope_names(scopes);
1156 theme.resolve(&table, stack)
1157 };
1158 assert_eq!(
1159 resolve(&["text.tex.latex", "support.function.general.tex"]).foreground,
1160 Some(RgbColor {
1161 red: 0x91,
1162 green: 0xcb,
1163 blue: 0xff,
1164 })
1165 );
1166 assert_eq!(
1167 resolve(&["source.test", "entity.name.function"]).foreground,
1168 Some(RgbColor {
1169 red: 0xdb,
1170 green: 0xb7,
1171 blue: 0xff,
1172 })
1173 );
1174 assert_eq!(
1175 resolve(&["text.tex.latex", "constant.character.math.tex"]).foreground,
1176 Some(RgbColor {
1177 red: 0xff,
1178 green: 0x94,
1179 blue: 0x92,
1180 })
1181 );
1182 }
1183
1184 #[test]
1185 fn shared_scope_name_resolution_matches_table_resolution() {
1186 let theme = github_dark_high_contrast();
1187 for scopes in [
1188 &[
1189 "text.tex.latex",
1190 "support.function.be.latex",
1191 "punctuation.definition.function.latex",
1192 ][..],
1193 &["source.test", "string.quoted", "variable.custom"][..],
1194 &["text.html.markdown", "markup.bold.markdown"][..],
1195 ] {
1196 let (table, stack) = HighlightScopeTable::from_scope_names(scopes);
1197 let shared = scopes
1198 .iter()
1199 .map(|scope| Arc::<str>::from(*scope))
1200 .collect::<Vec<_>>();
1201 assert_eq!(
1202 theme.resolve_shared_scope_names(&shared),
1203 theme.resolve(&table, stack)
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn nested_unmatched_scope_inherits_parent_theme_attributes() {
1210 let theme = github_dark_high_contrast();
1211 let (table, stack) = HighlightScopeTable::from_scope_names(&[
1212 "text.tex.latex",
1213 "support.function.be.latex",
1214 "punctuation.definition.function.latex",
1215 ]);
1216 assert_eq!(
1217 theme.resolve(&table, stack).foreground,
1218 Some(RgbColor {
1219 red: 0x91,
1220 green: 0xcb,
1221 blue: 0xff,
1222 })
1223 );
1224 }
1225
1226 #[test]
1227 fn parent_selectors_and_font_modifiers_resolve() {
1228 let theme = github_dark_high_contrast();
1229 let (table, stack) =
1230 HighlightScopeTable::from_scope_names(&["text.html.markdown", "markup.bold.markdown"]);
1231 assert!(
1232 theme
1233 .resolve(&table, stack)
1234 .modifiers
1235 .contains(FontModifiers::BOLD)
1236 );
1237
1238 let (table, stack) = HighlightScopeTable::from_scope_names(&[
1239 "source.test",
1240 "string.quoted",
1241 "variable.custom",
1242 ]);
1243 assert_eq!(
1244 theme.resolve(&table, stack).foreground,
1245 Some(RgbColor {
1246 red: 0x91,
1247 green: 0xcb,
1248 blue: 0xff,
1249 })
1250 );
1251 }
1252
1253 #[test]
1254 fn nonopaque_user_syntax_rule_colors_preserve_their_rgb_channels() {
1255 let theme = TextMateTheme::from_rules(&[ThemeRule {
1256 scope: "constant.numeric".to_owned(),
1257 foreground: Some("#ff000080".to_owned()),
1258 background: Some("#0f08".to_owned()),
1259 ..ThemeRule::default()
1260 }])
1261 .unwrap();
1262 let (table, stack) = HighlightScopeTable::from_scope_names(&["constant.numeric"]);
1263
1264 assert_eq!(
1265 theme.resolve(&table, stack),
1266 Style {
1267 foreground: Some(RgbColor {
1268 red: 0xff,
1269 green: 0,
1270 blue: 0,
1271 }),
1272 background: Some(RgbColor {
1273 red: 0,
1274 green: 0xff,
1275 blue: 0,
1276 }),
1277 modifiers: FontModifiers::empty(),
1278 }
1279 );
1280 }
1281
1282 #[test]
1283 fn user_syntax_rules_use_the_theme_selector_engine() {
1284 let theme = TextMateTheme::from_rules(&[
1285 ThemeRule {
1286 scope: "source.test support.function".to_owned(),
1287 foreground: Some("#123456".to_owned()),
1288 font_style: Some("bold italic".to_owned()),
1289 ..ThemeRule::default()
1290 },
1291 ThemeRule {
1292 scope: "support.function.deep".to_owned(),
1293 font_style: Some(String::new()),
1294 ..ThemeRule::default()
1295 },
1296 ])
1297 .unwrap();
1298 let (table, stack) = HighlightScopeTable::from_scope_names(&[
1299 "source.test",
1300 "meta.middle",
1301 "support.function.deep",
1302 ]);
1303 let matched = theme.resolve_with_match(&table, stack);
1304 assert_eq!(matched.selector, Some("support.function.deep"));
1305 assert!(matched.foreground_matched);
1306 assert!(matched.modifiers_matched);
1307 assert_eq!(matched.style.modifiers, FontModifiers::empty());
1308 assert_eq!(
1309 matched.style.foreground,
1310 Some(RgbColor {
1311 red: 0x12,
1312 green: 0x34,
1313 blue: 0x56
1314 })
1315 );
1316 assert_eq!(
1317 matched.score.as_ref().map(|score| score.target_depth),
1318 Some(3)
1319 );
1320 }
1321}