Skip to content

Commit c704885

Browse files
committed
infocmp -C: apply terminfo islong caret/octal heuristic to termcap control bytes
Derived from the oracle (clean-room): termcap control-byte rendering follows the same "long value" test as terminfo -- caret form ^X for short values (or when the next byte is a digit), octal \nnn once the non-control weight exceeds 3 (or >10 caret-eligible controls). Across 400 sampled terminals this predicts 332/350 octal cases, the apparent low-weight exceptions all being the NUL->\0 special case. Extracted the weight/islong computation from tic_expand into a shared noncontrol_islong() helper and threaded islong + next-byte context through tc_escape_byte / try_translate / tc_xlat. Termcap (-C) content match 27.7% -> 31.6%, exact 20.5% (was 18.1%). terminfo -1 remains 100%; unit tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbiPgWC3PHKmrTNvZzXcWQ
1 parent 54ffa27 commit c704885

1 file changed

Lines changed: 46 additions & 35 deletions

File tree

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

Lines changed: 46 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -59,29 +59,12 @@ fn is_caret_control(b: u8) -> bool {
5959
matches!(b, 0x01..=0x1f if !matches!(b, b'\n' | b'\r' | 0x1b)) || b == 0x7f
6060
}
6161

62-
/// ncurses terminfo string escaping (the inverse of `tic`), reproducing `_nc_tic_expand`'s
63-
/// byte-exact output as verified against real `tic`/`infocmp` across the whole terminfo DB:
64-
/// * `\E` (ESC), `\n` (0x0a), `\r` (0x0d) -- always letter-escaped, regardless of context;
65-
/// * byte 0x80 -> `\0`; other high bytes (0x81..=0xff) -> 3-digit octal `\nnn`;
66-
/// * `\s` for a space that is the first byte OR part of the trailing all-space run
67-
/// (interior spaces stay literal);
68-
/// * `\,` and `\^` for comma/caret; backslash is doubled (`\\`) unless the previous source
69-
/// byte is `^`, in which case it stays a single `\`;
70-
/// * a `%` immediately followed by a printable byte takes that byte verbatim (so terminfo
71-
/// operators like `%^`, `%:`, `%\` survive un-escaped), the lone exception being a comma,
72-
/// which is always protected as `\,`;
73-
/// * caret-eligible control bytes (see [`is_caret_control`]) use caret form `^X`
74-
/// (`X = ch ^ 0x40`) when the value is "short" -- the summed escaped width of all *other*
75-
/// bytes is <= 3 -- or (for `0x01..=0x1f`) when the next byte is an ASCII digit; otherwise
76-
/// octal `\nnn`;
77-
/// * all remaining printables (incl. `:`, `!`, `%`) are emitted literally.
78-
fn tic_expand(v: &[u8]) -> String {
79-
let mut s = String::new();
80-
// ncurses' "long value" test drives the caret-vs-octal choice for control bytes: sum the
81-
// escaped width of every *non*-caret-control byte (control bytes themselves count as zero);
82-
// once that exceeds 3, control bytes render as octal rather than caret. The walk mirrors the
83-
// emission below so that a `%`-operator's verbatim byte is weighted as it actually renders
84-
// (e.g. `%^` is two columns, not the three a standalone `\^` would cost).
62+
/// ncurses' "long value" test, driving the caret-vs-octal choice for control bytes: sum the escaped
63+
/// width of every *non*-caret-control byte (control bytes themselves count as zero); once that
64+
/// exceeds 3 -- or more than ten caret-eligible control bytes appear -- control bytes render as octal
65+
/// rather than caret. The walk mirrors the emission so that a `%`-operator's verbatim byte is
66+
/// weighted as it actually renders (e.g. `%^` is two columns, not the three a standalone `\^` costs).
67+
fn noncontrol_islong(v: &[u8]) -> bool {
8568
let noncontrol_weight: usize = {
8669
let width = |b: u8| -> usize {
8770
match b {
@@ -111,10 +94,29 @@ fn tic_expand(v: &[u8]) -> String {
11194
}
11295
w
11396
};
114-
// A value packed with caret-eligible control bytes also tips "long": more than ten of them
115-
// forces octal even when there is no other content to lengthen the estimate.
11697
let caret_control_count = v.iter().filter(|&&b| is_caret_control(b)).count();
117-
let islong = noncontrol_weight > 3 || caret_control_count > 10;
98+
noncontrol_weight > 3 || caret_control_count > 10
99+
}
100+
101+
/// ncurses terminfo string escaping (the inverse of `tic`), reproducing `_nc_tic_expand`'s
102+
/// byte-exact output as verified against real `tic`/`infocmp` across the whole terminfo DB:
103+
/// * `\E` (ESC), `\n` (0x0a), `\r` (0x0d) -- always letter-escaped, regardless of context;
104+
/// * byte 0x80 -> `\0`; other high bytes (0x81..=0xff) -> 3-digit octal `\nnn`;
105+
/// * `\s` for a space that is the first byte OR part of the trailing all-space run
106+
/// (interior spaces stay literal);
107+
/// * `\,` and `\^` for comma/caret; backslash is doubled (`\\`) unless the previous source
108+
/// byte is `^`, in which case it stays a single `\`;
109+
/// * a `%` immediately followed by a printable byte takes that byte verbatim (so terminfo
110+
/// operators like `%^`, `%:`, `%\` survive un-escaped), the lone exception being a comma,
111+
/// which is always protected as `\,`;
112+
/// * caret-eligible control bytes (see [`is_caret_control`]) use caret form `^X`
113+
/// (`X = ch ^ 0x40`) when the value is "short" -- the summed escaped width of all *other*
114+
/// bytes is <= 3 -- or (for `0x01..=0x1f`) when the next byte is an ASCII digit; otherwise
115+
/// octal `\nnn`;
116+
/// * all remaining printables (incl. `:`, `!`, `%`) are emitted literally.
117+
fn tic_expand(v: &[u8]) -> String {
118+
let mut s = String::new();
119+
let islong = noncontrol_islong(v);
118120

119121
let mut i = 0;
120122
while i < v.len() {
@@ -211,7 +213,7 @@ fn termcap_code(name: &str, names: &[&str], codes: &[&str]) -> Option<String> {
211213

212214
/// Escape one byte for a termcap value: like terminfo but `:` (the field separator) becomes `\072`,
213215
/// and 0x7f is octal `\177` rather than caret.
214-
fn tc_escape_byte(out: &mut String, b: u8) {
216+
fn tc_escape_byte(out: &mut String, b: u8, islong: bool, next: Option<u8>) {
215217
match b {
216218
0x1b => out.push_str("\\E"),
217219
b'\n' => out.push_str("\\n"),
@@ -220,9 +222,17 @@ fn tc_escape_byte(out: &mut String, b: u8) {
220222
0x80 => out.push_str("\\0"),
221223
0x7f => out.push_str("\\177"),
222224
b'\\' => out.push_str("\\\\"),
225+
// Control bytes follow the same caret-vs-octal "long value" heuristic as terminfo: caret
226+
// form `^X` for short values (or when the next byte is a digit, to avoid octal ambiguity),
227+
// octal `\nnn` once the value is long.
223228
0x00..=0x1f => {
224-
out.push('^');
225-
out.push((b ^ 0x40) as char);
229+
let next_is_digit = next.is_some_and(|n| n.is_ascii_digit());
230+
if !islong || next_is_digit {
231+
out.push('^');
232+
out.push((b ^ 0x40) as char);
233+
} else {
234+
out.push_str(&format!("\\{b:03o}"));
235+
}
226236
}
227237
0x81..=0xff => out.push_str(&format!("\\{b:03o}")),
228238
_ => out.push(b as char),
@@ -267,7 +277,7 @@ fn extract_padding(v: &[u8]) -> (Vec<u8>, String) {
267277
/// `%2d`->`%2`, `%03d`->`%3`, `%c`->`%.`, `%i`, `%%`, `%'X'%+%c`->`%+X`, reversed args -> leading
268278
/// `%r`). Returns `None` if any operator has no termcap equivalent (stack ops, `%?`/`%t`/`%e`/`%;`,
269279
/// `%{`, `%x`, ...), in which case the caller keeps the value verbatim, exactly as ncurses does.
270-
fn try_translate(v: &[u8]) -> Option<String> {
280+
fn try_translate(v: &[u8], islong: bool) -> Option<String> {
271281
// Decide whether the two parameters are used in reverse order (`%p2` before `%p1`).
272282
let mut order: Vec<u8> = Vec::new();
273283
let mut i = 0;
@@ -295,7 +305,7 @@ fn try_translate(v: &[u8]) -> Option<String> {
295305
let mut i = 0;
296306
while i < v.len() {
297307
if v[i] != b'%' {
298-
tc_escape_byte(&mut out, v[i]);
308+
tc_escape_byte(&mut out, v[i], islong, v.get(i + 1).copied());
299309
i += 1;
300310
continue;
301311
}
@@ -357,7 +367,7 @@ fn try_translate(v: &[u8]) -> Option<String> {
357367
r_pending = false;
358368
}
359369
out.push_str("%+");
360-
tc_escape_byte(&mut out, v[i + 2]);
370+
tc_escape_byte(&mut out, v[i + 2], islong, v.get(i + 3).copied());
361371
i += 8;
362372
}
363373
_ => return None,
@@ -372,10 +382,11 @@ fn try_translate(v: &[u8]) -> Option<String> {
372382
/// `_nc_infotocap`, which keeps untranslatable caps rather than dropping them.
373383
fn tc_xlat(raw: &[u8]) -> String {
374384
let (v, delay) = extract_padding(raw);
375-
let body = try_translate(&v).unwrap_or_else(|| {
385+
let islong = noncontrol_islong(&v);
386+
let body = try_translate(&v, islong).unwrap_or_else(|| {
376387
let mut s = String::new();
377-
for &b in &v {
378-
tc_escape_byte(&mut s, b);
388+
for i in 0..v.len() {
389+
tc_escape_byte(&mut s, v[i], islong, v.get(i + 1).copied());
379390
}
380391
s
381392
});

0 commit comments

Comments
 (0)