1use super::user::parse_color;
11use super::{Role, Style};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum StyleParseError {
17 UnknownRole(String),
18 InvalidFg(String),
19 UnknownToken(String),
20 MalformedRole(String),
21 MalformedFg(String),
22 UnclosedParen(String),
23 StrayCloseParen(String),
24}
25
26impl std::fmt::Display for StyleParseError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 Self::UnknownRole(s) => write!(f, "unknown role '{s}'"),
30 Self::InvalidFg(s) => write!(f, "invalid fg color '{s}'"),
31 Self::UnknownToken(s) => write!(f, "unknown style token '{s}'"),
32 Self::MalformedRole(s) => write!(f, "malformed role directive '{s}'"),
33 Self::MalformedFg(s) => write!(f, "malformed fg directive '{s}'"),
34 Self::UnclosedParen(s) => write!(f, "unclosed paren in '{s}'"),
35 Self::StrayCloseParen(s) => write!(f, "stray ')' in '{s}'"),
36 }
37 }
38}
39
40impl std::error::Error for StyleParseError {}
41
42pub fn parse_style(s: &str) -> Result<Style, StyleParseError> {
46 let mut style = Style::default();
47 for token in tokenize(s)? {
48 let (head, rest) = token.split_once(':').unzip();
49 let head = head.map(str::to_ascii_lowercase);
50 match head.as_deref() {
51 Some("role") => {
52 let name = rest.unwrap_or("");
53 if name.is_empty() {
54 return Err(StyleParseError::MalformedRole(token.to_string()));
55 }
56 style.role = Some(parse_role(name)?);
57 }
58 Some("fg") => {
59 let color = rest.unwrap_or("");
60 if color.is_empty() {
61 return Err(StyleParseError::MalformedFg(token.to_string()));
62 }
63 style.fg = Some(
64 parse_color(color)
65 .map_err(|_| StyleParseError::InvalidFg(color.to_string()))?,
66 );
67 }
68 _ => match token.to_ascii_lowercase().as_str() {
69 "bold" => style.bold = true,
70 "italic" => style.italic = true,
71 "underline" => style.underline = true,
72 "dim" => style.dim = true,
73 _ => return Err(StyleParseError::UnknownToken(token.to_string())),
74 },
75 }
76 }
77 Ok(style)
78}
79
80fn tokenize(s: &str) -> Result<Vec<&str>, StyleParseError> {
87 let mut tokens = Vec::new();
88 let mut start: Option<usize> = None;
89 let mut depth: u32 = 0;
90 for (i, c) in s.char_indices() {
91 if c.is_whitespace() && depth == 0 {
92 if let Some(s0) = start.take() {
93 tokens.push(&s[s0..i]);
94 }
95 continue;
96 }
97 if start.is_none() {
98 start = Some(i);
99 }
100 match c {
101 '(' => depth += 1,
102 ')' => {
103 if depth == 0 {
104 let s0 = start.unwrap_or(i);
105 return Err(StyleParseError::StrayCloseParen(s[s0..].to_string()));
106 }
107 depth -= 1;
108 }
109 _ => {}
110 }
111 }
112 if depth > 0 {
113 let offending = start.map(|s0| &s[s0..]).unwrap_or("");
114 return Err(StyleParseError::UnclosedParen(offending.to_string()));
115 }
116 if let Some(s0) = start {
117 tokens.push(&s[s0..]);
118 }
119 Ok(tokens)
120}
121
122fn parse_role(s: &str) -> Result<Role, StyleParseError> {
123 let role = match s.to_ascii_lowercase().as_str() {
124 "foreground" => Role::Foreground,
125 "background" => Role::Background,
126 "muted" => Role::Muted,
127 "primary" => Role::Primary,
128 "accent" => Role::Accent,
129 "success" => Role::Success,
130 "warning" => Role::Warning,
131 "error" => Role::Error,
132 "info" => Role::Info,
133 "success_dim" | "success-dim" => Role::SuccessDim,
134 "warning_dim" | "warning-dim" => Role::WarningDim,
135 "error_dim" | "error-dim" => Role::ErrorDim,
136 "primary_dim" | "primary-dim" => Role::PrimaryDim,
137 "accent_dim" | "accent-dim" => Role::AccentDim,
138 "surface" => Role::Surface,
139 "border" => Role::Border,
140 _ => return Err(StyleParseError::UnknownRole(s.to_string())),
141 };
142 Ok(role)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::theme::{AnsiColor, Color};
149
150 #[test]
151 fn empty_string_yields_default_style() {
152 assert_eq!(parse_style(""), Ok(Style::default()));
153 }
154
155 #[test]
156 fn whitespace_only_yields_default_style() {
157 assert_eq!(parse_style(" \t \n "), Ok(Style::default()));
158 }
159
160 #[test]
161 fn role_directive_sets_role() {
162 assert_eq!(parse_style("role:primary"), Ok(Style::role(Role::Primary)));
163 }
164
165 #[test]
166 fn role_plus_decorations_combine() {
167 let got = parse_style("role:success bold italic").expect("ok");
168 assert_eq!(got.role, Some(Role::Success));
169 assert!(got.bold);
170 assert!(got.italic);
171 assert!(!got.underline);
172 assert!(!got.dim);
173 }
174
175 #[test]
176 fn fg_hex_and_decoration() {
177 let got = parse_style("fg:#ff0000 underline").expect("ok");
178 assert_eq!(got.fg, Some(Color::TrueColor { r: 255, g: 0, b: 0 }));
179 assert_eq!(got.role, None);
180 assert!(got.underline);
181 }
182
183 #[test]
184 fn fg_named_color() {
185 let got = parse_style("fg:red").expect("ok");
186 assert_eq!(got.fg, Some(Color::Palette16(AnsiColor::Red)));
187 }
188
189 #[test]
190 fn fg_rgb_function() {
191 let got = parse_style("fg:rgb(203,166,247)").expect("ok");
192 assert_eq!(
193 got.fg,
194 Some(Color::TrueColor {
195 r: 203,
196 g: 166,
197 b: 247,
198 })
199 );
200 }
201
202 #[test]
203 fn fg_rgb_with_spaces_inside_parens_is_one_token() {
204 let got = parse_style("fg:rgb(203, 166, 247) bold").expect("ok");
207 assert_eq!(
208 got.fg,
209 Some(Color::TrueColor {
210 r: 203,
211 g: 166,
212 b: 247,
213 })
214 );
215 assert!(got.bold);
216 }
217
218 #[test]
219 fn unclosed_paren_errors() {
220 match parse_style("fg:rgb(203,166 bold") {
221 Err(StyleParseError::UnclosedParen(s)) => {
222 assert!(s.starts_with("fg:rgb("), "got {s:?}");
223 }
224 other => panic!("expected UnclosedParen, got {other:?}"),
225 }
226 }
227
228 #[test]
229 fn stray_close_paren_errors_before_reaching_parse_color() {
230 match parse_style("fg:rgb(1,2,3))") {
234 Err(StyleParseError::StrayCloseParen(s)) => {
235 assert!(s.starts_with("fg:rgb("), "got {s:?}");
236 }
237 other => panic!("expected StrayCloseParen, got {other:?}"),
238 }
239 }
240
241 #[test]
242 fn bare_close_paren_errors() {
243 match parse_style("bold )") {
244 Err(StyleParseError::StrayCloseParen(_)) => {}
245 other => panic!("expected StrayCloseParen, got {other:?}"),
246 }
247 }
248
249 #[test]
250 fn nested_parens_are_one_token() {
251 match parse_style("fg:rgb((1,2,3))") {
254 Err(StyleParseError::InvalidFg(_)) => {}
255 other => {
256 panic!("expected InvalidFg (parse_color rejects double parens), got {other:?}")
257 }
258 }
259 }
260
261 #[test]
262 fn case_insensitive_tokens() {
263 let got = parse_style("ROLE:PRIMARY BOLD ITALIC").expect("ok");
264 assert_eq!(got.role, Some(Role::Primary));
265 assert!(got.bold);
266 assert!(got.italic);
267 }
268
269 #[test]
270 fn mixed_case_directive_prefix_parses() {
271 assert_eq!(
274 parse_style("Role:Accent Fg:#ff0000").expect("ok"),
275 Style {
276 role: Some(Role::Accent),
277 fg: Some(Color::TrueColor { r: 255, g: 0, b: 0 }),
278 ..Style::default()
279 }
280 );
281 }
282
283 #[test]
284 fn order_does_not_matter() {
285 let a = parse_style("role:info bold italic").expect("ok");
286 let b = parse_style("italic bold role:info").expect("ok");
287 let c = parse_style("bold role:info italic").expect("ok");
288 assert_eq!(a, b);
289 assert_eq!(b, c);
290 }
291
292 #[test]
293 fn all_four_decorations_compose() {
294 let got = parse_style("bold italic underline dim").expect("ok");
295 assert!(got.bold);
296 assert!(got.italic);
297 assert!(got.underline);
298 assert!(got.dim);
299 }
300
301 #[test]
302 fn extended_role_with_underscore_and_hyphen_both_work() {
303 assert_eq!(
304 parse_style("role:success_dim").unwrap().role,
305 Some(Role::SuccessDim)
306 );
307 assert_eq!(
308 parse_style("role:success-dim").unwrap().role,
309 Some(Role::SuccessDim)
310 );
311 }
312
313 #[test]
314 fn unknown_role_errors_with_input() {
315 match parse_style("role:mauve") {
316 Err(StyleParseError::UnknownRole(s)) => assert_eq!(s, "mauve"),
317 other => panic!("expected UnknownRole, got {other:?}"),
318 }
319 }
320
321 #[test]
322 fn invalid_fg_errors_with_input() {
323 match parse_style("fg:notacolor") {
324 Err(StyleParseError::InvalidFg(s)) => assert_eq!(s, "notacolor"),
325 other => panic!("expected InvalidFg, got {other:?}"),
326 }
327 }
328
329 #[test]
330 fn unknown_token_errors() {
331 match parse_style("role:primary wobbly") {
332 Err(StyleParseError::UnknownToken(s)) => assert_eq!(s, "wobbly"),
333 other => panic!("expected UnknownToken, got {other:?}"),
334 }
335 }
336
337 #[test]
338 fn malformed_role_directive_errors() {
339 match parse_style("role: bold") {
340 Err(StyleParseError::MalformedRole(s)) => assert_eq!(s, "role:"),
341 other => panic!("expected MalformedRole, got {other:?}"),
342 }
343 }
344
345 #[test]
346 fn malformed_fg_directive_errors() {
347 match parse_style("fg:") {
348 Err(StyleParseError::MalformedFg(s)) => assert_eq!(s, "fg:"),
349 other => panic!("expected MalformedFg, got {other:?}"),
350 }
351 }
352
353 #[test]
354 fn parser_populates_both_fg_and_role_when_both_specified() {
355 let got = parse_style("role:primary fg:#ff8800 bold").expect("ok");
358 assert_eq!(got.role, Some(Role::Primary));
359 assert_eq!(
360 got.fg,
361 Some(Color::TrueColor {
362 r: 255,
363 g: 136,
364 b: 0
365 })
366 );
367 assert!(got.bold);
368 }
369
370 #[test]
371 fn duplicate_role_token_last_wins() {
372 assert_eq!(
373 parse_style("role:primary role:accent").unwrap().role,
374 Some(Role::Accent)
375 );
376 }
377
378 #[test]
379 fn duplicate_fg_token_last_wins() {
380 let got = parse_style("fg:#ff0000 fg:#00ff00").expect("ok");
381 assert_eq!(got.fg, Some(Color::TrueColor { r: 0, g: 255, b: 0 }));
382 }
383
384 #[test]
385 fn duplicate_decoration_token_is_idempotent() {
386 let got = parse_style("bold bold").expect("ok");
387 assert!(got.bold);
388 }
389
390 #[test]
391 fn error_display_quotes_offending_input() {
392 let err = StyleParseError::UnknownRole("mauve".into());
393 assert_eq!(err.to_string(), "unknown role 'mauve'");
394 let err = StyleParseError::InvalidFg("xyz".into());
395 assert_eq!(err.to_string(), "invalid fg color 'xyz'");
396 }
397}