Skip to main content

zsh/extensions/
tparm.rs

1//! !!! RUST-ORIGINAL — no counterpart in `zsh/Src` !!!
2//!
3//! The terminfo parameterized-string evaluator (`tparm`), the termcap cursor
4//! addresser (`tgoto`) and the padding writer (`tputs`) — the last three
5//! ncurses entry points zshrs imported, alongside the database reader in
6//! [`crate::terminfo_db`].
7//!
8//! `tparm` runs the stack language specified in `terminfo(5)` under "Parameterized
9//! Strings". A capability such as `cup` is stored as
10//! `\E[%i%p1%d;%p2%dH`, and evaluating it with `(row, col)` pushes the two
11//! parameters, increments them for one-based terminals (`%i`) and prints them.
12//!
13//! Directives implemented, all of them from that specification:
14//!
15//! ```text
16//!   %%              literal %
17//!   %d %o %x %X %s  pop and print (printf-style width/precision honoured)
18//!   %c              pop and emit as one byte
19//!   %pN             push parameter N (1-based)
20//!   %'c'            push the character literal c
21//!   %{n}            push the decimal constant n
22//!   %l              pop a string, push its length
23//!   %PA %gA         set / get static variable A-Z, dynamic a-z
24//!   %+ %- %* %/ %m  pop two, push arithmetic result
25//!   %& %| %^        pop two, push bitwise result
26//!   %= %< %>        pop two, push comparison result
27//!   %A %O           pop two, push logical result
28//!   %! %~           pop one, push logical / bitwise negation
29//!   %i              increment the first two parameters
30//!   %? %t %e %;     conditional
31//! ```
32//!
33//! Division and modulo by zero yield `0` rather than trapping, matching
34//! ncurses — a terminfo entry is untrusted input read off disk, and a
35//! malformed one must not take the shell down.
36//!
37//! Two ncurses behaviours are reproduced because real entries depend on them,
38//! and both are visible in `ncurses/tinfo/lib_tparm.c`:
39//!
40//!   * **The termcap hack** (`tparm_tc_compat`). A capability that never says
41//!     `%p` is a pre-terminfo string whose conversions consume parameters
42//!     positionally. ncurses pushes the parameters onto the stack in REVERSE
43//!     (`for (i = num_parsed - 1; i >= 0; i--) npush(param[i])`) so that
44//!     popping yields p1, p2, … in order, and it pushes at most two.
45//!     `%i` then writes the incremented values back into stack slots 0 and 1
46//!     — the BOTTOM of the stack, which after the reverse push holds p2 and
47//!     p1 — which is why `%i%d.%d` prints p2+1 before p1+1 while a plain
48//!     `%d.%d` prints p1 before p2. `u6` (`\E[%i%d;%dR`) is the common
49//!     consumer.
50//!   * **Unknown directives are dropped.** `lib_tparm.c`'s switch ends in
51//!     `default: break;`, so `%z` and the `%\E` that several vendor `is2`
52//!     strings carry emit nothing at all rather than the literal character.
53
54use std::sync::{Mutex, OnceLock};
55
56/// A `tparm` parameter, and the value type on its evaluation stack.
57///
58/// terminfo is nominally integer-only, but `%s` and `%l` need strings: the
59/// `pfkey`/`pfloc`/`pfx`/`pln`/`pfxl` capabilities take a string argument,
60/// which is why `echoti pfkey 1 foo` has to reach the evaluator intact.
61#[derive(Debug, Clone)]
62pub enum V {
63    /// A numeric parameter.
64    Int(i64),
65    /// A string parameter, consumed by `%s` and measured by `%l`.
66    Str(String),
67}
68
69impl V {
70    fn int(&self) -> i64 {
71        match self {
72            V::Int(i) => *i,
73            V::Str(s) => s.parse().unwrap_or(0),
74        }
75    }
76    fn string(&self) -> String {
77        match self {
78            V::Int(i) => i.to_string(),
79            V::Str(s) => s.clone(),
80        }
81    }
82}
83
84/// `%PA`-`%PZ` static variables persist across `tparm` calls, per
85/// `terminfo(5)`: "the static variables survive between calls".
86fn statics() -> &'static Mutex<[i64; 26]> {
87    static S: OnceLock<Mutex<[i64; 26]>> = OnceLock::new();
88    S.get_or_init(|| Mutex::new([0; 26]))
89}
90
91/// Format an integer the way `printf` would for the given terminfo directive,
92/// honouring an optional `%[[:]flags][width[.precision]]` run.
93fn fmt_int(spec: &str, conv: char, v: i64) -> String {
94    let spec = spec.strip_prefix(':').unwrap_or(spec);
95    let mut left = false;
96    let mut zero = false;
97    let mut plus = false;
98    let mut space = false;
99    let mut alt = false;
100    let mut rest = spec;
101    while let Some(c) = rest.chars().next() {
102        match c {
103            '-' => left = true,
104            '0' => zero = true,
105            '+' => plus = true,
106            ' ' => space = true,
107            '#' => alt = true,
108            _ => break,
109        }
110        rest = &rest[c.len_utf8()..];
111    }
112    let (w, p) = match rest.split_once('.') {
113        Some((a, b)) => (a.parse::<usize>().ok(), b.parse::<usize>().ok()),
114        None => (rest.parse::<usize>().ok(), None),
115    };
116
117    // ncurses formats through `int`, not a 64-bit type: `save_number` writes
118    // with a plain `%d`/`%x` and an `int` argument (lib_tparm.c). `%x` of -1
119    // is therefore `ffffffff`, not sixteen f's — `acsc` on the Data General
120    // entries is the capability that shows the difference.
121    let n32 = v as i32;
122    let mut body = match conv {
123        'o' => format!("{:o}", n32 as u32),
124        'x' => format!("{:x}", n32 as u32),
125        'X' => format!("{:X}", n32 as u32),
126        _ => (n32 as i64).abs().to_string(),
127    };
128    // printf precision on an integer conversion is a MINIMUM DIGIT COUNT and
129    // applies to every base, not just `%d`. `hpa=\036FP%p1%2.2XFF` needs the
130    // `%2.2X` to render 0 as `00`.
131    if let Some(p) = p {
132        while body.len() < p {
133            body.insert(0, '0');
134        }
135    }
136    if conv == 'd' {
137        let sign = if v < 0 {
138            "-"
139        } else if plus {
140            "+"
141        } else if space {
142            " "
143        } else {
144            ""
145        };
146        body = format!("{sign}{body}");
147    } else if alt && v != 0 {
148        let prefix = match conv {
149            'o' => "0",
150            'x' => "0x",
151            'X' => "0X",
152            _ => "",
153        };
154        body = format!("{prefix}{body}");
155    }
156
157    let Some(w) = w else { return body };
158    if body.len() >= w {
159        return body;
160    }
161    let pad = w - body.len();
162    if left {
163        format!("{body}{}", " ".repeat(pad))
164    } else if zero && conv == 'd' && (v < 0 || plus || space) {
165        // Zero padding goes AFTER the sign, not before it.
166        let (sign, digits) = body.split_at(1);
167        format!("{sign}{}{digits}", "0".repeat(pad))
168    } else if zero {
169        format!("{}{body}", "0".repeat(pad))
170    } else {
171        format!("{}{body}", " ".repeat(pad))
172    }
173}
174
175/// Evaluate a terminfo capability string with the given parameters.
176///
177/// Byte-oriented in and out: terminfo strings carry raw escape bytes that are
178/// not necessarily valid UTF-8, and `%c` can emit any byte at all.
179pub fn tparm(cap: &[u8], params: &[i64]) -> Vec<u8> {
180    let p: Vec<V> = params.iter().map(|&i| V::Int(i)).collect();
181    tparm_params(cap, &p)
182}
183
184/// [`tparm`] with parameters that may be strings as well as integers.
185pub fn tparm_params(cap: &[u8], params: &[V]) -> Vec<u8> {
186    let mut p: Vec<V> = params.to_vec();
187    let mut out: Vec<u8> = Vec::with_capacity(cap.len() + 8);
188    let mut stack: Vec<V> = Vec::new();
189    let mut dynamic = [0i64; 26];
190
191    // `tparm_tc_compat`: a string with no `%p` is termcap-style, and its
192    // parameters are pre-pushed in reverse so the first pop yields p1.
193    // ncurses pushes `num_parsed` of them, which for this path is the
194    // tgoto-like pair at most — a third conversion underflows to 0.
195    let termcap_hack = !cap.windows(2).any(|w| w == b"%p");
196    if termcap_hack {
197        let n = count_conversions(cap).min(2);
198        for i in (0..n).rev() {
199            stack.push(p.get(i).cloned().unwrap_or(V::Int(0)));
200        }
201    }
202    // `%i` applies once per evaluation (`incremented_two`).
203    let mut incremented_two = false;
204
205    let pop = |s: &mut Vec<V>| -> V { s.pop().unwrap_or(V::Int(0)) };
206    let pop2 = |s: &mut Vec<V>| -> (i64, i64) {
207        let b = s.pop().unwrap_or(V::Int(0)).int();
208        let a = s.pop().unwrap_or(V::Int(0)).int();
209        (a, b)
210    };
211
212    let mut i = 0usize;
213    while i < cap.len() {
214        if cap[i] != b'%' {
215            out.push(cap[i]);
216            i += 1;
217            continue;
218        }
219        i += 1;
220        let Some(&c) = cap.get(i) else { break };
221
222        // A printf-style flag/width run may precede the conversion char.
223        if c == b':' || c.is_ascii_digit() || c == b'-' || c == b'+' || c == b' ' || c == b'#' || c == b'.' {
224            let start = i;
225            let mut j = i;
226            if cap.get(j) == Some(&b':') {
227                j += 1;
228            }
229            while let Some(&d) = cap.get(j) {
230                if d.is_ascii_digit() || d == b'.' || d == b'-' || d == b'+' || d == b' ' || d == b'#' {
231                    j += 1;
232                } else {
233                    break;
234                }
235            }
236            if let Some(&conv) = cap.get(j) {
237                if matches!(conv, b'd' | b'o' | b'x' | b'X' | b's') {
238                    let spec = String::from_utf8_lossy(&cap[start..j]).into_owned();
239                    let v = pop(&mut stack);
240                    let s = if conv == b's' {
241                        v.string()
242                    } else {
243                        fmt_int(&spec, conv as char, v.int())
244                    };
245                    out.extend_from_slice(s.as_bytes());
246                    i = j + 1;
247                    continue;
248                }
249            }
250            // Not a formatted conversion after all. The run scanner treats
251            // `-`, `+`, ` ` and `#` as printf flags, but those same bytes are
252            // the arithmetic and logical operators when no conversion char
253            // follows — `%-` in `setaf` is a subtraction, not a left-justify.
254            // Fall through to the single-character directive match below.
255        }
256
257        i += 1;
258        match c {
259            b'%' => out.push(b'%'),
260            b'd' | b'o' | b'x' | b'X' => {
261                let v = pop(&mut stack).int();
262                out.extend_from_slice(fmt_int("", c as char, v).as_bytes());
263            }
264            b's' => {
265                let v = pop(&mut stack).string();
266                out.extend_from_slice(v.as_bytes());
267            }
268            b'c' => {
269                // ncurses `save_char` (lib_tparm.c): "if (c == 0) c = 0200".
270                // The test is on the popped VALUE, not on its low byte, so a
271                // value like 256 still writes a NUL — and since ncurses hands
272                // back a C string, that NUL ends the result. `truncate_at_nul`
273                // below reproduces the truncation.
274                let v = pop(&mut stack).int();
275                out.push(if v == 0 { 0o200 } else { (v & 0xff) as u8 });
276            }
277            b'p' => {
278                if let Some(&n) = cap.get(i) {
279                    i += 1;
280                    let idx = (n as char).to_digit(10).unwrap_or(0) as usize;
281                    if idx >= 1 {
282                        stack.push(p.get(idx - 1).cloned().unwrap_or(V::Int(0)));
283                    }
284                }
285            }
286            b'\'' => {
287                // `%'c'` — a character literal.
288                if let Some(&ch) = cap.get(i) {
289                    stack.push(V::Int(ch as i64));
290                    i += 1;
291                    if cap.get(i) == Some(&b'\'') {
292                        i += 1;
293                    }
294                }
295            }
296            b'{' => {
297                let start = i;
298                while i < cap.len() && cap[i] != b'}' {
299                    i += 1;
300                }
301                let n = String::from_utf8_lossy(&cap[start..i]);
302                stack.push(V::Int(n.parse().unwrap_or(0)));
303                if i < cap.len() {
304                    i += 1; // consume `}`
305                }
306            }
307            b'l' => {
308                let v = pop(&mut stack).string();
309                stack.push(V::Int(v.len() as i64));
310            }
311            b'P' => {
312                if let Some(&n) = cap.get(i) {
313                    i += 1;
314                    let v = pop(&mut stack).int();
315                    if n.is_ascii_uppercase() {
316                        if let Ok(mut g) = statics().lock() {
317                            g[(n - b'A') as usize] = v;
318                        }
319                    } else if n.is_ascii_lowercase() {
320                        dynamic[(n - b'a') as usize] = v;
321                    }
322                }
323            }
324            b'g' => {
325                if let Some(&n) = cap.get(i) {
326                    i += 1;
327                    let v = if n.is_ascii_uppercase() {
328                        statics().lock().map(|g| g[(n - b'A') as usize]).unwrap_or(0)
329                    } else if n.is_ascii_lowercase() {
330                        dynamic[(n - b'a') as usize]
331                    } else {
332                        0
333                    };
334                    stack.push(V::Int(v));
335                }
336            }
337            b'+' | b'-' | b'*' | b'/' | b'm' | b'&' | b'|' | b'^' | b'=' | b'<' | b'>' | b'A'
338            | b'O' => {
339                let (a, b) = pop2(&mut stack);
340                let r = match c {
341                    b'+' => a.wrapping_add(b),
342                    b'-' => a.wrapping_sub(b),
343                    b'*' => a.wrapping_mul(b),
344                    // ncurses yields 0 rather than trapping; an entry read
345                    // off disk must never abort the shell.
346                    b'/' => {
347                        if b == 0 {
348                            0
349                        } else {
350                            a.wrapping_div(b)
351                        }
352                    }
353                    b'm' => {
354                        if b == 0 {
355                            0
356                        } else {
357                            a.wrapping_rem(b)
358                        }
359                    }
360                    b'&' => a & b,
361                    b'|' => a | b,
362                    b'^' => a ^ b,
363                    b'=' => i64::from(a == b),
364                    b'<' => i64::from(a < b),
365                    b'>' => i64::from(a > b),
366                    b'A' => i64::from(a != 0 && b != 0),
367                    _ => i64::from(a != 0 || b != 0),
368                };
369                stack.push(V::Int(r));
370            }
371            b'!' => {
372                let a = pop(&mut stack).int();
373                stack.push(V::Int(i64::from(a == 0)));
374            }
375            b'~' => {
376                let a = pop(&mut stack).int();
377                stack.push(V::Int(!a));
378            }
379            b'i' => {
380                // `%i` increments the first two parameters in place, and
381                // under the termcap hack ALSO assigns them into stack slots
382                // 0 and 1 — the bottom of the stack, per lib_tparm.c.
383                if !incremented_two {
384                    incremented_two = true;
385                    for slot in p.iter_mut().take(2) {
386                        *slot = V::Int(slot.int() + 1);
387                    }
388                    if termcap_hack {
389                        for k in 0..2 {
390                            if let (Some(v), Some(slot)) = (p.get(k), stack.get_mut(k)) {
391                                *slot = V::Int(v.int());
392                            }
393                        }
394                    }
395                }
396            }
397            b'?' => { /* `if` — evaluation is driven by `%t` below. */ }
398            b't' => {
399                let cond = pop(&mut stack).int() != 0;
400                if !cond {
401                    i = skip_to_else_or_end(cap, i);
402                }
403            }
404            b'e' => {
405                // Reached only by falling out of a taken `%t` branch; skip
406                // the else-part up to the matching `%;`.
407                i = skip_to_end(cap, i);
408            }
409            b';' => { /* end of conditional */ }
410            // lib_tparm.c `default: break;` — an unrecognized directive
411            // emits nothing, and neither does the character after `%`.
412            _ => {}
413        }
414    }
415    // ncurses returns `char *`, so everything past an embedded NUL is
416    // unreachable to every caller. `cup=\002%i%p1%c%p2%c` on a 256-column
417    // terminal is the case that produces one.
418    if let Some(z) = out.iter().position(|&b| b == 0) {
419        out.truncate(z);
420    }
421    out
422}
423
424/// Number of value-consuming conversions in a capability, used only to size
425/// the termcap-hack pre-push. Counts the same directives ncurses' analysis
426/// does: `%d %o %x %X %s %c`, with or without a printf-style flag/width run.
427fn count_conversions(cap: &[u8]) -> usize {
428    let mut n = 0usize;
429    let mut i = 0usize;
430    while i + 1 < cap.len() {
431        if cap[i] != b'%' {
432            i += 1;
433            continue;
434        }
435        let mut j = i + 1;
436        if cap.get(j) == Some(&b':') {
437            j += 1;
438        }
439        while let Some(&d) = cap.get(j) {
440            if d.is_ascii_digit() || d == b'.' || d == b'-' || d == b'+' || d == b' ' || d == b'#' {
441                j += 1;
442            } else {
443                break;
444            }
445        }
446        match cap.get(j) {
447            Some(&c) if matches!(c, b'd' | b'o' | b'x' | b'X' | b's' | b'c') => {
448                n += 1;
449                i = j + 1;
450            }
451            // No conversion: step over `%` and ONE character only. Skipping to
452            // `j + 1` swallowed the `%` of whatever followed, so the second
453            // `%c` of `u6=\037%c%'A'%-%c%'A'%-` went uncounted and the
454            // termcap pre-push was one parameter short.
455            _ => i += 2,
456        }
457    }
458    n
459}
460
461/// Advance past a not-taken `%t` branch, stopping AFTER the matching `%e` or
462/// `%;`. Nested `%?` blocks are counted so an inner conditional does not
463/// terminate the outer one.
464fn skip_to_else_or_end(cap: &[u8], mut i: usize) -> usize {
465    let mut depth = 0usize;
466    while i < cap.len() {
467        if cap[i] != b'%' {
468            i += 1;
469            continue;
470        }
471        let Some(&c) = cap.get(i + 1) else { break };
472        match c {
473            b'?' => {
474                depth += 1;
475                i += 2;
476            }
477            b';' => {
478                i += 2;
479                if depth == 0 {
480                    return i;
481                }
482                depth -= 1;
483            }
484            b'e' if depth == 0 => return i + 2,
485            _ => i += 2,
486        }
487    }
488    i
489}
490
491/// Advance past a taken branch's else-part, stopping after the matching `%;`.
492fn skip_to_end(cap: &[u8], mut i: usize) -> usize {
493    let mut depth = 0usize;
494    while i < cap.len() {
495        if cap[i] != b'%' {
496            i += 1;
497            continue;
498        }
499        let Some(&c) = cap.get(i + 1) else { break };
500        match c {
501            b'?' => {
502                depth += 1;
503                i += 2;
504            }
505            b';' => {
506                i += 2;
507                if depth == 0 {
508                    return i;
509                }
510                depth -= 1;
511            }
512            _ => i += 2,
513        }
514    }
515    i
516}
517
518/// ncurses `tgoto(cap, col, row)` — the TERMCAP cursor addresser.
519///
520/// Termcap uses a different, older encoding than terminfo. When the string
521/// contains terminfo `%p` directives (which is what a terminfo-backed
522/// `tgetstr` hands back on a modern system) evaluation is delegated to
523/// [`tparm`] with the arguments in terminfo order (row, col).
524pub fn tgoto(cap: &[u8], col: i64, row: i64) -> Vec<u8> {
525    if cap.windows(2).any(|w| w == b"%p") {
526        return tparm(cap, &[row, col]);
527    }
528    // Classic termcap: `%d`/`%2`/`%3`/`%.` consume the next argument, which
529    // starts as the row and switches to the column, and `%r` swaps them.
530    let mut args = [row, col];
531    let mut next = 0usize;
532    let mut out = Vec::with_capacity(cap.len() + 8);
533    let mut i = 0usize;
534    while i < cap.len() {
535        if cap[i] != b'%' {
536            out.push(cap[i]);
537            i += 1;
538            continue;
539        }
540        i += 1;
541        let Some(&c) = cap.get(i) else { break };
542        i += 1;
543        let mut take = || {
544            let v = args.get(next).copied().unwrap_or(0);
545            next += 1;
546            v
547        };
548        match c {
549            b'%' => out.push(b'%'),
550            b'd' => out.extend_from_slice(take().to_string().as_bytes()),
551            b'2' => out.extend_from_slice(format!("{:02}", take() % 100).as_bytes()),
552            b'3' => out.extend_from_slice(format!("{:03}", take() % 1000).as_bytes()),
553            b'.' => out.push((take() & 0xff) as u8),
554            b'+' => {
555                let add = cap.get(i).copied().unwrap_or(0) as i64;
556                i += 1;
557                out.push(((take() + add) & 0xff) as u8);
558            }
559            b'>' => {
560                // `%>xy` — conditional add: if value > x, add y.
561                let x = cap.get(i).copied().unwrap_or(0) as i64;
562                let y = cap.get(i + 1).copied().unwrap_or(0) as i64;
563                i += 2;
564                if args.get(next).copied().unwrap_or(0) > x {
565                    if let Some(slot) = args.get_mut(next) {
566                        *slot += y;
567                    }
568                }
569            }
570            b'r' => args.swap(0, 1),
571            b'i' => {
572                args[0] += 1;
573                args[1] += 1;
574            }
575            b'n' => {
576                args[0] ^= 0o140;
577                args[1] ^= 0o140;
578            }
579            b'B' => {
580                if let Some(slot) = args.get_mut(next) {
581                    *slot = (*slot / 10) * 16 + *slot % 10;
582                }
583            }
584            b'D' => {
585                if let Some(slot) = args.get_mut(next) {
586                    *slot -= 2 * (*slot % 16);
587                }
588            }
589            other => out.push(other),
590        }
591    }
592    out
593}
594
595/// The terminal properties `tputs` needs in order to decide whether a delay
596/// spec turns into pad bytes. All of them come from the entry loaded by
597/// [`crate::terminfo_db`], plus the tty's output speed.
598#[derive(Debug, Clone, Copy)]
599pub struct PadInfo {
600    /// `ospeed` from `tcgetattr`, in bits per second. `0` when the output is
601    /// not a tty, which is also ncurses' "emit nothing" case.
602    pub baud: i32,
603    /// `xon` (`xon_xoff`) — the terminal does its own flow control, so
604    /// timing pads are pointless.
605    pub xon: bool,
606    /// `pb` (`padding_baud_rate`) — below this speed padding is unnecessary.
607    pub padding_baud_rate: i32,
608    /// `npc` (`no_pad_char`) — pad with real time rather than with bytes.
609    pub no_pad_char: bool,
610    /// `pad` (`pad_char`), the byte to send; NUL when the entry has none.
611    pub pad_char: u8,
612}
613
614impl Default for PadInfo {
615    /// The "not a tty" case: no speed, so no pad bytes, which is exactly what
616    /// ncurses emits when `ospeed` is 0.
617    fn default() -> Self {
618        PadInfo {
619            baud: 0,
620            xon: false,
621            padding_baud_rate: 0,
622            no_pad_char: false,
623            pad_char: 0,
624        }
625    }
626}
627
628/// ncurses' `BAUDBYTE`: 9 bits per character — 7 data, 1 parity, 1 stop.
629const BAUDBYTE: i64 = 9;
630
631/// Expand a capability's `$<…>` delay specs, the transformation half of
632/// ncurses' `tputs(str, affcnt, putc)`.
633///
634/// The spec is `$<` digits [`.` digit] [`*`] [`/`] `>`, where `*` multiplies
635/// the delay by `affcnt` (the number of lines affected) and `/` makes the
636/// pad mandatory even when the terminal has `xon`. ncurses accumulates the
637/// delay in TENTHS of a millisecond (`number *= 10` after the integer part,
638/// then one decimal digit is added) and passes `number / 10` to
639/// `delay_output`, which emits
640///
641/// ```text
642///   nullcount = (ms * baudrate) / (BAUDBYTE * 1000)
643/// ```
644///
645/// copies of the pad character. Padding is emitted only when the delay is
646/// positive AND the pad is mandatory or `normal_delay` holds — ncurses'
647/// `!xon_xoff && padding_baud_rate && ospeed >= padding_baud_rate`. So an
648/// entry with no `pb`, or output that is not a tty, produces no bytes at
649/// all and the spec is simply removed. `npc` asks for a real sleep instead
650/// of pad bytes; zshrs drops the delay rather than stalling the shell, which
651/// is the one deliberate divergence here.
652pub fn tputs(cap: &[u8], affcnt: i32, info: &PadInfo) -> Vec<u8> {
653    let mut out = Vec::with_capacity(cap.len());
654    let mut i = 0usize;
655    while i < cap.len() {
656        if cap[i] != b'$' {
657            out.push(cap[i]);
658            i += 1;
659            continue;
660        }
661        if cap.get(i + 1) != Some(&b'<') {
662            out.push(b'$');
663            i += 1;
664            continue;
665        }
666        // A `$<` that is not followed by a digit or `.`, or that never
667        // closes, is literal text — ncurses emits both characters.
668        let rest = &cap[i + 2..];
669        let starts_ok = matches!(rest.first(), Some(c) if c.is_ascii_digit() || *c == b'.');
670        if !starts_ok || !rest.contains(&b'>') {
671            out.push(b'$');
672            out.push(b'<');
673            i += 2;
674            continue;
675        }
676        let mut j = i + 2;
677        let mut number: i64 = 0;
678        while let Some(&c) = cap.get(j) {
679            if !c.is_ascii_digit() {
680                break;
681            }
682            number = number * 10 + i64::from(c - b'0');
683            j += 1;
684        }
685        number *= 10; // tenths of a millisecond
686        if cap.get(j) == Some(&b'.') {
687            j += 1;
688            if let Some(&c) = cap.get(j) {
689                if c.is_ascii_digit() {
690                    number += i64::from(c - b'0');
691                    j += 1;
692                }
693            }
694            while matches!(cap.get(j), Some(c) if c.is_ascii_digit()) {
695                j += 1;
696            }
697        }
698        let mut mandatory = false;
699        while let Some(&c) = cap.get(j) {
700            match c {
701                b'*' => {
702                    number *= i64::from(affcnt);
703                    j += 1;
704                }
705                b'/' => {
706                    mandatory = true;
707                    j += 1;
708                }
709                _ => break,
710            }
711        }
712        if cap.get(j) == Some(&b'>') {
713            j += 1;
714        }
715        let normal_delay =
716            !info.xon && info.padding_baud_rate > 0 && info.baud >= info.padding_baud_rate;
717        if number > 0 && (normal_delay || mandatory) && !info.no_pad_char {
718            let ms = number / 10;
719            let nullcount = (ms * i64::from(info.baud)) / (BAUDBYTE * 1000);
720            for _ in 0..nullcount {
721                out.push(info.pad_char);
722            }
723        }
724        i = j;
725    }
726    out
727}
728
729/// [`tputs`] for a caller with no terminal information: the delay specs are
730/// removed and no pad bytes are produced, which is what ncurses does
731/// whenever `ospeed` is 0 (any non-tty destination).
732pub fn tputs_strip_padding(s: &[u8]) -> Vec<u8> {
733    tputs(s, 1, &PadInfo::default())
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    fn t(cap: &str, params: &[i64]) -> String {
741        String::from_utf8_lossy(&tparm(cap.as_bytes(), params)).into_owned()
742    }
743
744    /// `cup` as xterm stores it — the single most-evaluated capability in the
745    /// shell, and the one that exercises `%i` plus two `%p%d` prints.
746    #[test]
747    fn cup_addresses_the_cursor_one_based() {
748        assert_eq!(t("\x1b[%i%p1%d;%p2%dH", &[0, 0]), "\x1b[1;1H");
749        assert_eq!(t("\x1b[%i%p1%d;%p2%dH", &[23, 79]), "\x1b[24;80H");
750    }
751
752    /// `setaf` on xterm-256color: a three-way conditional with arithmetic,
753    /// which covers `%?`/`%t`/`%e`/`%;`, `%{n}`, `%<` and `%-`.
754    #[test]
755    fn setaf_takes_each_branch_of_the_nested_conditional() {
756        let cap = "\x1b[%?%p1%{8}%<%t3%p1%d%e%p1%{16}%<%t9%p1%{8}%-%d%e38;5;%p1%d%;m";
757        assert_eq!(t(cap, &[1]), "\x1b[31m", "first branch: colours 0-7");
758        assert_eq!(t(cap, &[9]), "\x1b[91m", "second branch: bright 8-15");
759        assert_eq!(t(cap, &[200]), "\x1b[38;5;200m", "else branch: 256-colour");
760    }
761
762    #[test]
763    fn arithmetic_comparison_and_logic_directives() {
764        assert_eq!(t("%{6}%{7}%*%d", &[]), "42");
765        assert_eq!(t("%{10}%{3}%m%d", &[]), "1");
766        assert_eq!(t("%{12}%{10}%&%d", &[]), "8");
767        assert_eq!(t("%{1}%{0}%O%d", &[]), "1");
768        assert_eq!(t("%{1}%{0}%A%d", &[]), "0");
769        assert_eq!(t("%{0}%!%d", &[]), "1");
770        assert_eq!(t("%p1%p2%=%d", &[5, 5]), "1");
771    }
772
773    /// A malformed entry must degrade, never panic — these strings are read
774    /// off disk from a file zshrs does not control.
775    #[test]
776    fn division_by_zero_and_truncated_directives_do_not_panic() {
777        assert_eq!(t("%{5}%{0}%/%d", &[]), "0");
778        assert_eq!(t("%{5}%{0}%m%d", &[]), "0");
779        assert_eq!(t("%p", &[1]), "");
780        assert_eq!(t("%{123", &[]), "");
781        assert_eq!(t("%", &[]), "");
782        assert_eq!(t("%?%p1%t", &[1]), "");
783    }
784
785    /// Static variables survive between calls (terminfo(5)); dynamic ones
786    /// must not, or two unrelated capabilities would see each other's state.
787    #[test]
788    fn static_variables_persist_and_dynamic_ones_do_not() {
789        assert_eq!(t("%{42}%PZ", &[]), "");
790        assert_eq!(t("%gZ%d", &[]), "42");
791        assert_eq!(t("%{7}%Pz", &[]), "");
792        assert_eq!(t("%gz%d", &[]), "0", "dynamic vars reset every call");
793    }
794
795    #[test]
796    fn width_and_precision_are_honoured() {
797        assert_eq!(t("%p1%5d", &[42]), "   42");
798        assert_eq!(t("%p1%-5d|", &[42]), "42   |");
799        assert_eq!(t("%p1%05d", &[42]), "00042");
800        assert_eq!(t("%p1%x", &[255]), "ff");
801        assert_eq!(t("%p1%X", &[255]), "FF");
802        assert_eq!(t("%p1%o", &[8]), "10");
803    }
804
805    #[test]
806    fn char_literals_and_c_conversion() {
807        assert_eq!(t("%'A'%c", &[]), "A");
808        assert_eq!(t("%{65}%c", &[]), "A");
809        assert_eq!(t("%p1%c", &[66]), "B");
810    }
811
812    /// Classic termcap `cm`, which uses `%.`/`%+` rather than `%p`.
813    #[test]
814    fn tgoto_handles_the_termcap_encoding() {
815        let g = |c: &str, col: i64, row: i64| String::from_utf8_lossy(&tgoto(c.as_bytes(), col, row)).into_owned();
816        assert_eq!(g("\x1b[%i%d;%dH", 0, 0), "\x1b[1;1H");
817        assert_eq!(g("\x1b=%+ %+ ", 0, 0), "\x1b= \x20");
818        // A terminfo-style string routed through tgoto still evaluates, with
819        // the arguments in terminfo (row, col) order.
820        assert_eq!(g("\x1b[%i%p1%d;%p2%dH", 5, 9), "\x1b[10;6H");
821    }
822
823    /// Measured against ncurses `tputs` on a non-tty (`ospeed` 0): every
824    /// well-formed delay spec disappears and nothing is emitted in its place.
825    #[test]
826    fn padding_specs_are_stripped_but_literal_text_is_kept() {
827        assert_eq!(tputs_strip_padding(b"\x1b[H$<20>"), b"\x1b[H".to_vec());
828        assert_eq!(tputs_strip_padding(b"a$<5*/>b"), b"ab".to_vec());
829        assert_eq!(tputs_strip_padding(b"\x1b[1m$<2>"), b"\x1b[1m".to_vec());
830        assert_eq!(tputs_strip_padding(b"x$<5>y"), b"xy".to_vec());
831        // `$<` that cannot be a delay is literal text and must survive.
832        assert_eq!(
833            tputs_strip_padding(b"cost $<x> here"),
834            b"cost $<x> here".to_vec()
835        );
836        assert_eq!(tputs_strip_padding(b"unterminated $<20"), b"unterminated $<20".to_vec());
837        assert_eq!(tputs_strip_padding(b"no padding"), b"no padding".to_vec());
838        assert_eq!(tputs_strip_padding(b"a$b"), b"a$b".to_vec());
839    }
840
841    /// The pad-byte arithmetic: `nullcount = ms * baud / (9 * 1000)`, gated
842    /// on `normal_delay` (`!xon && pb && ospeed >= pb`) or a `/`-mandatory
843    /// spec.
844    #[test]
845    fn pad_bytes_follow_the_baud_rate_formula() {
846        let info = PadInfo {
847            baud: 38400,
848            xon: false,
849            padding_baud_rate: 1200,
850            no_pad_char: false,
851            pad_char: 0,
852        };
853        // 2ms at 38400 => 2 * 38400 / 9000 = 8 pad characters.
854        assert_eq!(tputs(b"\x1b[1m$<2>", 1, &info), b"\x1b[1m\0\0\0\0\0\0\0\0".to_vec());
855        // `*` multiplies by affcnt.
856        assert_eq!(tputs(b"$<1*>", 4, &info).len(), 4 * 38400 / 9000);
857        // A non-NUL pad character is honoured.
858        let dot = PadInfo { pad_char: b'.', ..info };
859        assert_eq!(tputs(b"$<2>", 1, &dot), b"........".to_vec());
860    }
861
862    /// Every gate that suppresses padding, each of which leaves the spec
863    /// removed but emits nothing.
864    #[test]
865    fn padding_is_suppressed_by_xon_missing_pb_zero_baud_and_npc() {
866        let base = PadInfo {
867            baud: 38400,
868            xon: false,
869            padding_baud_rate: 1200,
870            no_pad_char: false,
871            pad_char: 0,
872        };
873        let cases = [
874            ("xon", PadInfo { xon: true, ..base }),
875            ("no pb", PadInfo { padding_baud_rate: 0, ..base }),
876            ("baud below pb", PadInfo { baud: 300, ..base }),
877            ("not a tty", PadInfo { baud: 0, ..base }),
878            ("npc", PadInfo { no_pad_char: true, ..base }),
879        ];
880        for (why, info) in cases {
881            assert_eq!(tputs(b"A$<2>B", 1, &info), b"AB".to_vec(), "{why}");
882        }
883        // …except a `/`-mandatory pad, which beats xon.
884        let xon = PadInfo { xon: true, ..base };
885        assert_eq!(tputs(b"A$<2/>B", 1, &xon).len(), 2 + 2 * 38400 / 9000);
886    }
887}