Skip to main content

rmux_core/
formats.rs

1//! tmux-compatible format expansion engine.
2//!
3//! This module implements the core of tmux's `format_expand1` / `format_replace`
4//! pipeline: modifier parsing, nesting-aware delimiter scanning (`format_skip`),
5//! comparisons, boolean operators, multi-pair conditionals, quoting, literal mode,
6//! expand mode, and recursion-limited re-expansion.
7//!
8//! Runtime-only features still deferred here are loops `S`/`W`/`P`/`L`,
9//! expression arithmetic `e`, search `C`, and `#(cmd)` jobs. Time formatting,
10//! regex substitution, `a`/`c`/`w`/`N` modifiers, and tmux single-char aliases
11//! are handled in this core expansion layer.
12
13use crate::style::parse_colour;
14use crate::utf8::Utf8Config;
15#[path = "formats/colour.rs"]
16mod colour;
17#[path = "formats/condition.rs"]
18mod condition;
19#[path = "formats/context.rs"]
20mod context;
21#[path = "formats/expand.rs"]
22mod expand;
23#[path = "formats/expression.rs"]
24mod expression;
25#[path = "formats/glob.rs"]
26mod glob;
27#[path = "formats/modifiers.rs"]
28mod modifiers;
29#[path = "formats/regex_cache.rs"]
30mod regex_cache;
31#[path = "formats/scan.rs"]
32mod scan;
33#[path = "formats/styled_text.rs"]
34mod styled_text;
35#[path = "formats/time.rs"]
36mod time;
37#[path = "formats/transforms.rs"]
38mod transforms;
39
40use condition::{format_bool_op, format_conditional};
41pub use context::{
42    is_known_format_variable_name, FormatContext, FormatVariable, FormatVariables,
43    DEFAULT_DISPLAY_MESSAGE_FORMAT, DEFAULT_LIST_PANES_ALL_FORMAT, DEFAULT_LIST_PANES_FORMAT,
44    DEFAULT_LIST_PANES_SESSION_FORMAT, DEFAULT_LIST_PANES_WINDOW_FORMAT,
45    DEFAULT_LIST_SESSIONS_FORMAT, DEFAULT_LIST_WINDOWS_ALL_FORMAT, DEFAULT_LIST_WINDOWS_FORMAT,
46    FORMAT_VARIABLES, TMUX_FORMAT_TABLE_NAMES, TMUX_TIME_FORMAT_VARIABLE_NAMES,
47};
48use expand::{format_expand1, FORMAT_LOOP_LIMIT};
49use expression::format_expression;
50use glob::format_fnmatch;
51use modifiers::{parse_modifiers, FormatModifier};
52use scan::format_skip;
53pub use scan::format_skip_delimiter;
54pub use styled_text::{styled_text_width, truncate_styled_text_to_width};
55pub use time::expand_time_tokens;
56use time::format_time_string;
57use transforms::{
58    apply_substitution, format_unescape, shell_quote, style_quote, truncate_left, truncate_right,
59};
60
61const FORMAT_REPEAT_BYTES_LIMIT: usize = 5 * 1024 * 1024;
62
63// ---------------------------------------------------------------------------
64// Public entry points
65// ---------------------------------------------------------------------------
66
67/// Renders a format template against supported format variables.
68#[must_use]
69pub fn render_template<V>(template: &str, variables: &V) -> String
70where
71    V: FormatVariables + ?Sized,
72{
73    let mut state = ExpandState {
74        loop_depth: 0,
75        expand_time: false,
76        stop_expansion: false,
77        preserve_jobs: false,
78    };
79    format_expand1(&mut state, template, variables)
80}
81
82/// Renders a format template while preserving `#(...)` command jobs literally.
83///
84/// Runtime renderers that can actually execute jobs use this mode to keep jobs
85/// introduced by expanded option values, such as `#{T:status-left}`, available
86/// for a later execution pass.
87#[must_use]
88pub fn render_template_preserving_jobs<V>(template: &str, variables: &V) -> String
89where
90    V: FormatVariables + ?Sized,
91{
92    let mut state = ExpandState {
93        loop_depth: 0,
94        expand_time: false,
95        stop_expansion: false,
96        preserve_jobs: true,
97    };
98    format_expand1(&mut state, template, variables)
99}
100
101/// Renders a `list-windows` line using the default format when no format is supplied.
102#[must_use]
103pub fn render_list_windows_line<V>(variables: &V, format: Option<&str>) -> String
104where
105    V: FormatVariables + ?Sized,
106{
107    render_template(format.unwrap_or(DEFAULT_LIST_WINDOWS_FORMAT), variables)
108}
109
110/// Renders a `list-sessions` line using the default format when no format is supplied.
111#[must_use]
112pub fn render_list_sessions_line<V>(variables: &V, format: Option<&str>) -> String
113where
114    V: FormatVariables + ?Sized,
115{
116    render_template(format.unwrap_or(DEFAULT_LIST_SESSIONS_FORMAT), variables)
117}
118
119/// Renders a `list-panes` line using the default format when no format is supplied.
120#[must_use]
121pub fn render_list_panes_line<V>(variables: &V, format: Option<&str>) -> String
122where
123    V: FormatVariables + ?Sized,
124{
125    render_template(format.unwrap_or(DEFAULT_LIST_PANES_FORMAT), variables)
126}
127
128/// Returns whether a conditional format value is truthy.
129///
130/// Matches tmux `format_true`: non-empty and not exactly `"0"`.
131#[must_use]
132pub fn is_truthy(value: &str) -> bool {
133    !value.is_empty() && value != "0"
134}
135
136// ---------------------------------------------------------------------------
137// Expansion state
138// ---------------------------------------------------------------------------
139
140struct ExpandState {
141    loop_depth: u32,
142    expand_time: bool,
143    stop_expansion: bool,
144    preserve_jobs: bool,
145}
146
147// ---------------------------------------------------------------------------
148// format_choose — split body into left,right on first comma
149// ---------------------------------------------------------------------------
150
151/// Splits `body` into left and right operands at the first `,` delimiter
152/// (nesting-aware). Both sides are expanded. Returns `None` if no delimiter found.
153fn format_choose<V>(state: &mut ExpandState, body: &str, variables: &V) -> Option<(String, String)>
154where
155    V: FormatVariables + ?Sized,
156{
157    let bytes = body.as_bytes();
158    let pos = format_skip(bytes, b",")?;
159    let left_raw = &body[..pos];
160    let right_raw = &body[pos + 1..];
161    let left = format_expand1(state, left_raw, variables);
162    let right = format_expand1(state, right_raw, variables);
163    Some((left, right))
164}
165
166// ---------------------------------------------------------------------------
167// format_replace — the modifier pipeline dispatcher
168// ---------------------------------------------------------------------------
169
170/// Bitflags for modifier effects.
171const MOD_LITERAL: u32 = 1 << 0;
172const MOD_EXPAND: u32 = 1 << 1;
173const MOD_QUOTE_SHELL: u32 = 1 << 4;
174const MOD_QUOTE_STYLE: u32 = 1 << 5;
175const MOD_BASENAME: u32 = 1 << 6;
176const MOD_DIRNAME: u32 = 1 << 7;
177const MOD_LENGTH: u32 = 1 << 8;
178const MOD_EXPAND_TIME: u32 = 1 << 9;
179const FORMAT_PADDING_LIMIT: usize = 10_000;
180
181/// Processes the content inside `#{...}`, applying modifiers and returning the
182/// expanded result.
183fn format_replace<V>(state: &mut ExpandState, key: &str, variables: &V) -> String
184where
185    V: FormatVariables + ?Sized,
186{
187    let (modifiers, body) = parse_modifiers(state, key, variables);
188
189    // Classify modifiers.
190    let mut flags: u32 = 0;
191    let mut cmp: Option<&FormatModifier> = None;
192    let mut bool_op_n: Option<&FormatModifier> = None;
193    let mut limit: i32 = 0;
194    let mut limit_marker: Option<&str> = None;
195    let mut width: i32 = 0;
196    let mut subs: Vec<&FormatModifier> = Vec::new();
197    let mut time_string = false;
198    let mut time_pretty = false;
199    let mut time_format: Option<String> = None;
200    let mut deferred_loop_scope = None;
201    let mut ascii_char = false;
202    let mut colour_hex = false;
203    let mut display_width = false;
204    let mut name_exists: Option<&FormatModifier> = None;
205    let mut expression: Option<&FormatModifier> = None;
206    let mut search: Option<&FormatModifier> = None;
207    let mut bool_unary: Option<&FormatModifier> = None;
208    let mut repeat = false;
209
210    for fm in &modifiers {
211        if fm.modifier.len() == 1 {
212            match fm.modifier.as_bytes()[0] {
213                b'm' | b'<' | b'>' => cmp = Some(fm),
214                b'a' => ascii_char = true,
215                b'c' => colour_hex = true,
216                b's' if fm.argv.len() >= 2 => {
217                    subs.push(fm);
218                }
219                b'=' => {
220                    if let Some(arg) = fm.argv.first() {
221                        limit = arg.parse::<i32>().unwrap_or(0);
222                        if fm.argv.len() >= 2 {
223                            limit_marker = fm.argv.get(1).map(String::as_str);
224                        }
225                    }
226                }
227                b'p' => {
228                    if let Some(arg) = fm.argv.first() {
229                        width = arg.parse::<i32>().unwrap_or(0);
230                    }
231                }
232                b'l' => flags |= MOD_LITERAL,
233                b'b' => flags |= MOD_BASENAME,
234                b'd' => flags |= MOD_DIRNAME,
235                b'n' => flags |= MOD_LENGTH,
236                b'q' => {
237                    if fm.argv.is_empty() {
238                        flags |= MOD_QUOTE_SHELL;
239                    } else if let Some(arg) = fm.argv.first() {
240                        if arg.contains('e') || arg.contains('h') {
241                            flags |= MOD_QUOTE_STYLE;
242                        }
243                    }
244                }
245                b'E' => flags |= MOD_EXPAND,
246                b'T' => flags |= MOD_EXPAND_TIME,
247                b't' => {
248                    time_string = true;
249                    if let Some(arg) = fm.argv.first() {
250                        if arg.contains('p') {
251                            time_pretty = true;
252                        } else if arg.contains('f') {
253                            time_format = fm.argv.get(1).cloned();
254                        }
255                    }
256                }
257                b'S' | b'W' | b'P' | b'L' => {
258                    deferred_loop_scope = Some(fm.modifier.as_bytes()[0] as char)
259                }
260                b'N' => name_exists = Some(fm),
261                b'e' => expression = Some(fm),
262                b'C' => search = Some(fm),
263                b'w' => display_width = true,
264                b'!' => bool_unary = Some(fm),
265                b'R' => repeat = true,
266                _ => {}
267            }
268        } else if fm.modifier.len() == 2 {
269            match fm.modifier.as_str() {
270                "||" | "&&" => bool_op_n = Some(fm),
271                "!!" => bool_unary = Some(fm),
272                "==" | "!=" | "<=" | ">=" => cmp = Some(fm),
273                _ => {}
274            }
275        }
276    }
277
278    if let Some(scope) = deferred_loop_scope {
279        let (body, current_body) = if scope == 'S' {
280            (body, None)
281        } else {
282            split_loop_body(body)
283        };
284        if let Some(value) = variables.format_loop(scope, body, current_body, false) {
285            return value;
286        }
287    }
288    if let Some(modifier) = name_exists {
289        let scope = match modifier.argv.first().map(String::as_str) {
290            None | Some("") | Some("w") => None,
291            Some("s") => Some('s'),
292            Some(_) => return String::new(),
293        };
294        let operand = format_expand1(state, body, variables);
295        return variables
296            .format_name_exists(scope, &operand)
297            .map(bool_value)
298            .unwrap_or_default();
299    }
300    if let Some(modifier) = search {
301        let options = modifier
302            .argv
303            .first()
304            .map(String::as_str)
305            .unwrap_or_default();
306        let pattern = format_expand1(state, body, variables);
307        return variables
308            .format_search(options, &pattern)
309            .unwrap_or_default();
310    }
311
312    // --- Dispatch with classified modifiers ---
313
314    // Literal.
315    if flags & MOD_LITERAL != 0 {
316        return format_unescape(body);
317    }
318
319    let value;
320
321    if let Some(op) = bool_unary {
322        let truthy = is_truthy(&format_expand1(state, body, variables));
323        value = match op.modifier.as_str() {
324            "!" => bool_value(!truthy),
325            "!!" => bool_value(truthy),
326            _ => String::new(),
327        };
328    } else if repeat {
329        value = format_repeat(state, body, variables);
330    } else if let Some(op) = bool_op_n {
331        // N-ary boolean operator.
332        let is_and = op.modifier == "&&";
333        value = format_bool_op(state, body, is_and, variables);
334    } else if let Some(cmp_mod) = cmp {
335        // Comparison.
336        value = match format_choose(state, body, variables) {
337            Some((left, right)) => {
338                let result = match cmp_mod.modifier.as_str() {
339                    "==" => left == right,
340                    "!=" => left != right,
341                    "<" => left < right,
342                    "<=" => left <= right,
343                    ">" => left > right,
344                    ">=" => left >= right,
345                    "m" => format_fnmatch(&left, &right, cmp_mod),
346                    _ => false,
347                };
348                if result { "1" } else { "0" }.to_owned()
349            }
350            None => String::new(),
351        };
352    } else if let Some(cond_body) = body.strip_prefix('?') {
353        // Multi-pair conditional.
354        value = format_conditional(state, cond_body, variables);
355    } else if let Some(expression) = expression {
356        value = format_expression(state, body, expression, variables);
357    } else {
358        // Variable lookup.
359        if body.contains("#{") {
360            value = format_expand1(state, body, variables);
361        } else {
362            value = resolve_variable(body, variables);
363        }
364    }
365
366    // Post-processing pipeline.
367    let mut result = value;
368
369    if time_string {
370        result =
371            format_time_string(&result, time_pretty, time_format.as_deref()).unwrap_or_default();
372    }
373
374    // Expand modifier (re-expand the resolved value).
375    if flags & MOD_EXPAND != 0 {
376        result = format_expand1(state, &result, variables);
377        result = expand_time_tokens(&result);
378    } else if flags & MOD_EXPAND_TIME != 0 {
379        let previous = state.expand_time;
380        state.expand_time = true;
381        result = format_expand1(state, &result, variables);
382        state.expand_time = previous;
383    }
384
385    // Substitutions.
386    for sub in &subs {
387        if sub.argv.len() >= 2 {
388            result = apply_substitution(&result, sub);
389        }
390    }
391
392    // Truncation.
393    if limit > 0 {
394        let truncated = truncate_left(&result, limit as usize);
395        if truncated != result {
396            if let Some(marker) = limit_marker {
397                result = format!("{truncated}{marker}");
398            } else {
399                result = truncated;
400            }
401        } else {
402            result = truncated;
403        }
404    } else if limit < 0 {
405        let truncated = truncate_right(&result, signed_i32_abs_usize(limit));
406        if truncated != result {
407            if let Some(marker) = limit_marker {
408                result = format!("{marker}{truncated}");
409            } else {
410                result = truncated;
411            }
412        } else {
413            result = truncated;
414        }
415    }
416
417    // Padding.
418    if width > 0 {
419        if let Some(w) = bounded_format_padding_width(width) {
420            let current_width = styled_text_width(&result, &Utf8Config::default());
421            if current_width < w {
422                result.push_str(&" ".repeat(w - current_width));
423            }
424        }
425    } else if width < 0 {
426        if let Some(w) = bounded_format_padding_width(width) {
427            let current_width = styled_text_width(&result, &Utf8Config::default());
428            if current_width < w {
429                result = format!("{}{result}", " ".repeat(w - current_width));
430            }
431        }
432    }
433
434    // Basename.
435    if flags & MOD_BASENAME != 0 {
436        if let Some(pos) = result.rfind('/') {
437            result = result[pos + 1..].to_owned();
438        }
439    }
440
441    // Dirname.
442    if flags & MOD_DIRNAME != 0 {
443        result = format_dirname(&result);
444    }
445
446    // Length.
447    if flags & MOD_LENGTH != 0 {
448        result = result.len().to_string();
449    }
450
451    if display_width {
452        result = format_display_width(&result, body, state, variables);
453    }
454
455    if ascii_char {
456        result = format_ascii_character(&result, body, state, variables);
457    }
458
459    if colour_hex {
460        result = format_colour_hex(&result, body, state, variables);
461    }
462
463    // Quoting.
464    if flags & MOD_QUOTE_SHELL != 0 && !is_single_nested_expansion(body) {
465        result = shell_quote(&result);
466    } else if flags & MOD_QUOTE_STYLE != 0 {
467        result = style_quote(&result);
468    }
469
470    result
471}
472
473fn signed_i32_abs_usize(value: i32) -> usize {
474    value.unsigned_abs() as usize
475}
476
477fn bounded_format_padding_width(value: i32) -> Option<usize> {
478    let width = signed_i32_abs_usize(value);
479    (width <= FORMAT_PADDING_LIMIT).then_some(width)
480}
481
482fn format_repeat<V>(state: &mut ExpandState, body: &str, variables: &V) -> String
483where
484    V: FormatVariables + ?Sized,
485{
486    let Some((unit, count)) = format_choose(state, body, variables) else {
487        state.stop_expansion = true;
488        return String::new();
489    };
490    let Ok(count) = count.parse::<usize>() else {
491        return String::new();
492    };
493    if count == 0 || count > FORMAT_PADDING_LIMIT || unit.is_empty() {
494        return String::new();
495    }
496    let Some(capacity) = unit.len().checked_mul(count) else {
497        return String::new();
498    };
499    if capacity > FORMAT_REPEAT_BYTES_LIMIT {
500        return String::new();
501    }
502    let mut repeated = String::with_capacity(capacity);
503    for _ in 0..count {
504        repeated.push_str(&unit);
505    }
506    repeated
507}
508
509fn split_loop_body(body: &str) -> (&str, Option<&str>) {
510    format_skip(body.as_bytes(), b",")
511        .map(|offset| (&body[..offset], Some(&body[offset + 1..])))
512        .unwrap_or((body, None))
513}
514
515fn is_single_nested_expansion(body: &str) -> bool {
516    body.starts_with("#{") && format_skip(body.as_bytes(), b"}") == Some(body.len() - 1)
517}
518
519fn format_ascii_character<V>(
520    _result: &str,
521    body: &str,
522    state: &mut ExpandState,
523    variables: &V,
524) -> String
525where
526    V: FormatVariables + ?Sized,
527{
528    let operand = format_expand1(state, body, variables);
529    operand
530        .parse::<u32>()
531        .ok()
532        .and_then(|value| u8::try_from(value).ok())
533        .map(char::from)
534        .map(|character| character.to_string())
535        .unwrap_or_default()
536}
537
538fn format_colour_hex<V>(_result: &str, body: &str, state: &mut ExpandState, variables: &V) -> String
539where
540    V: FormatVariables + ?Sized,
541{
542    let operand = format_expand1(state, body, variables);
543    parse_colour(&operand)
544        .ok()
545        .and_then(colour::tmux_colour_to_rgb)
546        .map(|(red, green, blue)| format!("{red:02x}{green:02x}{blue:02x}"))
547        .unwrap_or_default()
548}
549
550fn format_display_width<V>(
551    result: &str,
552    _body: &str,
553    _state: &mut ExpandState,
554    _variables: &V,
555) -> String
556where
557    V: FormatVariables + ?Sized,
558{
559    styled_text_width(result, &Utf8Config::default()).to_string()
560}
561
562fn format_dirname(value: &str) -> String {
563    if value.is_empty() {
564        return String::new();
565    }
566    if value.chars().all(|character| character == '/') {
567        return value.to_owned();
568    }
569
570    let trimmed = value.trim_end_matches('/');
571    match trimmed.rfind('/') {
572        Some(0) => "/".to_owned(),
573        Some(position) => trimmed[..position].to_owned(),
574        None => ".".to_owned(),
575    }
576}
577
578// ---------------------------------------------------------------------------
579// Variable resolution
580// ---------------------------------------------------------------------------
581
582fn resolve_variable<V>(name: &str, variables: &V) -> String
583where
584    V: FormatVariables + ?Sized,
585{
586    variables.format_value_by_name(name).unwrap_or_default()
587}
588
589fn window_raw_flags(active: bool, last: bool) -> &'static str {
590    if active {
591        "*"
592    } else if last {
593        "-"
594    } else {
595        ""
596    }
597}
598
599fn bool_value(value: bool) -> String {
600    if value {
601        "1".to_owned()
602    } else {
603        "0".to_owned()
604    }
605}
606
607#[cfg(test)]
608#[path = "formats/tests.rs"]
609mod tests;