Skip to main content

syntaxmate/theme/
mod.rs

1//! TextMate color-theme parsing and selector resolution.
2//!
3//! This is deliberately independent of the tokenizer: a theme can be changed
4//! while reusing the immutable scope table in [`crate::HighlightedLine`].
5
6use 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/// An opaque sRGB color with 8-bit channels.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub struct RgbColor {
63    /// Red channel, from 0 to 255.
64    pub red: u8,
65    /// Green channel, from 0 to 255.
66    pub green: u8,
67    /// Blue channel, from 0 to 255.
68    pub blue: u8,
69}
70
71/// A set of resolved font decorations.
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
73pub struct FontModifiers(u8);
74
75impl FontModifiers {
76    /// Bold text.
77    pub const BOLD: Self = Self(0b0001);
78    /// Italic text.
79    pub const ITALIC: Self = Self(0b0010);
80    /// Underlined text.
81    pub const UNDERLINED: Self = Self(0b0100);
82    /// Struck-through text.
83    pub const CROSSED_OUT: Self = Self(0b1000);
84
85    /// Returns an empty modifier set.
86    pub const fn empty() -> Self {
87        Self(0)
88    }
89
90    /// Returns whether all modifiers in `other` are present.
91    pub const fn contains(self, other: Self) -> bool {
92        self.0 & other.0 == other.0
93    }
94
95    /// Returns whether the collection is empty.
96    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/// Resolved foreground, background, and font decorations.
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
107pub struct Style {
108    /// Foreground color, or `None` to inherit.
109    pub foreground: Option<RgbColor>,
110    /// Background color, or `None` to inherit.
111    pub background: Option<RgbColor>,
112    /// Resolved font decorations.
113    pub modifiers: FontModifiers,
114}
115
116/// Render-relevant theme resolution data, excluding diagnostic selector data.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
118pub struct ResolvedThemeStyle {
119    /// Whether a selector explicitly supplied the foreground.
120    pub foreground_matched: bool,
121    /// Whether a selector explicitly supplied the background.
122    pub background_matched: bool,
123    /// Whether a selector explicitly supplied font decorations.
124    pub modifiers_matched: bool,
125    /// Resolved style including theme defaults.
126    pub style: Style,
127}
128
129/// Diagnostic style resolution and representative selector metadata.
130#[cfg(any(feature = "diagnostics", test))]
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ThemeMatch<'a> {
133    /// Representative matching selector, if any.
134    pub selector: Option<&'a str>,
135    /// Specificity of the representative selector.
136    pub score: Option<ThemeSelectorScore>,
137    /// Zero-based rule order in the theme.
138    pub source_order: Option<usize>,
139    /// Whether a selector explicitly supplied the foreground.
140    pub foreground_matched: bool,
141    /// Whether a selector explicitly supplied the background.
142    pub background_matched: bool,
143    /// Whether a selector explicitly supplied font decorations.
144    pub modifiers_matched: bool,
145    /// Resolved style including theme defaults.
146    pub style: Style,
147}
148
149struct ScopeResolution<'a> {
150    // Only selector diagnostics consume this metadata.
151    #[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/// Diagnostic specificity components for a matching selector.
160#[cfg(any(feature = "diagnostics", test))]
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ThemeSelectorScore {
163    /// Number of dotted components in the selector target.
164    pub target_depth: usize,
165    /// Lengths of ancestor selector components, from nearest to farthest.
166    pub parent_lengths: Vec<usize>,
167    /// Number of ancestor selector components.
168    pub parent_count: usize,
169    /// Zero-based rule order in the theme.
170    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    // Deepest parent first, matching vscode-textmate's representation.
188    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    /// Compiles post-theme user selector rules through the same matcher as
393    /// built-in TextMate themes.
394    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    // Ascending rank; the bool preserves explicit font resets versus inheritance.
450    #[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    /// Resolves and caches all style and property-match data needed by a
494    /// renderer. Diagnostic selector metadata remains available separately
495    /// through the diagnostics feature.
496    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        // vscode-textmate applies theme attributes every time a scope is
554        // pushed, merging only properties set by the new scope into the
555        // attributes inherited from its parent. Resolving only the innermost
556        // scope incorrectly turns punctuation nested in support.function back
557        // to the editor default, which was the remaining LaTeX discrepancy.
558        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            // VS Code themes use `normal` to explicitly clear inherited styles.
820            "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    // User rules have no single background to composite against: their tokens
846    // can be rendered over theme, diff, or inline-diff backgrounds. Preserve
847    // the historical behavior of accepting alpha forms and using their RGB
848    // channels rather than rejecting an existing configuration.
849    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}