Skip to main content

polyfont_core/
engine.rs

1use crate::{FontAssignment, FontRule, TokenInfo};
2
3pub trait PolyfontEngine: Send + Sync {
4    fn add_rule(&mut self, rule: FontRule);
5
6    fn remove_rule(&mut self, scope: &str);
7
8    fn rules(&self) -> &[FontRule];
9
10    fn resolve_token(&self, token: &TokenInfo) -> Option<FontAssignment>;
11
12    fn resolve_all(&self, tokens: &[TokenInfo]) -> Vec<Option<FontAssignment>>;
13
14    fn clear(&mut self);
15}
16
17pub struct ScopeMatchEngine {
18    rules: Vec<FontRule>,
19}
20
21impl ScopeMatchEngine {
22    #[must_use]
23    pub const fn new() -> Self {
24        Self { rules: Vec::new() }
25    }
26
27    #[must_use]
28    pub fn from_rules(rules: Vec<FontRule>) -> Self {
29        let mut engine = Self { rules };
30        engine.sort_rules();
31        engine
32    }
33
34    fn sort_rules(&mut self) {
35        self.rules
36            .sort_by_key(|b| std::cmp::Reverse(b.specificity()));
37    }
38
39    #[must_use]
40    pub fn scope_matches(scope: &str, pattern: &str) -> bool {
41        if pattern == "*" {
42            return true;
43        }
44        if scope == pattern {
45            return true;
46        }
47        scope.len() > pattern.len()
48            && scope.as_bytes()[pattern.len()] == b'.'
49            && scope.starts_with(pattern)
50    }
51}
52
53impl PolyfontEngine for ScopeMatchEngine {
54    fn add_rule(&mut self, rule: FontRule) {
55        self.rules.push(rule);
56        self.sort_rules();
57    }
58
59    fn remove_rule(&mut self, scope: &str) {
60        self.rules.retain(|r| r.scope != scope);
61    }
62
63    fn rules(&self) -> &[FontRule] {
64        &self.rules
65    }
66
67    fn resolve_token(&self, token: &TokenInfo) -> Option<FontAssignment> {
68        for rule in &self.rules {
69            if Self::scope_matches(&token.scope, &rule.scope) {
70                return Some(FontAssignment {
71                    scope: token.scope.clone(),
72                    font: rule.font.clone(),
73                    specificity: rule.specificity(),
74                    is_active: true,
75                });
76            }
77        }
78        None
79    }
80
81    fn resolve_all(&self, tokens: &[TokenInfo]) -> Vec<Option<FontAssignment>> {
82        tokens.iter().map(|t| self.resolve_token(t)).collect()
83    }
84
85    fn clear(&mut self) {
86        self.rules.clear();
87    }
88}
89
90impl Default for ScopeMatchEngine {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::font::{FontRule, FontSpec};
100    use crate::token::{Position, Range, TokenInfo};
101
102    #[test]
103    fn test_new_engine_has_no_rules() {
104        let engine = ScopeMatchEngine::new();
105        assert!(engine.rules().is_empty());
106    }
107
108    #[test]
109    fn test_add_rule() {
110        let mut engine = ScopeMatchEngine::new();
111        engine.add_rule(FontRule {
112            scope: "keyword".to_string(),
113            font: FontSpec::default_font("TestFont"),
114        });
115        assert_eq!(engine.rules().len(), 1);
116    }
117
118    #[test]
119    fn test_remove_rule() {
120        let mut engine = ScopeMatchEngine::new();
121        engine.add_rule(FontRule {
122            scope: "keyword".to_string(),
123            font: FontSpec::default_font("TestFont"),
124        });
125        engine.remove_rule("keyword");
126        assert!(engine.rules().is_empty());
127    }
128
129    #[test]
130    fn test_scope_matches_exact() {
131        assert!(ScopeMatchEngine::scope_matches("keyword", "keyword"));
132    }
133
134    #[test]
135    fn test_scope_matches_hierarchical() {
136        assert!(ScopeMatchEngine::scope_matches(
137            "keyword.control",
138            "keyword"
139        ));
140        assert!(ScopeMatchEngine::scope_matches(
141            "entity.name.function",
142            "entity"
143        ));
144        assert!(ScopeMatchEngine::scope_matches(
145            "entity.name.function",
146            "entity.name"
147        ));
148    }
149
150    #[test]
151    fn test_scope_matches_wildcard() {
152        assert!(ScopeMatchEngine::scope_matches("anything", "*"));
153        assert!(ScopeMatchEngine::scope_matches("keyword.control", "*"));
154    }
155
156    #[test]
157    fn test_scope_no_false_positive() {
158        assert!(!ScopeMatchEngine::scope_matches(
159            "keyword",
160            "keyword.control"
161        ));
162        assert!(!ScopeMatchEngine::scope_matches("keywordx", "keyword"));
163    }
164
165    #[test]
166    fn test_resolve_token_most_specific_wins() {
167        let mut engine = ScopeMatchEngine::new();
168        engine.add_rule(FontRule {
169            scope: "keyword".to_string(),
170            font: FontSpec::default_font("FontA"),
171        });
172        engine.add_rule(FontRule {
173            scope: "keyword.control".to_string(),
174            font: FontSpec::default_font("FontB"),
175        });
176
177        let token = TokenInfo {
178            text: "if".to_string(),
179            range: Range {
180                start: Position { line: 0, column: 0 },
181                end: Position { line: 0, column: 2 },
182            },
183            scope: "keyword.control".to_string(),
184            modifiers: vec![],
185        };
186
187        let result = engine.resolve_token(&token).unwrap();
188        assert_eq!(result.font.family, "FontB");
189    }
190
191    #[test]
192    fn test_resolve_all() {
193        let mut engine = ScopeMatchEngine::new();
194        engine.add_rule(FontRule {
195            scope: "keyword".to_string(),
196            font: FontSpec::default_font("FontA"),
197        });
198
199        let tokens = vec![
200            TokenInfo {
201                text: "fn".to_string(),
202                range: Range {
203                    start: Position { line: 0, column: 0 },
204                    end: Position { line: 0, column: 2 },
205                },
206                scope: "keyword".to_string(),
207                modifiers: vec![],
208            },
209            TokenInfo {
210                text: "x".to_string(),
211                range: Range {
212                    start: Position { line: 0, column: 3 },
213                    end: Position { line: 0, column: 4 },
214                },
215                scope: "variable".to_string(),
216                modifiers: vec![],
217            },
218        ];
219
220        let results = engine.resolve_all(&tokens);
221        let matched: Vec<_> = results.iter().flatten().collect();
222        assert_eq!(matched.len(), 1);
223        assert_eq!(matched[0].scope, "keyword");
224    }
225
226    #[test]
227    fn test_clear() {
228        let mut engine = ScopeMatchEngine::new();
229        engine.add_rule(FontRule {
230            scope: "keyword".to_string(),
231            font: FontSpec::default_font("FontA"),
232        });
233        engine.clear();
234        assert!(engine.rules().is_empty());
235    }
236}