use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use crate::extensions::terminfo_caps::{
BOOL_CODES, BOOL_NAMES, NUM_CODES, NUM_NAMES, STR_CODES, STR_NAMES,
};
const MAGIC_16: u16 = 0o432;
const MAGIC_32: u16 = 0o1036;
pub const ABSENT: i32 = -1;
pub const CANCELLED: i32 = -2;
#[derive(Debug, Default, Clone)]
pub struct TermEntry {
pub names: Vec<String>,
pub bools: Vec<i8>,
pub nums: Vec<i32>,
pub strs: Vec<Option<Vec<u8>>>,
pub ext_bools: HashMap<String, bool>,
pub ext_nums: HashMap<String, i32>,
pub ext_strs: HashMap<String, Vec<u8>>,
pub fix_sgr0: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupError {
NotFound,
NoDatabase,
Unset,
}
impl SetupError {
pub fn errret(self) -> i32 {
match self {
SetupError::NoDatabase => -1,
SetupError::NotFound | SetupError::Unset => 0,
}
}
}
fn read_i16(b: &[u8], at: usize) -> Option<i16> {
let hi = *b.get(at + 1)?;
let lo = *b.get(at)?;
Some(i16::from_le_bytes([lo, hi]))
}
fn read_i32(b: &[u8], at: usize) -> Option<i32> {
let s: [u8; 4] = b.get(at..at + 4)?.try_into().ok()?;
Some(i32::from_le_bytes(s))
}
fn num_at(b: &[u8], at: usize, wide: bool) -> Option<i32> {
if wide {
read_i32(b, at)
} else {
read_i16(b, at).map(i32::from)
}
}
fn cstr_at(table: &[u8], off: usize) -> Option<Vec<u8>> {
let rest = table.get(off..)?;
let end = rest.iter().position(|&c| c == 0).unwrap_or(rest.len());
Some(rest[..end].to_vec())
}
impl TermEntry {
pub fn parse(b: &[u8]) -> Option<TermEntry> {
let magic = read_i16(b, 0)? as u16;
let wide = match magic {
MAGIC_16 => false,
MAGIC_32 => true,
_ => return None,
};
let name_size = read_i16(b, 2)?.max(0) as usize;
let bool_count = read_i16(b, 4)?.max(0) as usize;
let num_count = read_i16(b, 6)?.max(0) as usize;
let str_count = read_i16(b, 8)?.max(0) as usize;
let table_size = read_i16(b, 10)?.max(0) as usize;
let num_width = if wide { 4 } else { 2 };
let mut p = 12;
let names_raw = b.get(p..p + name_size)?;
let names = String::from_utf8_lossy(names_raw)
.trim_end_matches('\0')
.split('|')
.map(|s| s.to_string())
.collect();
p += name_size;
let bools: Vec<i8> = b.get(p..p + bool_count)?.iter().map(|&v| v as i8).collect();
p += bool_count;
if p % 2 != 0 {
p += 1;
}
let mut nums = Vec::with_capacity(num_count);
for i in 0..num_count {
nums.push(num_at(b, p + i * num_width, wide)?);
}
p += num_count * num_width;
let mut offsets = Vec::with_capacity(str_count);
for i in 0..str_count {
offsets.push(read_i16(b, p + i * 2)?);
}
p += str_count * 2;
let table = b.get(p..p + table_size)?;
p += table_size;
let strs = offsets
.iter()
.map(|&o| {
if o < 0 {
None
} else {
cstr_at(table, o as usize)
}
})
.collect();
let mut e = TermEntry {
names,
bools,
nums,
strs,
..Default::default()
};
e.parse_extended(b, p, wide);
Some(e)
}
fn parse_extended(&mut self, b: &[u8], mut p: usize, wide: bool) {
if p % 2 != 0 {
p += 1;
}
let Some(ext_bool) = read_i16(b, p).map(|v| v.max(0) as usize) else {
return;
};
let Some(ext_num) = read_i16(b, p + 2).map(|v| v.max(0) as usize) else {
return;
};
let Some(ext_str) = read_i16(b, p + 4).map(|v| v.max(0) as usize) else {
return;
};
let Some(ext_off) = read_i16(b, p + 6).map(|v| v.max(0) as usize) else {
return;
};
let Some(ext_size) = read_i16(b, p + 8).map(|v| v.max(0) as usize) else {
return;
};
p += 10;
let num_width = if wide { 4 } else { 2 };
let Some(bools) = b.get(p..p + ext_bool) else {
return;
};
let bools: Vec<i8> = bools.iter().map(|&v| v as i8).collect();
p += ext_bool;
if p % 2 != 0 {
p += 1;
}
let mut nums = Vec::with_capacity(ext_num);
for i in 0..ext_num {
match num_at(b, p + i * num_width, wide) {
Some(v) => nums.push(v),
None => return,
}
}
p += ext_num * num_width;
let mut offsets = Vec::with_capacity(ext_off);
for i in 0..ext_off {
match read_i16(b, p + i * 2) {
Some(v) => offsets.push(v),
None => return,
}
}
p += ext_off * 2;
let Some(table) = b.get(p..p + ext_size) else {
return;
};
let values: Vec<Option<Vec<u8>>> = offsets
.iter()
.take(ext_str)
.map(|&o| {
if o < 0 {
None
} else {
cstr_at(table, o as usize)
}
})
.collect();
let names: Vec<String> = offsets
.iter()
.skip(ext_str)
.map(|&o| {
if o < 0 {
String::new()
} else {
cstr_at(table, o as usize)
.map(|v| String::from_utf8_lossy(&v).into_owned())
.unwrap_or_default()
}
})
.collect();
let mut it = names.into_iter();
for i in 0..ext_bool {
if let Some(n) = it.next() {
if !n.is_empty() {
self.ext_bools.insert(n, bools.get(i).copied().unwrap_or(0) == 1);
}
}
}
for i in 0..ext_num {
if let Some(n) = it.next() {
if !n.is_empty() {
self.ext_nums.insert(n, nums.get(i).copied().unwrap_or(ABSENT));
}
}
}
for i in 0..ext_str {
if let Some(n) = it.next() {
if n.is_empty() {
continue;
}
if let Some(Some(v)) = values.get(i) {
self.ext_strs.insert(n, v.clone());
}
}
}
}
pub fn flag(&self, name: &str) -> i32 {
if let Some(i) = BOOL_NAMES.iter().position(|&n| n == name) {
return i32::from(self.bools.get(i).copied().unwrap_or(0) == 1);
}
if let Some(&v) = self.ext_bools.get(name) {
return i32::from(v);
}
-1
}
pub fn num(&self, name: &str) -> i32 {
if let Some(i) = NUM_NAMES.iter().position(|&n| n == name) {
let v = self.nums.get(i).copied().unwrap_or(ABSENT);
return if v == CANCELLED { ABSENT } else { v };
}
if let Some(&v) = self.ext_nums.get(name) {
return v;
}
CANCELLED
}
pub fn string(&self, name: &str) -> Option<&[u8]> {
if let Some(i) = STR_NAMES.iter().position(|&n| n == name) {
return self.strs.get(i).and_then(|o| o.as_deref());
}
self.ext_strs.get(name).map(|v| v.as_slice())
}
pub fn set_string(&mut self, name: &str, value: Option<Vec<u8>>) {
let Some(i) = STR_NAMES.iter().position(|&n| n == name) else {
return;
};
if self.strs.len() <= i {
self.strs.resize(i + 1, None);
}
self.strs[i] = value;
}
pub fn str_is_wrong_type(&self, name: &str) -> bool {
!STR_NAMES.contains(&name) && !self.ext_strs.contains_key(name)
}
pub fn tc_flag(&self, code: &str) -> i32 {
match BOOL_CODES.iter().position(|&c| c == code) {
Some(i) => i32::from(self.bools.get(i).copied().unwrap_or(0) == 1),
None => 0,
}
}
pub fn tc_num(&self, code: &str) -> i32 {
match NUM_CODES.iter().position(|&c| c == code) {
Some(i) => self.nums.get(i).copied().unwrap_or(ABSENT).max(ABSENT),
None => ABSENT,
}
}
pub fn tc_string(&self, code: &str) -> Option<&[u8]> {
let i = STR_CODES.iter().rposition(|&c| c == code)?;
if STR_NAMES.get(i) == Some(&"sgr0") {
if let Some(fix) = self.fix_sgr0.as_deref() {
return Some(fix);
}
}
self.strs.get(i).and_then(|o| o.as_deref())
}
}
fn cur_term() -> &'static Mutex<Option<TermEntry>> {
static CUR: OnceLock<Mutex<Option<TermEntry>>> = OnceLock::new();
CUR.get_or_init(|| Mutex::new(None))
}
pub fn with_cur_term<T>(default: T, f: impl FnOnce(&TermEntry) -> T) -> T {
match cur_term().lock() {
Ok(g) => match g.as_ref() {
Some(e) => f(e),
None => default,
},
Err(_) => default,
}
}
pub fn have_cur_term() -> bool {
cur_term().lock().map(|g| g.is_some()).unwrap_or(false)
}
fn search_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
let mut push = |p: PathBuf| {
if !p.as_os_str().is_empty() && !dirs.contains(&p) {
dirs.push(p);
}
};
let defaults = || {
let brew = [
"/opt/homebrew/opt/ncurses/share/terminfo",
"/usr/local/opt/ncurses/share/terminfo",
];
let system = [
"/usr/share/terminfo",
"/lib/terminfo",
"/etc/terminfo",
"/usr/lib/terminfo",
"/usr/share/lib/terminfo",
"/usr/local/share/terminfo",
];
let ordered: Vec<&str> = if cfg!(target_os = "macos") {
brew.into_iter().chain(system).collect()
} else {
system.into_iter().chain(brew).collect()
};
ordered.into_iter().map(PathBuf::from)
};
if let Ok(v) = std::env::var("TERMINFO") {
push(PathBuf::from(v));
}
if let Ok(h) = std::env::var("HOME") {
push(PathBuf::from(h).join(".terminfo"));
}
if let Ok(v) = std::env::var("TERMINFO_DIRS") {
for part in v.split(':') {
if part.is_empty() {
for d in defaults() {
push(d);
}
} else {
push(PathBuf::from(part));
}
}
}
for d in defaults() {
push(d);
}
dirs
}
fn candidates(dir: &Path, name: &str) -> Vec<PathBuf> {
let Some(first) = name.chars().next() else {
return Vec::new();
};
let mut v = vec![dir.join(first.to_string()).join(name)];
let mut buf = [0u8; 4];
let hex = format!("{:02x}", first.encode_utf8(&mut buf).as_bytes()[0]);
v.push(dir.join(hex).join(name));
v
}
pub fn load_entry(name: &str) -> Result<TermEntry, SetupError> {
if name.is_empty() || name.contains('/') {
return Err(SetupError::Unset);
}
let dirs = search_dirs();
let mut saw_dir = false;
for dir in &dirs {
if !dir.is_dir() {
continue;
}
saw_dir = true;
for cand in candidates(dir, name) {
let Ok(bytes) = std::fs::read(&cand) else {
continue;
};
if let Some(e) = TermEntry::parse(&bytes) {
return Ok(e);
}
}
}
Err(if saw_dir {
SetupError::NotFound
} else {
SetupError::NoDatabase
})
}
pub fn setupterm(term: Option<&str>) -> Result<(), SetupError> {
let name = match term {
Some(t) => t.to_string(),
None => std::env::var("TERM").unwrap_or_default(),
};
if name.is_empty() {
return Err(SetupError::Unset);
}
if let Ok(g) = cur_term().lock() {
if let Some(cur) = g.as_ref() {
if cur.names.iter().any(|n| *n == name) {
return Ok(());
}
}
}
let mut e = load_entry(&name)?;
apply_screensize(&mut e);
apply_termcap_fixups(&mut e);
if let Ok(mut g) = cur_term().lock() {
*g = Some(e);
}
crate::shout::invalidate_pad_info();
Ok(())
}
fn apply_screensize(e: &mut TermEntry) {
let idx = |n: &str| NUM_NAMES.iter().position(|&x| x == n);
let (Some(li), Some(co)) = (idx("lines"), idx("cols")) else {
return;
};
if e.nums.len() <= li.max(co) {
e.nums.resize(li.max(co) + 1, ABSENT);
}
let mut lines = e.nums[li];
let mut cols = e.nums[co];
for fd in [libc::STDOUT_FILENO, libc::STDERR_FILENO, libc::STDIN_FILENO] {
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
if unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) } == 0 {
if ws.ws_row > 0 {
lines = i32::from(ws.ws_row);
}
if ws.ws_col > 0 {
cols = i32::from(ws.ws_col);
}
break;
}
}
if let Ok(v) = std::env::var("LINES") {
if let Ok(n) = v.parse::<i32>() {
if n > 0 {
lines = n;
}
}
}
if let Ok(v) = std::env::var("COLUMNS") {
if let Ok(n) = v.parse::<i32>() {
if n > 0 {
cols = n;
}
}
}
e.nums[li] = if lines > 0 { lines } else { 24 };
e.nums[co] = if cols > 0 { cols } else { 80 };
}
pub fn tigetflag(name: &str) -> i32 {
with_cur_term(0, |e| e.flag(name))
}
pub fn tigetnum(name: &str) -> i32 {
with_cur_term(ABSENT, |e| e.num(name))
}
pub fn tigetstr(name: &str) -> Result<Option<Vec<u8>>, ()> {
let wrong = with_cur_term(false, |e| e.str_is_wrong_type(name));
if wrong {
return Err(());
}
Ok(with_cur_term(None, |e| e.string(name).map(|s| s.to_vec())))
}
pub fn tgetent(name: &str) -> i32 {
if let Err(e) = setupterm(Some(name)) {
return e.errret();
}
1
}
fn apply_termcap_fixups(e: &mut TermEntry) {
if let Some(cub1) = e.string("cub1").map(|s| s.to_vec()) {
let is_bs = cub1 == b"\x08";
if let Some(i) = BOOL_NAMES.iter().position(|&n| n == "OTbs") {
if e.bools.len() <= i {
e.bools.resize(i + 1, 0);
}
e.bools[i] = i8::from(is_bs);
}
let _ = cub1;
}
let trimmed = trim_sgr0(e);
if let Some(t) = trimmed {
if e.string("sgr0") != Some(t.as_slice()) {
e.fix_sgr0 = Some(t);
}
}
}
fn trim_sgr0(e: &TermEntry) -> Option<Vec<u8>> {
let base = match e.string("sgr") {
Some(sgr) => crate::tparm::tparm(sgr, &[0; 9]),
None => e.string("sgr0")?.to_vec(),
};
let base = crate::tparm::tputs_strip_padding(&base);
let mut out: Vec<u8> = Vec::with_capacity(base.len());
let mut i = 0usize;
while i < base.len() {
if base[i] == 0x1b && base.get(i + 1) == Some(&b'(') && i + 2 < base.len() {
i += 3;
continue;
}
if base[i] == 0x0e || base[i] == 0x0f {
i += 1;
continue;
}
out.push(base[i]);
i += 1;
}
let cleaned = drop_sgr_font_param(&out);
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
fn drop_sgr_font_param(s: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len());
let mut i = 0usize;
while i < s.len() {
if s[i] == 0x1b && s.get(i + 1) == Some(&b'[') {
let mut j = i + 2;
while j < s.len() && (s[j].is_ascii_digit() || s[j] == b';') {
j += 1;
}
if s.get(j) == Some(&b'm') {
let params: Vec<&[u8]> = s[i + 2..j].split(|&c| c == b';').collect();
let kept: Vec<&[u8]> = params.into_iter().filter(|p| *p != b"10").collect();
out.extend_from_slice(b"\x1b[");
for (k, p) in kept.iter().enumerate() {
if k > 0 {
out.push(b';');
}
out.extend_from_slice(p);
}
out.push(b'm');
i = j + 1;
continue;
}
}
out.push(s[i]);
i += 1;
}
out
}
pub fn tgetflag(code: &str) -> i32 {
with_cur_term(0, |e| e.tc_flag(code))
}
pub fn tgetnum(code: &str) -> i32 {
with_cur_term(ABSENT, |e| e.tc_num(code))
}
pub fn tgetstr(code: &str) -> Option<Vec<u8>> {
with_cur_term(None, |e| e.tc_string(code).map(|s| s.to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
fn synth(wide: bool) -> Vec<u8> {
let names = b"zz-test|synthetic\0";
let bools: [i8; 3] = [1, 0, 1];
let nums: [i32; 2] = [80, -1];
let table = b"\x1b[H\0\x1b[J\0";
let offsets: [i16; 3] = [0, 4, -1];
let mut v = Vec::new();
let magic: u16 = if wide { MAGIC_32 } else { MAGIC_16 };
for x in [
magic as i16,
names.len() as i16,
bools.len() as i16,
nums.len() as i16,
offsets.len() as i16,
table.len() as i16,
] {
v.extend_from_slice(&x.to_le_bytes());
}
v.extend_from_slice(names);
v.extend(bools.iter().map(|&b| b as u8));
if v.len() % 2 != 0 {
v.push(0);
}
for n in nums {
if wide {
v.extend_from_slice(&n.to_le_bytes());
} else {
v.extend_from_slice(&(n as i16).to_le_bytes());
}
}
for o in offsets {
v.extend_from_slice(&o.to_le_bytes());
}
v.extend_from_slice(table);
v
}
#[test]
fn parses_both_numeric_widths_identically() {
for wide in [false, true] {
let e = TermEntry::parse(&synth(wide)).expect("parses");
assert_eq!(e.names[0], "zz-test");
assert_eq!(e.names[1], "synthetic");
assert_eq!(e.bools, vec![1, 0, 1]);
assert_eq!(e.nums, vec![80, ABSENT]);
assert_eq!(e.strs[0].as_deref(), Some(&b"\x1b[H"[..]));
assert_eq!(e.strs[1].as_deref(), Some(&b"\x1b[J"[..]));
assert_eq!(e.strs[2], None, "offset -1 is absent, not empty");
}
}
#[test]
fn rejects_non_terminfo_bytes() {
assert!(TermEntry::parse(b"not a terminfo file at all").is_none());
assert!(TermEntry::parse(&[]).is_none());
let mut t = synth(false);
t.truncate(20);
assert!(TermEntry::parse(&t).is_none());
}
#[test]
fn cap_tables_have_the_frozen_ncurses6_lengths() {
assert_eq!(BOOL_NAMES.len(), 44);
assert_eq!(BOOL_CODES.len(), 44);
assert_eq!(NUM_NAMES.len(), 39);
assert_eq!(NUM_CODES.len(), 39);
assert_eq!(STR_NAMES.len(), 414);
assert_eq!(STR_CODES.len(), 414);
}
#[test]
fn well_known_capabilities_sit_at_their_documented_indexes() {
assert_eq!(BOOL_NAMES[0], "bw");
assert_eq!(BOOL_NAMES.iter().position(|&n| n == "am"), Some(1));
assert_eq!(BOOL_CODES[1], "am");
assert_eq!(NUM_NAMES.iter().position(|&n| n == "cols"), Some(0));
assert_eq!(NUM_CODES[0], "co");
assert_eq!(NUM_NAMES.iter().position(|&n| n == "lines"), Some(2));
assert_eq!(NUM_CODES[2], "li");
assert_eq!(STR_NAMES.iter().position(|&n| n == "cup"), Some(10));
assert_eq!(STR_CODES[10], "cm");
}
}