zsh/extensions/terminfo_db.rs
1//! !!! RUST-ORIGINAL — no counterpart in `zsh/Src` !!!
2//!
3//! A pure-Rust reader for the compiled terminfo database, replacing the
4//! `setupterm` / `tigetstr` / `tigetnum` / `tigetflag` / `tgetent` /
5//! `tgetstr` / `tgetnum` / `tgetflag` entry points zshrs used to import from
6//! ncurses (`libtinfo` on Linux, `libncursesw` on macOS).
7//!
8//! Why this exists: those eighteen symbols were the ONLY reason the binary
9//! carried a C terminal library, which made `libtinfo.so.6` a hard install
10//! dependency on Ubuntu and `libtinfo-dev` a build dependency in CI. Nothing
11//! in zshrs needed the ncurses *screen* library — `zsh/curses` is already a
12//! pure-Rust port and imported a single `tigetnum`. Reading the database
13//! directly removes the whole dependency and keeps the build self-hosting on
14//! a machine with no ncurses headers.
15//!
16//! The on-disk format is `term(5)` and has been frozen for decades:
17//!
18//! ```text
19//! header 6 × int16 LE: magic, name-size, bool-count, num-count,
20//! str-offset-count, string-table-size
21//! names name-size bytes, NUL-terminated, `|`-separated aliases
22//! bools bool-count bytes (0 absent, 1 set, 2 cancelled)
23//! pad one byte when (name-size + bool-count) is odd
24//! nums num-count × 2 bytes (magic 0o432) or × 4 bytes (magic 0o1036)
25//! offsets str-offset-count × int16 LE into the string table
26//! strings string-table-size bytes, NUL-terminated entries
27//! ```
28//!
29//! `-1` means absent and `-2` means cancelled, for numbers and offsets alike.
30//! ncurses' extended section (user-defined capabilities such as `Smulx`,
31//! `RGB` and the `XM`/`xm` mouse caps) follows the same shape and is parsed
32//! too, since `tigetstr` resolves extended names.
33//!
34//! What this module does NOT do: it never writes the database, and it does
35//! not read the hashed (Berkeley DB) form some ncurses builds use. Every
36//! platform zshrs targets — Debian/Ubuntu, Fedora, Arch, Alpine, macOS and
37//! the BSDs — ships the directory-tree form, which is what is read here.
38
39use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41use std::sync::{Mutex, OnceLock};
42
43use crate::extensions::terminfo_caps::{
44 BOOL_CODES, BOOL_NAMES, NUM_CODES, NUM_NAMES, STR_CODES, STR_NAMES,
45};
46
47/// `term(5)` magic for an entry whose numeric capabilities are 16-bit.
48const MAGIC_16: u16 = 0o432;
49/// ncurses 6 magic for the 32-bit numeric form, written when any numeric
50/// capability does not fit in an `i16` (`Co` on direct-colour terminals).
51const MAGIC_32: u16 = 0o1036;
52
53/// Value stored for a capability the entry does not define.
54pub const ABSENT: i32 = -1;
55/// Value stored for a capability the entry explicitly cancels (`cap@`).
56pub const CANCELLED: i32 = -2;
57
58/// One compiled terminfo entry, held as the positional arrays the file
59/// stores plus ncurses' extended-capability maps.
60#[derive(Debug, Default, Clone)]
61pub struct TermEntry {
62 /// `|`-separated names from the header, first is the primary name.
63 pub names: Vec<String>,
64 /// Booleans, indexed by position in [`BOOL_NAMES`].
65 pub bools: Vec<i8>,
66 /// Numbers, indexed by position in [`NUM_NAMES`]; [`ABSENT`]/[`CANCELLED`].
67 pub nums: Vec<i32>,
68 /// Strings, indexed by position in [`STR_NAMES`]. `None` is absent.
69 pub strs: Vec<Option<Vec<u8>>>,
70 /// ncurses extended booleans, by capability name.
71 pub ext_bools: HashMap<String, bool>,
72 /// ncurses extended numbers, by capability name.
73 pub ext_nums: HashMap<String, i32>,
74 /// ncurses extended strings, by capability name.
75 pub ext_strs: HashMap<String, Vec<u8>>,
76 /// ncurses' `FIX_SGR0`: the termcap-facing form of `sgr0`, computed by
77 /// `tgetent` and substituted for it by `tgetstr`. `None` until `tgetent`
78 /// runs, or when the trimmed form equals `sgr0` itself.
79 pub fix_sgr0: Option<Vec<u8>>,
80}
81
82/// Why `setupterm` could not produce an entry. The discriminants are
83/// ncurses' `errret` values, which `zsh/terminfo`'s port already branches on.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum SetupError {
86 /// errret 0 — `$TERM` names an entry that is not in the database.
87 NotFound,
88 /// errret -1 — no database could be located at all.
89 NoDatabase,
90 /// errret 0 with an empty/unset `$TERM`.
91 Unset,
92}
93
94impl SetupError {
95 /// ncurses' `errret` code, so callers can reproduce its exact returns.
96 pub fn errret(self) -> i32 {
97 match self {
98 SetupError::NoDatabase => -1,
99 SetupError::NotFound | SetupError::Unset => 0,
100 }
101 }
102}
103
104fn read_i16(b: &[u8], at: usize) -> Option<i16> {
105 let hi = *b.get(at + 1)?;
106 let lo = *b.get(at)?;
107 Some(i16::from_le_bytes([lo, hi]))
108}
109
110fn read_i32(b: &[u8], at: usize) -> Option<i32> {
111 let s: [u8; 4] = b.get(at..at + 4)?.try_into().ok()?;
112 Some(i32::from_le_bytes(s))
113}
114
115/// Widen a stored numeric capability. Both widths sign-extend, so `-1`
116/// (absent) and `-2` (cancelled) survive the 16-bit form unchanged.
117fn num_at(b: &[u8], at: usize, wide: bool) -> Option<i32> {
118 if wide {
119 read_i32(b, at)
120 } else {
121 read_i16(b, at).map(i32::from)
122 }
123}
124
125/// NUL-terminated slice of the string table starting at `off`.
126fn cstr_at(table: &[u8], off: usize) -> Option<Vec<u8>> {
127 let rest = table.get(off..)?;
128 let end = rest.iter().position(|&c| c == 0).unwrap_or(rest.len());
129 Some(rest[..end].to_vec())
130}
131
132impl TermEntry {
133 /// Parse one compiled entry. Returns `None` when the bytes are not a
134 /// terminfo file or are truncated — a corrupt entry is treated the same
135 /// as a missing one, exactly as ncurses does.
136 pub fn parse(b: &[u8]) -> Option<TermEntry> {
137 let magic = read_i16(b, 0)? as u16;
138 let wide = match magic {
139 MAGIC_16 => false,
140 MAGIC_32 => true,
141 _ => return None,
142 };
143 let name_size = read_i16(b, 2)?.max(0) as usize;
144 let bool_count = read_i16(b, 4)?.max(0) as usize;
145 let num_count = read_i16(b, 6)?.max(0) as usize;
146 let str_count = read_i16(b, 8)?.max(0) as usize;
147 let table_size = read_i16(b, 10)?.max(0) as usize;
148 let num_width = if wide { 4 } else { 2 };
149
150 let mut p = 12;
151 let names_raw = b.get(p..p + name_size)?;
152 let names = String::from_utf8_lossy(names_raw)
153 .trim_end_matches('\0')
154 .split('|')
155 .map(|s| s.to_string())
156 .collect();
157 p += name_size;
158
159 let bools: Vec<i8> = b.get(p..p + bool_count)?.iter().map(|&v| v as i8).collect();
160 p += bool_count;
161
162 // term(5): numbers start on an even boundary relative to the file.
163 if p % 2 != 0 {
164 p += 1;
165 }
166
167 let mut nums = Vec::with_capacity(num_count);
168 for i in 0..num_count {
169 nums.push(num_at(b, p + i * num_width, wide)?);
170 }
171 p += num_count * num_width;
172
173 let mut offsets = Vec::with_capacity(str_count);
174 for i in 0..str_count {
175 offsets.push(read_i16(b, p + i * 2)?);
176 }
177 p += str_count * 2;
178
179 let table = b.get(p..p + table_size)?;
180 p += table_size;
181
182 let strs = offsets
183 .iter()
184 .map(|&o| {
185 if o < 0 {
186 None
187 } else {
188 cstr_at(table, o as usize)
189 }
190 })
191 .collect();
192
193 let mut e = TermEntry {
194 names,
195 bools,
196 nums,
197 strs,
198 ..Default::default()
199 };
200 // The extended section is optional; a truncated or absent one just
201 // leaves the maps empty rather than failing the whole entry.
202 e.parse_extended(b, p, wide);
203 Some(e)
204 }
205
206 /// ncurses' extended (user-definable) capability block, which follows the
207 /// standard section after realignment. Layout mirrors the main one: a
208 /// 5-short header, then bools, nums, and a single offset array holding
209 /// the extended string VALUES followed by the NAMES of all three kinds.
210 fn parse_extended(&mut self, b: &[u8], mut p: usize, wide: bool) {
211 if p % 2 != 0 {
212 p += 1;
213 }
214 let Some(ext_bool) = read_i16(b, p).map(|v| v.max(0) as usize) else {
215 return;
216 };
217 let Some(ext_num) = read_i16(b, p + 2).map(|v| v.max(0) as usize) else {
218 return;
219 };
220 let Some(ext_str) = read_i16(b, p + 4).map(|v| v.max(0) as usize) else {
221 return;
222 };
223 let Some(ext_off) = read_i16(b, p + 6).map(|v| v.max(0) as usize) else {
224 return;
225 };
226 let Some(ext_size) = read_i16(b, p + 8).map(|v| v.max(0) as usize) else {
227 return;
228 };
229 p += 10;
230 let num_width = if wide { 4 } else { 2 };
231
232 let Some(bools) = b.get(p..p + ext_bool) else {
233 return;
234 };
235 let bools: Vec<i8> = bools.iter().map(|&v| v as i8).collect();
236 p += ext_bool;
237 if p % 2 != 0 {
238 p += 1;
239 }
240
241 let mut nums = Vec::with_capacity(ext_num);
242 for i in 0..ext_num {
243 match num_at(b, p + i * num_width, wide) {
244 Some(v) => nums.push(v),
245 None => return,
246 }
247 }
248 p += ext_num * num_width;
249
250 let mut offsets = Vec::with_capacity(ext_off);
251 for i in 0..ext_off {
252 match read_i16(b, p + i * 2) {
253 Some(v) => offsets.push(v),
254 None => return,
255 }
256 }
257 p += ext_off * 2;
258
259 let Some(table) = b.get(p..p + ext_size) else {
260 return;
261 };
262
263 // The first `ext_str` offsets are values; everything after is the
264 // name list, ordered bools, then numbers, then strings.
265 let values: Vec<Option<Vec<u8>>> = offsets
266 .iter()
267 .take(ext_str)
268 .map(|&o| {
269 if o < 0 {
270 None
271 } else {
272 cstr_at(table, o as usize)
273 }
274 })
275 .collect();
276 let names: Vec<String> = offsets
277 .iter()
278 .skip(ext_str)
279 .map(|&o| {
280 if o < 0 {
281 String::new()
282 } else {
283 cstr_at(table, o as usize)
284 .map(|v| String::from_utf8_lossy(&v).into_owned())
285 .unwrap_or_default()
286 }
287 })
288 .collect();
289
290 let mut it = names.into_iter();
291 for i in 0..ext_bool {
292 if let Some(n) = it.next() {
293 if !n.is_empty() {
294 self.ext_bools.insert(n, bools.get(i).copied().unwrap_or(0) == 1);
295 }
296 }
297 }
298 for i in 0..ext_num {
299 if let Some(n) = it.next() {
300 if !n.is_empty() {
301 self.ext_nums.insert(n, nums.get(i).copied().unwrap_or(ABSENT));
302 }
303 }
304 }
305 for i in 0..ext_str {
306 if let Some(n) = it.next() {
307 if n.is_empty() {
308 continue;
309 }
310 if let Some(Some(v)) = values.get(i) {
311 self.ext_strs.insert(n, v.clone());
312 }
313 }
314 }
315 }
316
317 /// Boolean capability by terminfo long name (`am`, `xenl`, …), falling
318 /// back to the extended map. ncurses returns `-1` for a name that is not
319 /// a boolean at all; that is the caller's `tigetflag` contract.
320 pub fn flag(&self, name: &str) -> i32 {
321 if let Some(i) = BOOL_NAMES.iter().position(|&n| n == name) {
322 return i32::from(self.bools.get(i).copied().unwrap_or(0) == 1);
323 }
324 if let Some(&v) = self.ext_bools.get(name) {
325 return i32::from(v);
326 }
327 // `tigetflag(3)`: "-1 if capname is not a boolean capability". That
328 // covers an UNKNOWN name as well as a known one of another kind —
329 // returning 0 here made every two-letter termcap code answer "no"
330 // when the `zsh/terminfo` scan probed it, so `$terminfo` grew ~220
331 // phantom entries (`S1`, `S2`, `YA`-`YG`, `ta`, `te`, `xn`, …) on
332 // terminals whose codes are not also capability names.
333 -1
334 }
335
336 /// Numeric capability by terminfo long name.
337 ///
338 /// `tigetnum(3)`: "-1 if `capname` is not a numeric capability, or -2 if
339 /// it is canceled or absent". A CANCELLED value stored in the entry
340 /// (`ncv@`, `colors@`) is therefore reported as [`ABSENT`] — only an
341 /// unknown-or-wrong-kind NAME yields [`CANCELLED`]. Returning the raw
342 /// -2 here made `$terminfo[ncv]` read -2 where zsh reads -1 on 45 of the
343 /// 394 entries in the reference database.
344 pub fn num(&self, name: &str) -> i32 {
345 if let Some(i) = NUM_NAMES.iter().position(|&n| n == name) {
346 let v = self.nums.get(i).copied().unwrap_or(ABSENT);
347 return if v == CANCELLED { ABSENT } else { v };
348 }
349 if let Some(&v) = self.ext_nums.get(name) {
350 return v;
351 }
352 // `tigetnum(3)`: "-2 if capname is not a numeric capability" — an
353 // unknown name included. [`ABSENT`] is reserved for a numeric
354 // capability the entry does not define.
355 CANCELLED
356 }
357
358 /// String capability by terminfo long name. `None` is absent; the
359 /// wrong-type case is reported separately by [`TermEntry::str_is_wrong_type`]
360 /// so callers can reproduce ncurses' `(char *)-1`.
361 pub fn string(&self, name: &str) -> Option<&[u8]> {
362 if let Some(i) = STR_NAMES.iter().position(|&n| n == name) {
363 return self.strs.get(i).and_then(|o| o.as_deref());
364 }
365 self.ext_strs.get(name).map(|v| v.as_slice())
366 }
367
368 /// Write (or clear) a string capability by terminfo long name, growing
369 /// the positional array when the compiled entry was shorter than the slot.
370 /// Only `tgetent`'s termcap fixups mutate a loaded entry.
371 pub fn set_string(&mut self, name: &str, value: Option<Vec<u8>>) {
372 let Some(i) = STR_NAMES.iter().position(|&n| n == name) else {
373 return;
374 };
375 if self.strs.len() <= i {
376 self.strs.resize(i + 1, None);
377 }
378 self.strs[i] = value;
379 }
380
381 /// True when `name` is not a string capability at all, which is what
382 /// ncurses signals with a `(char *)-1` return from `tigetstr` — for a
383 /// capability of another kind and for an unknown name alike.
384 pub fn str_is_wrong_type(&self, name: &str) -> bool {
385 !STR_NAMES.contains(&name) && !self.ext_strs.contains_key(name)
386 }
387
388 /// Boolean capability by two-letter TERMCAP code (`am`, `xn`, …).
389 pub fn tc_flag(&self, code: &str) -> i32 {
390 match BOOL_CODES.iter().position(|&c| c == code) {
391 Some(i) => i32::from(self.bools.get(i).copied().unwrap_or(0) == 1),
392 None => 0,
393 }
394 }
395
396 /// Numeric capability by two-letter TERMCAP code (`li`, `co`, `Co`, …).
397 /// `tgetnum` reports a missing capability as `-1`.
398 pub fn tc_num(&self, code: &str) -> i32 {
399 match NUM_CODES.iter().position(|&c| c == code) {
400 // Cancelled reads as absent here too — `tgetnum` has only the
401 // one failure value.
402 Some(i) => self.nums.get(i).copied().unwrap_or(ABSENT).max(ABSENT),
403 None => ABSENT,
404 }
405 }
406
407 /// String capability by two-letter TERMCAP code (`cl`, `ce`, `up`, …).
408 ///
409 /// `rposition`, not `position`: `ML` is carried by two slots (`smgl` at
410 /// 271 and `smglr` at 368) and ncurses' `_nc_find_type_entry` resolves it
411 /// to the later one, so a `position` lookup answered with `smgl` — absent
412 /// on xterm — and dropped `ML` from `$termcap` entirely.
413 pub fn tc_string(&self, code: &str) -> Option<&[u8]> {
414 let i = STR_CODES.iter().rposition(|&c| c == code)?;
415 // lib_termcap.c `tgetstr`: "if (result == exit_attribute_mode &&
416 // FIX_SGR0 != 0) result = FIX_SGR0" — the termcap view of `sgr0` is
417 // the trimmed one.
418 if STR_NAMES.get(i) == Some(&"sgr0") {
419 if let Some(fix) = self.fix_sgr0.as_deref() {
420 return Some(fix);
421 }
422 }
423 self.strs.get(i).and_then(|o| o.as_deref())
424 }
425}
426
427/// The entry `setupterm` / `tgetent` last loaded — ncurses' `cur_term`.
428fn cur_term() -> &'static Mutex<Option<TermEntry>> {
429 static CUR: OnceLock<Mutex<Option<TermEntry>>> = OnceLock::new();
430 CUR.get_or_init(|| Mutex::new(None))
431}
432
433/// Run `f` against the loaded entry, or return `default` when none is loaded.
434pub fn with_cur_term<T>(default: T, f: impl FnOnce(&TermEntry) -> T) -> T {
435 match cur_term().lock() {
436 Ok(g) => match g.as_ref() {
437 Some(e) => f(e),
438 None => default,
439 },
440 Err(_) => default,
441 }
442}
443
444/// True once an entry has been loaded — ncurses' `cur_term != 0` test.
445pub fn have_cur_term() -> bool {
446 cur_term().lock().map(|g| g.is_some()).unwrap_or(false)
447}
448
449/// The directories to search, in ncurses' documented order: `$TERMINFO`,
450/// `~/.terminfo`, then each entry of `$TERMINFO_DIRS` (an empty entry meaning
451/// the compiled-in default), then the built-in list.
452fn search_dirs() -> Vec<PathBuf> {
453 let mut dirs: Vec<PathBuf> = Vec::new();
454 let mut push = |p: PathBuf| {
455 if !p.as_os_str().is_empty() && !dirs.contains(&p) {
456 dirs.push(p);
457 }
458 };
459 // Built-in defaults, shared by the `$TERMINFO_DIRS` empty-entry rule.
460 // These stand in for the path ncurses was COMPILED with, so the order
461 // has to name whichever database the rest of the system reads.
462 //
463 // On Linux that is `/usr/share/terminfo` (ncurses-base), first.
464 //
465 // On macOS the system copy under `/usr/share/terminfo` is many years
466 // old — its `xterm-256color` has no `rep`, `nel`, `oc`, `mgc`,
467 // `smglr`, `ka1`… and reports `pairs` as 32767 rather than 65536 —
468 // while anything linked against Homebrew's ncurses reads
469 // `<prefix>/opt/ncurses/share/terminfo`. Reference zsh on such a host
470 // is one of those things, so the Homebrew trees are offered FIRST
471 // there: with the system copy first, `${(k)terminfo}` came back 12
472 // capabilities short of the zsh running beside it.
473 let defaults = || {
474 let brew = [
475 "/opt/homebrew/opt/ncurses/share/terminfo",
476 "/usr/local/opt/ncurses/share/terminfo",
477 ];
478 let system = [
479 "/usr/share/terminfo",
480 "/lib/terminfo",
481 "/etc/terminfo",
482 "/usr/lib/terminfo",
483 "/usr/share/lib/terminfo",
484 "/usr/local/share/terminfo",
485 ];
486 let ordered: Vec<&str> = if cfg!(target_os = "macos") {
487 brew.into_iter().chain(system).collect()
488 } else {
489 system.into_iter().chain(brew).collect()
490 };
491 ordered.into_iter().map(PathBuf::from)
492 };
493 if let Ok(v) = std::env::var("TERMINFO") {
494 push(PathBuf::from(v));
495 }
496 if let Ok(h) = std::env::var("HOME") {
497 push(PathBuf::from(h).join(".terminfo"));
498 }
499 if let Ok(v) = std::env::var("TERMINFO_DIRS") {
500 for part in v.split(':') {
501 if part.is_empty() {
502 for d in defaults() {
503 push(d);
504 }
505 } else {
506 push(PathBuf::from(part));
507 }
508 }
509 }
510 for d in defaults() {
511 push(d);
512 }
513 dirs
514}
515
516/// Candidate files for `name` under `dir`. Two layouts are in use: the
517/// letter-directory form (`<dir>/x/xterm`, Linux and the BSDs) and the
518/// hex-directory form (`<dir>/78/xterm`, macOS). Both are tried.
519fn candidates(dir: &Path, name: &str) -> Vec<PathBuf> {
520 let Some(first) = name.chars().next() else {
521 return Vec::new();
522 };
523 let mut v = vec![dir.join(first.to_string()).join(name)];
524 let mut buf = [0u8; 4];
525 let hex = format!("{:02x}", first.encode_utf8(&mut buf).as_bytes()[0]);
526 v.push(dir.join(hex).join(name));
527 v
528}
529
530/// Locate and parse the entry for `name`, without touching `cur_term`.
531pub fn load_entry(name: &str) -> Result<TermEntry, SetupError> {
532 if name.is_empty() || name.contains('/') {
533 return Err(SetupError::Unset);
534 }
535 let dirs = search_dirs();
536 let mut saw_dir = false;
537 for dir in &dirs {
538 if !dir.is_dir() {
539 continue;
540 }
541 saw_dir = true;
542 for cand in candidates(dir, name) {
543 let Ok(bytes) = std::fs::read(&cand) else {
544 continue;
545 };
546 if let Some(e) = TermEntry::parse(&bytes) {
547 return Ok(e);
548 }
549 }
550 }
551 Err(if saw_dir {
552 SetupError::NotFound
553 } else {
554 SetupError::NoDatabase
555 })
556}
557
558/// ncurses `setupterm(term, fd, errret)` — load `term` (or `$TERM` when
559/// `None`) and install it as the current entry.
560pub fn setupterm(term: Option<&str>) -> Result<(), SetupError> {
561 let name = match term {
562 Some(t) => t.to_string(),
563 None => std::env::var("TERM").unwrap_or_default(),
564 };
565 if name.is_empty() {
566 return Err(SetupError::Unset);
567 }
568 // ncurses keeps ONE `cur_term`; calling `setupterm` again for the terminal
569 // that is already loaded does not discard it. That matters because
570 // `tgetent` mutates the loaded entry (see `apply_termcap_fixups`) and
571 // `init_term` runs it long before `zmodload zsh/terminfo` calls
572 // `setupterm`: reloading from disk here threw the fixups away, so
573 // `$terminfo[OTbs]` reported the entry's stored value where zsh reports
574 // the `cub1 == "\b"` answer ncurses computed.
575 if let Ok(g) = cur_term().lock() {
576 if let Some(cur) = g.as_ref() {
577 if cur.names.iter().any(|n| *n == name) {
578 return Ok(());
579 }
580 }
581 }
582 let mut e = load_entry(&name)?;
583 apply_screensize(&mut e);
584 // The termcap fixups belong to `tgetent` in ncurses, but every zsh
585 // process reaches them: `init_term` (Src/init.c) calls `tgetent` during
586 // startup, so by the time anything reads `$terminfo` the entry has been
587 // through them. zshrs's `init_term` does not run for a non-interactive
588 // `-c` script, which left `$terminfo[OTbs]` reporting the entry's stored
589 // value there while zsh reported ncurses' computed one. Applying them on
590 // load keeps the two agreeing on every path; they are idempotent and
591 // derived only from the entry itself.
592 apply_termcap_fixups(&mut e);
593 if let Ok(mut g) = cur_term().lock() {
594 *g = Some(e);
595 }
596 // The pad parameters `crate::shout::tputs` caches are all derived from
597 // this entry, so a new one invalidates them.
598 crate::shout::invalidate_pad_info();
599 Ok(())
600}
601
602/// ncurses `_nc_get_screensize`, which `setupterm` runs before publishing
603/// `cur_term`: the `lines` / `cols` capabilities are overridden by the real
604/// terminal size, then by `$LINES` / `$COLUMNS`, and finally defaulted to
605/// 24x80. Without it `$terminfo[lines]` reports whatever the entry happens
606/// to carry — and 72 of the reference database's 394 entries carry nothing
607/// at all, where ncurses answers 24 and 80.
608fn apply_screensize(e: &mut TermEntry) {
609 let idx = |n: &str| NUM_NAMES.iter().position(|&x| x == n);
610 let (Some(li), Some(co)) = (idx("lines"), idx("cols")) else {
611 return;
612 };
613 if e.nums.len() <= li.max(co) {
614 e.nums.resize(li.max(co) + 1, ABSENT);
615 }
616 let mut lines = e.nums[li];
617 let mut cols = e.nums[co];
618
619 // TIOCGWINSZ on whichever of the three standard descriptors is a tty.
620 // A zero field means "unknown" and must not clobber the capability.
621 for fd in [libc::STDOUT_FILENO, libc::STDERR_FILENO, libc::STDIN_FILENO] {
622 let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
623 if unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) } == 0 {
624 if ws.ws_row > 0 {
625 lines = i32::from(ws.ws_row);
626 }
627 if ws.ws_col > 0 {
628 cols = i32::from(ws.ws_col);
629 }
630 break;
631 }
632 }
633 // `use_env(TRUE)` is ncurses' default, so the environment wins last.
634 if let Ok(v) = std::env::var("LINES") {
635 if let Ok(n) = v.parse::<i32>() {
636 if n > 0 {
637 lines = n;
638 }
639 }
640 }
641 if let Ok(v) = std::env::var("COLUMNS") {
642 if let Ok(n) = v.parse::<i32>() {
643 if n > 0 {
644 cols = n;
645 }
646 }
647 }
648 e.nums[li] = if lines > 0 { lines } else { 24 };
649 e.nums[co] = if cols > 0 { cols } else { 80 };
650}
651
652/// ncurses `tigetflag` — `1` set, `0` absent, `-1` not a boolean capability.
653pub fn tigetflag(name: &str) -> i32 {
654 with_cur_term(0, |e| e.flag(name))
655}
656
657/// ncurses `tigetnum` — the value, `-1` absent, `-2` not a numeric capability.
658pub fn tigetnum(name: &str) -> i32 {
659 with_cur_term(ABSENT, |e| e.num(name))
660}
661
662/// ncurses `tigetstr` — `Ok(Some(v))` set, `Ok(None)` absent, `Err(())` for
663/// the wrong-type case ncurses reports as `(char *)-1`.
664pub fn tigetstr(name: &str) -> Result<Option<Vec<u8>>, ()> {
665 let wrong = with_cur_term(false, |e| e.str_is_wrong_type(name));
666 if wrong {
667 return Err(());
668 }
669 Ok(with_cur_term(None, |e| e.string(name).map(|s| s.to_vec())))
670}
671
672/// ncurses `tgetent(NULL, name)` — `1` on success, `0` when the entry is not
673/// in the database, `-1` when no database was found. Installs `cur_term` the
674/// same way `setupterm` does, since the termcap layer reads the same entry.
675///
676/// `tgetent` is not merely `setupterm` with a different return convention:
677/// lib_termcap.c MUTATES the loaded entry with two termcap-era fixups, and
678/// both are observable through `$termcap` and `$terminfo` afterwards. zsh
679/// runs `tgetent` from `init_term`, so every zsh process sees the mutated
680/// entry — which is why `$terminfo[OTbs]` differs between a shell that has
681/// called `tgetent` and one that has only called `setupterm`.
682pub fn tgetent(name: &str) -> i32 {
683 if let Err(e) = setupterm(Some(name)) {
684 return e.errret();
685 }
686 1
687}
688
689/// The two entry mutations lib_termcap.c's `tgetent` performs.
690fn apply_termcap_fixups(e: &mut TermEntry) {
691 // `if (cursor_left) backspaces_with_bs = !strcmp(cursor_left, "\b")`.
692 // `backspaces_with_bs` IS the `OTbs` boolean, so the entry's stored value
693 // is replaced by whether `cub1` is a literal backspace. Measured: `ansi`
694 // ships OTbs set but `cub1=\E[D`, so ncurses reports it unset; `Eterm`
695 // ships it unset but `cub1=^H`, so ncurses reports it set.
696 if let Some(cub1) = e.string("cub1").map(|s| s.to_vec()) {
697 let is_bs = cub1 == b"\x08";
698 if let Some(i) = BOOL_NAMES.iter().position(|&n| n == "OTbs") {
699 if e.bools.len() <= i {
700 e.bools.resize(i + 1, 0);
701 }
702 e.bools[i] = i8::from(is_bs);
703 }
704 // The other half of the same `if` — `backspace_if_not_bs = cursor_left`
705 // — is NOT reproduced: it assigns ncurses' `BC` global, not the
706 // entry, so `$terminfo[OTbc]` stays unset in the reference shell.
707 let _ = cub1;
708 }
709 // `FIX_SGR0 = _nc_trim_sgr0(...)`, cleared again when it matches `sgr0`.
710 let trimmed = trim_sgr0(e);
711 if let Some(t) = trimmed {
712 if e.string("sgr0") != Some(t.as_slice()) {
713 e.fix_sgr0 = Some(t);
714 }
715 }
716}
717
718/// ncurses' `_nc_trim_sgr0`: the termcap-facing "turn every attribute off"
719/// string. It starts from `sgr` evaluated with all-zero parameters when the
720/// entry has one (else from `sgr0`) and removes the parts termcap's `me` must
721/// not carry — the alternate-character-set selection (`\E(B`, `\E(0`, and the
722/// SO/SI bytes `\016`/`\017`), the `10` "primary font" SGR parameter, and any
723/// padding spec.
724///
725/// Measured against ncurses: `xterm` `\E(B\E[m` → `\E[0m`, `ansi`
726/// `\E[0;10m` → `\E[0m`, `vt100` `\E[m\017$<2>` → `\E[0m`.
727fn trim_sgr0(e: &TermEntry) -> Option<Vec<u8>> {
728 let base = match e.string("sgr") {
729 Some(sgr) => crate::tparm::tparm(sgr, &[0; 9]),
730 None => e.string("sgr0")?.to_vec(),
731 };
732 let base = crate::tparm::tputs_strip_padding(&base);
733 let mut out: Vec<u8> = Vec::with_capacity(base.len());
734 let mut i = 0usize;
735 while i < base.len() {
736 // `\E(` + one designator selects a character set — never part of `me`.
737 if base[i] == 0x1b && base.get(i + 1) == Some(&b'(') && i + 2 < base.len() {
738 i += 3;
739 continue;
740 }
741 // Shift-out / shift-in.
742 if base[i] == 0x0e || base[i] == 0x0f {
743 i += 1;
744 continue;
745 }
746 out.push(base[i]);
747 i += 1;
748 }
749 // Drop a `10` parameter from the SGR sequence (`\E[0;10m` → `\E[0m`).
750 let cleaned = drop_sgr_font_param(&out);
751 if cleaned.is_empty() {
752 None
753 } else {
754 Some(cleaned)
755 }
756}
757
758/// Remove the `10` (primary font) parameter from every `CSI … m` sequence.
759fn drop_sgr_font_param(s: &[u8]) -> Vec<u8> {
760 let mut out = Vec::with_capacity(s.len());
761 let mut i = 0usize;
762 while i < s.len() {
763 if s[i] == 0x1b && s.get(i + 1) == Some(&b'[') {
764 // Find the terminating `m`; anything else is left alone.
765 let mut j = i + 2;
766 while j < s.len() && (s[j].is_ascii_digit() || s[j] == b';') {
767 j += 1;
768 }
769 if s.get(j) == Some(&b'm') {
770 let params: Vec<&[u8]> = s[i + 2..j].split(|&c| c == b';').collect();
771 let kept: Vec<&[u8]> = params.into_iter().filter(|p| *p != b"10").collect();
772 out.extend_from_slice(b"\x1b[");
773 for (k, p) in kept.iter().enumerate() {
774 if k > 0 {
775 out.push(b';');
776 }
777 out.extend_from_slice(p);
778 }
779 out.push(b'm');
780 i = j + 1;
781 continue;
782 }
783 }
784 out.push(s[i]);
785 i += 1;
786 }
787 out
788}
789
790/// ncurses `tgetflag(code)` for a two-letter termcap code.
791pub fn tgetflag(code: &str) -> i32 {
792 with_cur_term(0, |e| e.tc_flag(code))
793}
794
795/// ncurses `tgetnum(code)` for a two-letter termcap code.
796pub fn tgetnum(code: &str) -> i32 {
797 with_cur_term(ABSENT, |e| e.tc_num(code))
798}
799
800/// ncurses `tgetstr(code, area)` for a two-letter termcap code. The `area`
801/// out-parameter of the C call is not reproduced: callers copy the returned
802/// bytes, which is all the zsh ports ever did with it.
803pub fn tgetstr(code: &str) -> Option<Vec<u8>> {
804 with_cur_term(None, |e| e.tc_string(code).map(|s| s.to_vec()))
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 /// A hand-built entry exercising both numeric widths and the absent /
812 /// cancelled encodings, so the parser is checked without a database.
813 fn synth(wide: bool) -> Vec<u8> {
814 let names = b"zz-test|synthetic\0";
815 let bools: [i8; 3] = [1, 0, 1];
816 let nums: [i32; 2] = [80, -1];
817 let table = b"\x1b[H\0\x1b[J\0";
818 let offsets: [i16; 3] = [0, 4, -1];
819
820 let mut v = Vec::new();
821 let magic: u16 = if wide { MAGIC_32 } else { MAGIC_16 };
822 for x in [
823 magic as i16,
824 names.len() as i16,
825 bools.len() as i16,
826 nums.len() as i16,
827 offsets.len() as i16,
828 table.len() as i16,
829 ] {
830 v.extend_from_slice(&x.to_le_bytes());
831 }
832 v.extend_from_slice(names);
833 v.extend(bools.iter().map(|&b| b as u8));
834 if v.len() % 2 != 0 {
835 v.push(0);
836 }
837 for n in nums {
838 if wide {
839 v.extend_from_slice(&n.to_le_bytes());
840 } else {
841 v.extend_from_slice(&(n as i16).to_le_bytes());
842 }
843 }
844 for o in offsets {
845 v.extend_from_slice(&o.to_le_bytes());
846 }
847 v.extend_from_slice(table);
848 v
849 }
850
851 #[test]
852 fn parses_both_numeric_widths_identically() {
853 for wide in [false, true] {
854 let e = TermEntry::parse(&synth(wide)).expect("parses");
855 assert_eq!(e.names[0], "zz-test");
856 assert_eq!(e.names[1], "synthetic");
857 assert_eq!(e.bools, vec![1, 0, 1]);
858 assert_eq!(e.nums, vec![80, ABSENT]);
859 assert_eq!(e.strs[0].as_deref(), Some(&b"\x1b[H"[..]));
860 assert_eq!(e.strs[1].as_deref(), Some(&b"\x1b[J"[..]));
861 assert_eq!(e.strs[2], None, "offset -1 is absent, not empty");
862 }
863 }
864
865 #[test]
866 fn rejects_non_terminfo_bytes() {
867 assert!(TermEntry::parse(b"not a terminfo file at all").is_none());
868 assert!(TermEntry::parse(&[]).is_none());
869 // Right magic, truncated body — must not panic or half-parse.
870 let mut t = synth(false);
871 t.truncate(20);
872 assert!(TermEntry::parse(&t).is_none());
873 }
874
875 /// The three cap tables are positional indexes into the file format, so a
876 /// length change silently misreads every entry past the changed slot.
877 #[test]
878 fn cap_tables_have_the_frozen_ncurses6_lengths() {
879 assert_eq!(BOOL_NAMES.len(), 44);
880 assert_eq!(BOOL_CODES.len(), 44);
881 assert_eq!(NUM_NAMES.len(), 39);
882 assert_eq!(NUM_CODES.len(), 39);
883 assert_eq!(STR_NAMES.len(), 414);
884 assert_eq!(STR_CODES.len(), 414);
885 }
886
887 /// Spot-check the anchors the zsh ports look up by name and by code, so a
888 /// regenerated table that shifted a row is caught immediately.
889 #[test]
890 fn well_known_capabilities_sit_at_their_documented_indexes() {
891 assert_eq!(BOOL_NAMES[0], "bw");
892 assert_eq!(BOOL_NAMES.iter().position(|&n| n == "am"), Some(1));
893 assert_eq!(BOOL_CODES[1], "am");
894 assert_eq!(NUM_NAMES.iter().position(|&n| n == "cols"), Some(0));
895 assert_eq!(NUM_CODES[0], "co");
896 assert_eq!(NUM_NAMES.iter().position(|&n| n == "lines"), Some(2));
897 assert_eq!(NUM_CODES[2], "li");
898 assert_eq!(STR_NAMES.iter().position(|&n| n == "cup"), Some(10));
899 assert_eq!(STR_CODES[10], "cm");
900 }
901}