Skip to main content

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}