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}