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}