Skip to content

Commit 1722fc3

Browse files
committed
tools: decode cancelled-extended terminfo layout -- infocmp/tic -x now 100%
Crack the extended (user-defined) offset-table layout that previously bounded the `-x` paths, lifting both `infocmp -1 -x` and the `infocmp -1 -x | tic -x` round-trip to 100.000% byte-exact across the whole 2,869-entry terminfo database. The key was the offset-table length: it is `2*es + eb + en` (es string-value offsets followed by eb+en+es cap-name offsets), NOT the extended header's 4th field -- which is the *count of strings* in the table (offsets minus the cancelled string caps, since a cancelled string keeps a -2 value slot but contributes no value string). The 5th field is the string-table byte size. Byte accounting (`p + 2*(2es+eb+en) + str_size == file_end`) confirms it. reader (src/terminfo/mod.rs): read `2*es+eb+en` offsets; surface extended string caps with cancellation (`Some(value)` vs `None` for `name@`); `ext_string_caps()`. infocmp: emit cancelled extended strings as `name@`, merged sorted. tic: parse extended caps (names outside the predefined tables; a bare `name@` is a cancelled extended string, matching ncurses); with `-x`, lift the SVr4 predefined cutoff and append the full extended section -- ext bool bytes (+pad), ext numbers, the value+name offset tables, and the value/name string tables, with the str_count/str_size header and cancelled `-2` value offsets. Both NCURSES.INFOCMP and NCURSES.TIC courts now admit 100.000% on -1 and -x. Closes the cancelled-extended-capability corner noted in the gap ledger; advances BLD-03. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbiPgWC3PHKmrTNvZzXcWQ
1 parent 8e709f7 commit 1722fc3

8 files changed

Lines changed: 281 additions & 146 deletions

File tree

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,14 @@ The same crate ships a native **`infocmp`** decompiler whose `-1` source output
8282
byte-exact across the whole 2,869-entry terminfo database** (`NCURSES.INFOCMP` court): it reconstructs
8383
`_nc_tic_expand` string escaping (the caret-vs-octal length heuristic, the `%`-operator verbatim rule,
8484
the `\E`/`\n`/`\r`/`\0`/`\s`/`\,`/`\^` escapes), power-of-two hex number formatting (`colors#0x100`),
85-
cancelled caps (`name@`), and `acsc` glyph-pair sorting. `infocmp -1 -x` (extended caps) is at 99.2%,
86-
the residual confined to entries using *cancelled extended* capabilities (see the gap ledger). Its
87-
inverse, a native **`tic`**, compiles source back to the binary form **100.000% byte-identically to
88-
system tic across the whole database** on the `infocmp -1 | tic` round-trip (`NCURSES.TIC` court):
89-
the source parser, the `_nc_tic_expand` un-escaper, and the binary writer (magic selection, the SVr4
90-
cutoff that drops ncurses-extension predefined caps without `-x`, cancelled `-2` markers).
85+
cancelled caps (`name@`), `acsc` glyph-pair sorting, and the full extended (user-defined, `-x`)
86+
section including cancelled `name@` extensions — **100.000% byte-exact for both `-1` and `-1 -x`
87+
across the whole database** (`NCURSES.INFOCMP` court). Its inverse, a native **`tic`**, compiles
88+
source back to the binary form **100.000% byte-identically to system tic** on both the
89+
`infocmp -1 | tic` and `infocmp -1 -x | tic -x` round-trips (`NCURSES.TIC` court): the source parser,
90+
the `_nc_tic_expand` un-escaper, and the binary writer (magic selection, the SVr4 cutoff that drops
91+
ncurses-extension predefined caps without `-x`, cancelled `-2` markers, and the extended-section
92+
offset/string tables).
9193

9294
## Forensic gap ledger
9395

crates/ncurses-tools/src/bin/infocmp.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,10 +277,14 @@ fn main() {
277277
}
278278

