Skip to main content

rmux_core/
style.rs

1//! tmux-compatible style and colour parsing.
2//!
3//! This module owns the shared style grammar used by options, format style
4//! clauses, and renderer-facing colour parsing. The parser is additive: it
5//! mutates an existing [`Style`] using a base [`StyleCell`] for `default`
6//! resets, matching tmux `style_parse`.
7
8use std::fmt;
9
10use crate::input::{Colour, GridAttr, COLOUR_DEFAULT};
11
12#[path = "style/colour.rs"]
13mod colour;
14#[path = "style/grammar.rs"]
15mod grammar;
16#[path = "style/types.rs"]
17mod types;
18
19pub use colour::{colour_to_string, parse_colour, ColourParseError};
20pub use types::{
21    Style, StyleAlign, StyleCell, StyleDefaultType, StyleList, StyleRange, StyleWidth,
22};
23
24use grammar::{attributes_to_string, parse_attributes, parse_range, parse_width, strip_prefix_ci};
25
26const STYLE_TOKEN_DELIMITERS: &[char] = &[' ', ',', '\n'];
27
28impl Style {
29    /// Parses a standalone style string using tmux's default base cell.
30    pub fn parse(input: &str) -> Result<Self, StyleParseError> {
31        let mut style = Self::default();
32        style.parse_in_place(&StyleCell::default(), input)?;
33        Ok(style)
34    }
35
36    /// Creates a style whose cell state starts from `cell`.
37    #[must_use]
38    pub fn with_cell(cell: StyleCell) -> Self {
39        Self {
40            cell,
41            ..Self::default()
42        }
43    }
44
45    /// Applies `input` onto the current style using `base` for `default`
46    /// resets.
47    pub fn parse_in_place(&mut self, base: &StyleCell, input: &str) -> Result<(), StyleParseError> {
48        if input.is_empty() {
49            return Ok(());
50        }
51
52        let saved = self.clone();
53        for token in input
54            .split(|character| STYLE_TOKEN_DELIMITERS.contains(&character))
55            .filter(|token| !token.is_empty())
56        {
57            if let Err(error) = self.apply_token(base, token) {
58                *self = saved;
59                return Err(error);
60            }
61        }
62        Ok(())
63    }
64
65    /// Returns a parsed copy of the current style.
66    pub fn applied(&self, base: &StyleCell, input: &str) -> Result<Self, StyleParseError> {
67        let mut next = self.clone();
68        next.parse_in_place(base, input)?;
69        Ok(next)
70    }
71
72    /// Returns a parsed copy of the current style using the current cell as the
73    /// additive base for `default` resets.
74    pub fn overlaid(&self, input: &str) -> Result<Self, StyleParseError> {
75        self.applied(&self.cell, input)
76    }
77
78    fn apply_token(&mut self, base: &StyleCell, token: &str) -> Result<(), StyleParseError> {
79        let lowered = token.to_ascii_lowercase();
80
81        // Exact keyword matches (case-insensitive).
82        match lowered.as_str() {
83            "default" => {
84                self.cell = *base;
85                return Ok(());
86            }
87            "ignore" => {
88                self.ignore = true;
89                return Ok(());
90            }
91            "noignore" => {
92                self.ignore = false;
93                return Ok(());
94            }
95            "push-default" => {
96                self.default_type = StyleDefaultType::Push;
97                return Ok(());
98            }
99            "pop-default" => {
100                self.default_type = StyleDefaultType::Pop;
101                return Ok(());
102            }
103            "set-default" => {
104                self.default_type = StyleDefaultType::Set;
105                return Ok(());
106            }
107            "nolist" => {
108                self.list = StyleList::Off;
109                return Ok(());
110            }
111            "norange" => {
112                self.range = StyleRange::None;
113                return Ok(());
114            }
115            "noalign" => {
116                self.align = StyleAlign::Default;
117                return Ok(());
118            }
119            "none" => {
120                self.cell.attr = 0;
121                return Ok(());
122            }
123            _ => {}
124        }
125
126        // Prefixed key=value directives.  `strip_prefix_ci` preserves the
127        // original casing of the value portion (needed for colour hex digits).
128        if let Some(value) = strip_prefix_ci(token, "list=") {
129            self.list = match value.to_ascii_lowercase().as_str() {
130                "on" => StyleList::On,
131                "focus" => StyleList::Focus,
132                "left-marker" => StyleList::LeftMarker,
133                "right-marker" => StyleList::RightMarker,
134                _ => return Err(StyleParseError::invalid(token)),
135            };
136            return Ok(());
137        }
138
139        if let Some(value) = strip_prefix_ci(token, "range=") {
140            self.range = parse_range(value).ok_or_else(|| StyleParseError::invalid(token))?;
141            return Ok(());
142        }
143
144        if let Some(value) = strip_prefix_ci(token, "align=") {
145            self.align = match value.to_ascii_lowercase().as_str() {
146                "left" => StyleAlign::Left,
147                "centre" => StyleAlign::Centre,
148                "right" => StyleAlign::Right,
149                "absolute-centre" => StyleAlign::AbsoluteCentre,
150                _ => return Err(StyleParseError::invalid(token)),
151            };
152            return Ok(());
153        }
154
155        if let Some(value) = strip_prefix_ci(token, "fill=") {
156            self.fill = parse_colour(value).map_err(|_| StyleParseError::invalid(token))?;
157            return Ok(());
158        }
159
160        if let Some(value) = strip_prefix_ci(token, "fg=") {
161            self.cell.fg = resolve_style_colour(base.fg, value, token)?;
162            return Ok(());
163        }
164
165        if let Some(value) = strip_prefix_ci(token, "bg=") {
166            self.cell.bg = resolve_style_colour(base.bg, value, token)?;
167            return Ok(());
168        }
169
170        if let Some(value) = strip_prefix_ci(token, "us=") {
171            self.cell.us = resolve_style_colour(base.us, value, token)?;
172            return Ok(());
173        }
174
175        if let Some(value) = strip_prefix_ci(token, "width=") {
176            self.width = Some(parse_width(value).ok_or_else(|| StyleParseError::invalid(token))?);
177            return Ok(());
178        }
179
180        if let Some(value) = strip_prefix_ci(token, "pad=") {
181            self.pad = Some(
182                value
183                    .parse::<u32>()
184                    .map_err(|_| StyleParseError::invalid(token))?,
185            );
186            return Ok(());
187        }
188
189        // Attribute negation: "noattr" sets the NOATTR sentinel,
190        // "no<attr>" clears the named attribute bit.
191        if let Some(attr_name) = lowered.strip_prefix("no") {
192            if attr_name == "attr" {
193                self.cell.attr |= GridAttr::NOATTR;
194                return Ok(());
195            }
196            let bits =
197                parse_attributes(attr_name).ok_or_else(|| StyleParseError::invalid(token))?;
198            self.cell.attr &= !bits;
199            return Ok(());
200        }
201
202        // Bare colour as implicit fg (uses original token for hex casing).
203        if let Ok(colour) = parse_colour(token) {
204            self.cell.fg = if colour == COLOUR_DEFAULT {
205                base.fg
206            } else {
207                colour
208            };
209            return Ok(());
210        }
211
212        // Bare attribute name.
213        let bits = parse_attributes(token).ok_or_else(|| StyleParseError::invalid(token))?;
214        self.cell.attr |= bits;
215        Ok(())
216    }
217}
218
219/// Parses `input` onto `style`, matching tmux `style_parse`.
220pub fn style_parse(
221    style: &mut Style,
222    base: &StyleCell,
223    input: &str,
224) -> Result<(), StyleParseError> {
225    style.parse_in_place(base, input)
226}
227
228/// Returns the canonical tmux string form for `style`.
229#[must_use]
230pub fn style_tostring(style: &Style) -> String {
231    let mut tokens = Vec::new();
232
233    if let Some(list) = style.list.as_tmux_str() {
234        tokens.push(format!("list={list}"));
235    }
236    if let Some(range) = style.range.as_tmux_value() {
237        tokens.push(format!("range={range}"));
238    }
239    if let Some(align) = style.align.as_tmux_str() {
240        tokens.push(format!("align={align}"));
241    }
242    if let Some(default_type) = style.default_type.as_tmux_str() {
243        tokens.push(default_type.to_owned());
244    }
245    if style.fill != COLOUR_DEFAULT {
246        tokens.push(format!("fill={}", colour_to_string(style.fill)));
247    }
248    if style.cell.fg != COLOUR_DEFAULT {
249        tokens.push(format!("fg={}", colour_to_string(style.cell.fg)));
250    }
251    if style.cell.bg != COLOUR_DEFAULT {
252        tokens.push(format!("bg={}", colour_to_string(style.cell.bg)));
253    }
254    if style.cell.us != COLOUR_DEFAULT {
255        tokens.push(format!("us={}", colour_to_string(style.cell.us)));
256    }
257    if style.cell.attr != 0 {
258        tokens.push(attributes_to_string(style.cell.attr));
259    }
260    if let Some(width) = style.width {
261        tokens.push(format!("width={}", width.as_tmux_value()));
262    }
263    if let Some(pad) = style.pad {
264        tokens.push(format!("pad={pad}"));
265    }
266
267    if tokens.is_empty() {
268        "default".to_owned()
269    } else {
270        tokens.join(",")
271    }
272}
273
274fn resolve_style_colour(base: Colour, value: &str, token: &str) -> Result<Colour, StyleParseError> {
275    let colour = parse_colour(value).map_err(|_| StyleParseError::invalid(token))?;
276    Ok(if colour == COLOUR_DEFAULT {
277        base
278    } else {
279        colour
280    })
281}
282
283/// Style parse failure.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct StyleParseError {
286    token: String,
287}
288
289impl StyleParseError {
290    fn invalid(token: &str) -> Self {
291        Self {
292            token: token.to_owned(),
293        }
294    }
295}
296
297impl fmt::Display for StyleParseError {
298    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
299        write!(formatter, "invalid style token: {}", self.token)
300    }
301}
302
303impl std::error::Error for StyleParseError {}
304
305#[cfg(test)]
306mod tests {
307    use super::{
308        colour_to_string, parse_colour, style_parse, style_tostring, ColourParseError, Style,
309        StyleAlign, StyleCell, StyleDefaultType, StyleList, StyleRange, StyleWidth,
310    };
311    use crate::input::{
312        colour_join_rgb, GridAttr, COLOUR_DEFAULT, COLOUR_FLAG_256, COLOUR_NONE, COLOUR_TERMINAL,
313    };
314
315    fn default_base() -> StyleCell {
316        StyleCell::default()
317    }
318
319    fn parse_style(input: &str) -> Style {
320        Style::parse(input).expect("style parses")
321    }
322
323    #[test]
324    fn colour_parser_accepts_all_supported_forms() {
325        for (input, expected) in [
326            ("black", 0),
327            ("red", 1),
328            ("green", 2),
329            ("yellow", 3),
330            ("blue", 4),
331            (concat!("mag", "enta"), 5),
332            ("cyan", 6),
333            ("white", 7),
334            ("brightblack", 90),
335            ("brightred", 91),
336            ("brightgreen", 92),
337            ("brightyellow", 93),
338            ("brightblue", 94),
339            (concat!("bright", "mag", "enta"), 95),
340            ("brightcyan", 96),
341            ("brightwhite", 97),
342            ("colour214", COLOUR_FLAG_256 | 214),
343            ("color33", COLOUR_FLAG_256 | 33),
344            ("214", COLOUR_FLAG_256 | 214),
345            ("default", COLOUR_DEFAULT),
346            ("terminal", COLOUR_TERMINAL),
347            ("none", COLOUR_NONE),
348        ] {
349            assert_eq!(parse_colour(input), Ok(expected), "{input}");
350        }
351        assert_eq!(
352            parse_colour("#123456"),
353            Ok(colour_join_rgb(0x12, 0x34, 0x56))
354        );
355        assert_eq!(
356            parse_colour("colour256"),
357            Err(ColourParseError::Invalid("colour256".to_owned()))
358        );
359    }
360
361    #[test]
362    fn colour_tostring_canonicalizes_supported_forms() {
363        assert_eq!(colour_to_string(COLOUR_NONE), "none");
364        assert_eq!(colour_to_string(COLOUR_DEFAULT), "default");
365        assert_eq!(colour_to_string(COLOUR_TERMINAL), "terminal");
366        assert_eq!(colour_to_string(COLOUR_FLAG_256 | 214), "colour214");
367        assert_eq!(
368            colour_to_string(colour_join_rgb(0x12, 0x34, 0x56)),
369            "#123456"
370        );
371    }
372
373    #[test]
374    fn style_parser_accepts_attributes_none_noattr_and_negation() {
375        let style = parse_style(
376            "acs,bold,bright,dim,underscore,blink,reverse,hidden,italics,\
377             strikethrough,double-underscore,curly-underscore,dotted-underscore,\
378             dashed-underscore,overline,noattr",
379        );
380        assert_eq!(
381            style.cell.attr,
382            GridAttr::CHARSET
383                | GridAttr::BRIGHT
384                | GridAttr::DIM
385                | GridAttr::UNDERSCORE
386                | GridAttr::BLINK
387                | GridAttr::REVERSE
388                | GridAttr::HIDDEN
389                | GridAttr::ITALICS
390                | GridAttr::STRIKETHROUGH
391                | GridAttr::UNDERSCORE_2
392                | GridAttr::UNDERSCORE_3
393                | GridAttr::UNDERSCORE_4
394                | GridAttr::UNDERSCORE_5
395                | GridAttr::OVERLINE
396                | GridAttr::NOATTR
397        );
398
399        let style = parse_style("bold,reverse,nobold,noreverse");
400        assert_eq!(style.cell.attr, 0);
401
402        let style = parse_style("none");
403        assert_eq!(style.cell.attr, 0);
404    }
405
406    #[test]
407    fn style_parser_accepts_prefixed_and_bare_colours() {
408        let style = parse_style("fg=red,bg=colour214,us=#123456,fill=214,blue");
409        assert_eq!(style.cell.fg, 4);
410        assert_eq!(style.cell.bg, COLOUR_FLAG_256 | 214);
411        assert_eq!(style.cell.us, colour_join_rgb(0x12, 0x34, 0x56));
412        assert_eq!(style.fill, COLOUR_FLAG_256 | 214);
413    }
414
415    #[test]
416    fn style_parser_accepts_list_align_range_width_pad_and_defaults() {
417        let style = parse_style(
418            "list=focus,align=absolute-centre,range=window|42,width=50%,pad=3,push-default,ignore",
419        );
420        assert_eq!(style.list, StyleList::Focus);
421        assert_eq!(style.align, StyleAlign::AbsoluteCentre);
422        assert_eq!(style.range, StyleRange::Window(42));
423        assert_eq!(style.width, Some(StyleWidth::Percentage(50)));
424        assert_eq!(style.pad, Some(3));
425        assert_eq!(style.default_type, StyleDefaultType::Push);
426        assert!(style.ignore);
427    }
428
429    #[test]
430    fn style_parser_is_case_insensitive_and_accepts_space_and_newline_delimiters() {
431        let style = parse_style(
432            "FG=RED BG=blue\nUs=#123456 Fill=214 BOLD REVERSE LIST=FOCUS RANGE=PANE|%7",
433        );
434        assert_eq!(style.cell.fg, 1);
435        assert_eq!(style.cell.bg, 4);
436        assert_eq!(style.cell.us, colour_join_rgb(0x12, 0x34, 0x56));
437        assert_eq!(style.fill, COLOUR_FLAG_256 | 214);
438        assert_eq!(style.cell.attr, GridAttr::BRIGHT | GridAttr::REVERSE);
439        assert_eq!(style.list, StyleList::Focus);
440        assert_eq!(style.range, StyleRange::Pane(7));
441    }
442
443    #[test]
444    fn style_parser_validates_all_range_variants() {
445        for input in [
446            "range=left",
447            "range=right",
448            "range=pane|%7",
449            "range=window|9",
450            "range=session|$11",
451            "range=user|custom-tag",
452            "range=control|9",
453        ] {
454            parse_style(input);
455        }
456
457        for input in [
458            "range=left|1",
459            "range=pane|7",
460            "range=session|11",
461            "range=control|10",
462            "range=user|",
463            "range=user|0123456789abcdef",
464            "range=window|x",
465        ] {
466            let mut style = Style::default();
467            assert!(
468                style_parse(&mut style, &default_base(), input).is_err(),
469                "{input}"
470            );
471            assert_eq!(style, Style::default(), "{input}");
472        }
473    }
474
475    #[test]
476    fn style_parser_supports_norange_nolist_noalign_and_noignore() {
477        let mut style =
478            parse_style("list=left-marker,align=right,range=right,ignore,width=3,pad=2");
479        style_parse(
480            &mut style,
481            &default_base(),
482            "nolist,noalign,norange,noignore",
483        )
484        .expect("second parse succeeds");
485        assert_eq!(style.list, StyleList::Off);
486        assert_eq!(style.align, StyleAlign::Default);
487        assert_eq!(style.range, StyleRange::None);
488        assert!(!style.ignore);
489        assert_eq!(style.width, Some(StyleWidth::Cells(3)));
490        assert_eq!(style.pad, Some(2));
491    }
492
493    #[test]
494    fn style_parser_is_additive_and_default_resets_to_base_cell() {
495        let base = parse_style("fg=red,bg=blue,bold");
496        let style = base.overlaid("bg=green,reverse").expect("overlay parses");
497        assert_eq!(style.cell.fg, 1);
498        assert_eq!(style.cell.bg, 2);
499        assert_eq!(style.cell.attr, GridAttr::BRIGHT | GridAttr::REVERSE);
500
501        let reset = style
502            .applied(&base.cell, "default")
503            .expect("default parses");
504        assert_eq!(reset.cell, base.cell);
505        assert_eq!(reset.fill, style.fill);
506    }
507
508    #[test]
509    fn empty_style_string_is_a_no_op() {
510        let style = parse_style("fg=red");
511        let next = style.overlaid("").expect("empty parse succeeds");
512        assert_eq!(next, style);
513    }
514
515    #[test]
516    fn invalid_style_keeps_the_original_value() {
517        let original = parse_style("fg=red,bold");
518        let mut style = original.clone();
519        assert!(style_parse(&mut style, &default_base(), "fg=invalid").is_err());
520        assert_eq!(style, original);
521    }
522
523    #[test]
524    fn style_tostring_round_trips_supported_states() {
525        let cases = [
526            "default",
527            "fg=red,bg=blue",
528            "fill=#123456,fg=214,bg=none,us=colour33,bold,reverse,width=42,pad=0",
529            "list=left-marker,range=left,align=centre,push-default,ignore",
530            "range=pane|%9,list=focus,fg=none,noattr",
531            "range=session|$5,list=on,align=absolute-centre,width=50%",
532            "range=user|custom,list=right-marker,pop-default",
533            "range=control|7,set-default",
534        ];
535
536        for input in cases {
537            let style = parse_style(input);
538            let rendered = style_tostring(&style);
539            let mut round_tripped = Style::default();
540            style_parse(&mut round_tripped, &default_base(), &rendered)
541                .expect("rendered style parses");
542            if style.ignore {
543                let mut expected = style.clone();
544                expected.ignore = false;
545                assert_eq!(round_tripped, expected, "{input} => {rendered}");
546            } else {
547                assert_eq!(round_tripped, style, "{input} => {rendered}");
548            }
549        }
550    }
551
552    #[test]
553    fn style_tostring_emits_default_for_empty_style() {
554        assert_eq!(style_tostring(&Style::default()), "default");
555    }
556
557    #[test]
558    fn style_tostring_omits_ignore_like_tmux() {
559        let style = parse_style("fg=red,ignore");
560        assert_eq!(style_tostring(&style), "fg=red");
561    }
562}