279279
if ext {
280-
let mut ex: Vec<(String, Vec<u8>)> = ti.ext_string_names();
280+
// Extended string caps follow, sorted by name; a cancelled one renders as `name@`.
281+
let mut ex = ti.ext_string_caps();
281282
ex.sort_unstable_by(|a, b| a.0.cmp(&b.0));
282283
for (n, v) in ex {
283-
out.push_str(&format!("\t{}={},\n", n, tic_expand(&v)));
284+
match v {
285+
Some(bytes) => out.push_str(&format!("\t{}={},\n", n, tic_expand(&bytes))),
286+
None => out.push_str(&format!("\t{n}@,\n")),
287+
}
284288
}
285289
}
286290

crates/ncurses-tools/src/bin/tic.rs

Lines changed: 148 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ fn unescape(s: &[u8]) -> Vec<u8> {
8686
out
8787
}
8888

89-
/// One parsed capability from source.
89+
/// One parsed *predefined* capability from source.
9090
enum Cap {
9191
Bool(usize),
9292
Num(usize, i32),
@@ -95,10 +95,20 @@ enum Cap {
9595
Cancel { is_string: bool, index: usize },
9696
}
9797

98+
/// An *extended* (user-defined) capability -- a name not in the predefined tables. A bare `name@`
99+
/// is, like ncurses, a cancelled extended *string*.
100+
enum ExtCap {
101+
Bool(String),
102+
Num(String, i32),
103+
/// `Some(bytes)` present value, `None` cancelled (`name@`).
104+
Str(String, Option<Vec<u8>>),
105+
}
106+
98107
/// A parsed terminfo entry ready to compile.
99108
struct Entry {
100109
names: String,
101110
caps: Vec<Cap>,
111+
ext: Vec<ExtCap>,
102112
}
103113

104114
/// Split an entry body into comma-separated fields. A `\X` escape and a `^X` caret pair are each
@@ -165,32 +175,43 @@ fn parse_entry(text: &str) -> Option<Entry> {
165175
return None;
166176
}
167177
let mut caps_out = Vec::new();
178+
let mut ext_out = Vec::new();
168179
for f in it {
169180
let f = f.trim();
170181
if f.is_empty() || f.starts_with("use=") {
171182
continue; // `use=` flattening is not needed for resolved (`infocmp -1`) source.
172183
}
173184
if let Some(eq) = f.find('=') {
174185
let (name, val) = (&f[..eq], &f[eq + 1..]);
175-
if let Some(ix) = str_index(name) {
176-
caps_out.push(Cap::Str(ix, unescape(val.as_bytes())));
186+
match str_index(name) {
187+
Some(ix) => caps_out.push(Cap::Str(ix, unescape(val.as_bytes()))),
188+
None => ext_out.push(ExtCap::Str(name.to_string(), Some(unescape(val.as_bytes())))),
177189
}
178190
} else if let Some(name) = f.strip_suffix('@') {
179191
if let Some(ix) = str_index(name) {
180192
caps_out.push(Cap::Cancel { is_string: true, index: ix });
181193
} else if let Some(ix) = num_index(name) {
182194
caps_out.push(Cap::Cancel { is_string: false, index: ix });
195+
} else {
196+
// An unknown cancelled cap is, like ncurses, a cancelled extended string.
197+
ext_out.push(ExtCap::Str(name.to_string(), None));
183198
}
184199
} else if let Some(h) = f.find('#') {
185200
let (name, val) = (&f[..h], &f[h + 1..]);
186-
if let (Some(ix), Some(n)) = (num_index(name), parse_num(val)) {
187-
caps_out.push(Cap::Num(ix, n));
201+
if let Some(n) = parse_num(val) {
202+
match num_index(name) {
203+
Some(ix) => caps_out.push(Cap::Num(ix, n)),
204+
None => ext_out.push(ExtCap::Num(name.to_string(), n)),
205+
}
206+
}
207+
} else {
208+
match bool_index(f) {
209+
Some(ix) => caps_out.push(Cap::Bool(ix)),
210+
None => ext_out.push(ExtCap::Bool(f.to_string())),
188211
}
189-
} else if let Some(ix) = bool_index(f) {
190-
caps_out.push(Cap::Bool(ix));
191212
}
192213
}
193-
Some(Entry { names, caps: caps_out })
214+
Some(Entry { names, caps: caps_out, ext: ext_out })
194215
}
195216

196217
/// SVr4 capability counts: without `-x`, `tic` stores only caps below these indices. The
@@ -201,7 +222,15 @@ const SVR4_NUM: usize = 33;
201222
const SVR4_STR: usize = 394;
202223

203224
/// Compile a parsed entry to the binary terminfo layout (the inverse of this crate's reader).
204-
fn compile(entry: &Entry) -> Vec<u8> {
225+
/// With `ext`, the predefined-cap cutoff is lifted (the full tables are stored) and an extended
226+
/// (user-defined) section is appended; without it, the SVr4 cutoff applies and extensions drop.
227+
fn compile(entry: &Entry, ext: bool) -> Vec<u8> {
228+
let (cut_b, cut_n, cut_s) = if ext {
229+
(caps::BOOL_NAMES.len(), caps::NUM_NAMES.len(), caps::STR_NAMES.len())
230+
} else {
231+
(SVR4_BOOL, SVR4_NUM, SVR4_STR)
232+
};
233+
205234
// Lay capabilities out by index; absent slots are -1, cancelled are -2.
206235
let mut bool_vals: Vec<i8> = Vec::new(); // 0 absent, 1 true
207236
let mut num_vals: Vec<i32> = Vec::new(); // -1 absent, -2 cancelled, else value
@@ -227,27 +256,27 @@ fn compile(entry: &Entry) -> Vec<u8> {
227256

228257
for cap in &entry.caps {
229258
match cap {
230-
Cap::Bool(ix) if *ix < SVR4_BOOL => {
259+
Cap::Bool(ix) if *ix < cut_b => {
231260
ensure_b(&mut bool_vals, *ix);
232261
bool_vals[*ix] = 1;
233262
}
234-
Cap::Num(ix, n) if *ix < SVR4_NUM => {
263+
Cap::Num(ix, n) if *ix < cut_n => {
235264
ensure_n(&mut num_vals, *ix);
236265
num_vals[*ix] = *n;
237266
}
238-
Cap::Str(ix, bytes) if *ix < SVR4_STR => {
267+
Cap::Str(ix, bytes) if *ix < cut_s => {
239268
ensure_s(&mut str_vals, &mut str_cancel, *ix);
240269
str_vals[*ix] = Some(bytes.clone());
241270
}
242-
Cap::Cancel { is_string: true, index } if *index < SVR4_STR => {
271+
Cap::Cancel { is_string: true, index } if *index < cut_s => {
243272
ensure_s(&mut str_vals, &mut str_cancel, *index);
244273
str_cancel[*index] = true;
245274
}
246-
Cap::Cancel { is_string: false, index } if *index < SVR4_NUM => {
275+
Cap::Cancel { is_string: false, index } if *index < cut_n => {
247276
ensure_n(&mut num_vals, *index);
248277
num_vals[*index] = -2;
249278
}
250-
// Caps at/above the SVr4 cutoffs are ncurses extensions, stored only under `-x`.
279+
// Caps at/above the cutoff are ncurses extensions, stored only under `-x`.
251280
_ => {}
252281
}
253282
}
@@ -256,8 +285,13 @@ fn compile(entry: &Entry) -> Vec<u8> {
256285
let nnum = num_vals.len();
257286
let nstr = str_vals.len();
258287

259-
// 32-bit numbers only when a value does not fit a signed 16-bit (ncurses' magic switch).
260-
let need_32 = num_vals.iter().any(|&n| n > 32767);
288+
// 32-bit numbers only when a value does not fit a signed 16-bit (ncurses' magic switch);
289+
// extended numerics count too.
290+
let ext_num_max = entry.ext.iter().filter_map(|e| match e {
291+
ExtCap::Num(_, n) => Some(*n),
292+
_ => None,
293+
});
294+
let need_32 = num_vals.iter().copied().chain(ext_num_max).any(|n| n > 32767);
261295
let (magic, num_width) = if need_32 {
262296
(MAGIC_EXTENDED_NUMBERS, 4usize)
263297
} else {
@@ -313,9 +347,102 @@ fn compile(entry: &Entry) -> Vec<u8> {
313347
out.extend_from_slice(&o.to_le_bytes());
314348
}
315349
out.extend_from_slice(&strtab);
350+
351+
if ext && !entry.ext.is_empty() {
352+
append_extended(&mut out, &entry.ext, num_width);
353+
}
316354
out
317355
}
318356

357+
/// Append the extended (user-defined) section, mirroring ncurses' `-x` layout: an even-aligned
358+
/// header `[eb, en, es, str_count, str_size]`, the extended boolean bytes (+pad), the extended
359+
/// numbers, an offset table of `es` value offsets then `eb+en+es` name offsets, and a string table
360+
/// of the present value strings followed by every cap name. Cancelled strings keep a `-2` value
361+
/// offset and no value string.
362+
fn append_extended(out: &mut Vec<u8>, ext: &[ExtCap], num_width: usize) {
363+
let bools: Vec<&String> = ext
364+
.iter()
365+
.filter_map(|e| if let ExtCap::Bool(n) = e { Some(n) } else { None })
366+
.collect();
367+
let nums: Vec<(&String, i32)> = ext
368+
.iter()
369+
.filter_map(|e| if let ExtCap::Num(n, v) = e { Some((n, *v)) } else { None })
370+
.collect();
371+
let strs: Vec<(&String, &Option<Vec<u8>>)> = ext
372+
.iter()
373+
.filter_map(|e| if let ExtCap::Str(n, v) = e { Some((n, v)) } else { None })
374+
.collect();
375+
let (eb, en, es) = (bools.len(), nums.len(), strs.len());
376+
377+
// Value strings (present only) then names (every cap). Offsets: values from the table start,
378+
// names relative to the end of the value region.
379+
let mut value_tab: Vec<u8> = Vec::new();
380+
let mut val_offsets: Vec<i16> = Vec::with_capacity(es);
381+
for (_, v) in &strs {
382+
match v {
383+
Some(bytes) => {
384+
val_offsets.push(value_tab.len() as i16);
385+
value_tab.extend_from_slice(bytes);
386+
value_tab.push(0);
387+
}
388+
None => val_offsets.push(-2),
389+
}
390+
}
391+
let mut name_tab: Vec<u8> = Vec::new();
392+
let mut name_offsets: Vec<i16> = Vec::with_capacity(eb + en + es);
393+
let push_name = |tab: &mut Vec<u8>, offs: &mut Vec<i16>, name: &str| {
394+
offs.push(tab.len() as i16);
395+
tab.extend_from_slice(name.as_bytes());
396+
tab.push(0);
397+
};
398+
for n in &bools {
399+
push_name(&mut name_tab, &mut name_offsets, n);
400+
}
401+
for (n, _) in &nums {
402+
push_name(&mut name_tab, &mut name_offsets, n);
403+
}
404+
for (n, _) in &strs {
405+
push_name(&mut name_tab, &mut name_offsets, n);
406+
}
407+
408+
let present_values = val_offsets.iter().filter(|&&o| o >= 0).count();
409+
let str_count = present_values + eb + en + es;
410+
let str_size = value_tab.len() + name_tab.len();
411+
412+
// The extended section starts on an even boundary.
413+
if out.len() % 2 == 1 {
414+
out.push(0);
415+
}
416+
let push16 = |o: &mut Vec<u8>, v: u16| o.extend_from_slice(&v.to_le_bytes());
417+
push16(out, eb as u16);
418+
push16(out, en as u16);
419+
push16(out, es as u16);
420+
push16(out, str_count as u16);
421+
push16(out, str_size as u16);
422+
for n in &bools {
423+
let _ = n;
424+
out.push(1); // extended booleans present in source are true
425+
}
426+
if eb % 2 == 1 {
427+
out.push(0);
428+
}
429+
for (_, v) in &nums {
430+
if num_width == 2 {
431+
out.extend_from_slice(&(*v as i16).to_le_bytes());
432+
} else {
433+
out.extend_from_slice(&v.to_le_bytes());
434+
}
435+
}
436+
for &o in &val_offsets {
437+
out.extend_from_slice(&o.to_le_bytes());
438+
}
439+
for &o in &name_offsets {
440+
out.extend_from_slice(&o.to_le_bytes());
441+
}
442+
out.extend_from_slice(&value_tab);
443+
out.extend_from_slice(&name_tab);
444+
}
445+
319446
fn split_entries(text: &str) -> Vec<String> {
320447
// Strip comment lines, then group: an entry begins at a non-indented, non-empty line.
321448
let mut entries = Vec::new();
@@ -341,6 +468,7 @@ fn main() {
341468
let argv: Vec<String> = std::env::args().skip(1).collect();
342469
let mut out_dir: Option<String> = None;
343470
let mut input: Option<String> = None;
471+
let mut ext = false;
344472
let mut i = 0;
345473
while i < argv.len() {
346474
match argv[i].as_str() {
@@ -349,8 +477,9 @@ fn main() {
349477
i += 1;
350478
}
351479
a if a.starts_with("-o") && a.len() > 2 => out_dir = Some(a[2..].to_string()),
480+
"-x" => ext = true,
352481
// Accept and ignore the common flags that do not change compiled bytes.
353-
"-x" | "-1" | "-v" | "-s" | "-c" | "-r" | "-a" | "-g" | "-q" => {}
482+
"-1" | "-v" | "-s" | "-c" | "-r" | "-a" | "-g" | "-q" => {}
354483
a if a.starts_with('-') && a != "-" => {}
355484
a => input = Some(a.to_string()),
356485
}
@@ -387,7 +516,7 @@ fn main() {
387516
if primary.is_empty() {
388517
continue;
389518
}
390-
let compiled = compile(&entry);
519+
let compiled = compile(&entry, ext);
391520
let sub = &primary[..1];
392521
let subdir = PathBuf::from(&dir).join(sub);
393522
if let Err(e) = std::fs::create_dir_all(&subdir) {

docs/gap-ledger.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -588,24 +588,25 @@ intentional, documented divergence — that is strictly-better parity, not a gap
588588
`tput`, `clear`, `tabs`, `reset`, `tset`, `captoinfo`, `infotocap` — scripts
589589
depend on `tput`/`clear` exit codes & output, and on `infocmp | tic` round-trip
590590
idempotence. **`tput`/`clear` are done** (native binaries, `NCURSES.TPUT` court).
591-
**`infocmp` is done for the `-1` source layout: 100.000% byte-exact across the
591+
**`infocmp` is done — 100.000% byte-exact for BOTH `-1` and `-1 -x` across the
592592
whole 2,869-entry terminfo database** (`NCURSES.INFOCMP` court) — the decompiler
593593
reconstructs `_nc_tic_expand` escaping (the caret-vs-octal length heuristic, the
594594
`%`-operator verbatim rule, the letter/`\s`/`\,`/`\^` escapes), power-of-two hex
595-
number formatting (`colors#0x100`), cancelled caps (`name@`), and `acsc` glyph
596-
sorting. `infocmp -1 -x` (extended/user-defined caps) is at **99.2%** (2846/2869);
597-
the residual is confined to entries that use *cancelled extended* capabilities,
598-
whose compiled offset-table layout is not yet decoded byte-exactly (honest bound,
599-
not a claimed match). **`tic` is now done for the non-extended case: 100.000%
600-
byte-exact across the whole 2,869-entry database on the `infocmp -1 | tic`
601-
round-trip** (`NCURSES.TIC` court) — the inverse of `infocmp`, reconstructing the
595+
number formatting (`colors#0x100`), cancelled caps (`name@`), `acsc` glyph
596+
sorting, and the full extended (user-defined, `-x`) section including cancelled
597+
`name@` extensions — the extended offset-table layout (value offsets + name
598+
offsets = `2·es + eb + en`, the header's string-*count* vs string-*size* fields,
599+
cancelled strings keeping a `-2` value slot but no value string) is now decoded
600+
byte-exactly. **`tic` is done too — 100.000% byte-exact on BOTH the
601+
`infocmp -1 | tic` and `infocmp -1 -x | tic -x` round-trips across the whole
602+
database** (`NCURSES.TIC` court) — the inverse of `infocmp`, reconstructing the
602603
source parser (comma/`^X`/`\X`-aware splitting), the `_nc_tic_expand` un-escaper
603604
(`\E`/`^X`/`\nnn`/`\0`→0x80/`\s`, the `%`-operator verbatim rule), and the binary
604605
writer (header, `|`-names, bool bytes, even-offset numbers with 16-vs-32-bit magic
605-
selection, string offsets/table, cancelled `-2`, and the SVr4 cutoff that drops
606-
ncurses-extension predefined caps without `-x`). `tic -x` (extended-cap
607-
compilation) shares the same cancelled-extended-offset-table corner as `infocmp -x`.
608-
`toe`/`tabs`/`reset`/`tset`/`captoinfo`/`infotocap` remain. **S1, in progress.**
606+
selection, string offsets/table, cancelled `-2`, the SVr4 cutoff that drops
607+
ncurses-extension predefined caps without `-x`, and — with `-x` — the full
608+
extended section writer). `toe`/`tabs`/`reset`/`tset`/`captoinfo`/`infotocap`
609+
remain. **S1, in progress.**
609610
- **BLD-04 · companion libraries panel / menu / form (+`w`)**~200+ symbols,
610611
entire subsystems (overlapping-window stack; menu driver; field/form driver).
611612
**Zero coverage.** **S0, open.**
@@ -1160,7 +1161,7 @@ intentional, documented divergence — that is strictly-better parity, not a gap
11601161
draws each field's buffer padded into the (sub)window and form_driver edits the current field's
11611162
buffer + parks the cursor; byte-identical to system `libformw`, `NCURSES.FORM` court). All three
11621163
companion libraries are built natively (closes BLD-04 / STRUCT-03).
1163-
5. **CLI tools**`tput`/`clear` are **done** (native binaries in `crates/ncurses-tools`, byte-identical to the system tools incl. the extended-terminfo `E3` append, `NCURSES.TPUT` court); **`infocmp -1` is done** (100.000% byte-exact decompile across the 2,869-entry terminfo DB, `NCURSES.INFOCMP` court; `-1 -x` at 99.2%, residual = cancelled extended caps); **`tic` is done** for the non-extended case (100.000% byte-exact `infocmp -1 | tic` round-trip across the DB, `NCURSES.TIC` court); `toe`/… remain (BLD-03).
1164+
5. **CLI tools**`tput`/`clear` are **done** (native binaries in `crates/ncurses-tools`, byte-identical to the system tools incl. the extended-terminfo `E3` append, `NCURSES.TPUT` court); **`infocmp` is done** (100.000% byte-exact decompile for both `-1` and `-1 -x` across the 2,869-entry terminfo DB, `NCURSES.INFOCMP` court); **`tic` is done** (100.000% byte-exact on both the `infocmp -1 | tic` and `infocmp -1 -x | tic -x` round-trips, `NCURSES.TIC` court); `toe`/… remain (BLD-03).
11641165
6. **Native API completeness** — the macro API as Rust methods, panel/menu/form
11651166
modules, wide-char module, and the CLI tools as native binaries (closes
11661167
STRUCT-03); plus a global-state compatibility shim (thread-local `SP`/`cur_term`,

0 commit comments

Comments
 (0)