Skip to content

Commit b13827e

Browse files
committed
feat(rust): native printable-binary raw codec crate (byte-identical to Zig, faster)
A native Rust implementation for the Rust-GUI <-> Zig-core transport. build.rs codegens the byte<->glyph map and O(1) decode tables (decode_1byte / decode_2byte payload-indexed / sorted 3-byte binary-search, ported from the C core) straight from the single-source character_map.txt, so output is byte-identical to every other impl. lib.rs has encode/decode plus zero-allocation encode_into/decode_into (reuse a caller-owned buffer; no per-message malloc). Cross-verified vs the Zig CLI (encode bytes match exactly; Rust decodes Zig-encoded streams) and benchmarked ~1.7-1.85x faster than Zig on 10MB. Minimal stdin/stdout CLI for cross-impl tests. Flake/CI wiring and the full CLI surface are deliberately deferred (specifics TBD).
1 parent c106bb9 commit b13827e

7 files changed

Lines changed: 228 additions & 0 deletions

File tree

.dirtree-state

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ ver=1.2
33
annotate=[
44
PLAN.md = Issue #1 plan: decode workflow + printable-binary-file.json container
55
docs/plans/2026-06-26-printable-binary-file-container-design.md = Design spec: printable-binary-file.json container + web decode workflow (issue #1)
6+
rust/build.rs = Codegens byte<->glyph map + O(1) decode tables from character_map.txt (single source)
7+
rust/src/lib.rs = Rust printable-binary core: raw encode/decode + zero-alloc *_into (byte-identical to Zig, ~1.8x faster)
8+
rust/src/main.rs = Minimal Rust CLI (encode default, -d decode; stdin->stdout) for cross-impl verification
69
src/container_json.h = Shared pure transport-resistant flat-JSON helpers for the .pbf.json container (C FFI + standalone C)
710
src/zig/ffi.zig = C ABI (FFI) export surface: all 12 pb_* C functions; root of libprintable_binary.a; keeps C symbols OUT of the importable printable_binary module so static (musl) consumers don't collide
811
test/module_consumer.zig = Test fixture: minimal downstream importer of the printable_binary Zig module (mirrors how difz/blip consume it) for the FFI-symbol-leak test

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,6 @@ jj_cheatsheet.md
4545

4646
# Inter-LLM coordination notes (LLMsend) — transient/local, never tracked
4747
inbox/
48+
49+
# Rust build artifacts
50+
/rust/target/

rust/Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "printable_binary"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "PrintableBinary raw codec (Rust) — byte<->printable-UTF-8, for a GUI<->core transport"
6+
license = "MIT"
7+
8+
[lib]
9+
path = "src/lib.rs"
10+
11+
[profile.release]
12+
lto = true
13+
codegen-units = 1
14+
panic = "abort"
15+
16+
[[bin]]
17+
name = "printable-binary-rs"
18+
path = "src/main.rs"

rust/build.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Codegen the byte<->glyph map + O(1) decode tables from the single-source
2+
// character_map.txt (same parse rules as every other implementation), so the
3+
// Rust impl produces byte-identical output and stays in lockstep with the map.
4+
use std::{env, fs, path::Path};
5+
6+
fn main() {
7+
let manifest = env::var("CARGO_MANIFEST_DIR").unwrap();
8+
let map_path = Path::new(&manifest).join("..").join("character_map.txt");
9+
println!("cargo:rerun-if-changed={}", map_path.display());
10+
let text = fs::read_to_string(&map_path).expect("read ../character_map.txt");
11+
12+
// One glyph per line in byte order; `##` = full-line comment; first
13+
// whitespace-delimited token is the glyph; blank lines skipped.
14+
let mut glyphs: Vec<&str> = Vec::new();
15+
for line in text.lines() {
16+
let line = line.trim_end_matches('\r');
17+
if line.is_empty() || line.starts_with("##") {
18+
continue;
19+
}
20+
match line.split_whitespace().next() {
21+
Some(tok) if !tok.is_empty() => glyphs.push(tok),
22+
_ => {}
23+
}
24+
}
25+
assert_eq!(glyphs.len(), 256, "expected 256 glyphs, got {}", glyphs.len());
26+
27+
let mut map_data: Vec<u8> = Vec::new();
28+
let mut encode: Vec<(usize, usize)> = Vec::new();
29+
let mut decode_1 = [-1i16; 256];
30+
let mut decode_2 = [[-1i16; 64]; 32];
31+
let mut decode_3: Vec<(u32, u8)> = Vec::new();
32+
33+
for (byte, g) in glyphs.iter().enumerate() {
34+
let b = g.as_bytes();
35+
encode.push((map_data.len(), b.len()));
36+
map_data.extend_from_slice(b);
37+
match b.len() {
38+
1 => decode_1[b[0] as usize] = byte as i16,
39+
2 => decode_2[(b[0] & 0x1F) as usize][(b[1] & 0x3F) as usize] = byte as i16,
40+
3 => {
41+
let key = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
42+
decode_3.push((key, byte as u8));
43+
}
44+
n => panic!("byte {byte}: glyph is {n} bytes (expected 1-3, BMP only)"),
45+
}
46+
}
47+
decode_3.sort_by_key(|&(k, _)| k);
48+
49+
let mut s = String::new();
50+
s.push_str(&format!("pub static MAP_DATA: [u8; {}] = {:?};\n", map_data.len(), map_data));
51+
s.push_str("pub static ENCODE: [(u16, u8); 256] = [");
52+
for (o, l) in &encode { s.push_str(&format!("({o},{l}),")); }
53+
s.push_str("];\n");
54+
s.push_str(&format!("pub static DECODE_1: [i16; 256] = {:?};\n", &decode_1[..]));
55+
s.push_str("pub static DECODE_2: [[i16; 64]; 32] = [");
56+
for row in &decode_2 { s.push_str(&format!("{:?},", &row[..])); }
57+
s.push_str("];\n");
58+
s.push_str(&format!("pub static DECODE_3: [(u32, u8); {}] = [", decode_3.len()));
59+
for (k, b) in &decode_3 { s.push_str(&format!("({k},{b}),")); }
60+
s.push_str("];\n");
61+
62+
fs::write(Path::new(&env::var("OUT_DIR").unwrap()).join("map.rs"), s).unwrap();
63+
}

rust/src/lib.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//! PrintableBinary — Rust implementation of the raw byte<->printable-UTF-8 codec.
2+
//!
3+
//! Produces byte-identical output to the Zig/C/Lua/JS implementations (same
4+
//! `character_map.txt`). Built for a Rust-GUI <-> Zig-core transport: the
5+
//! `*_into` variants reuse a caller-owned buffer for zero per-message allocation.
6+
include!(concat!(env!("OUT_DIR"), "/map.rs"));
7+
8+
/// Worst-case encoded length: every byte maps to at most a 3-byte glyph.
9+
#[inline]
10+
pub fn encode_bound(len: usize) -> usize {
11+
len * 3
12+
}
13+
14+
/// Encode raw bytes to printable-binary UTF-8 (allocating).
15+
pub fn encode(input: &[u8]) -> Vec<u8> {
16+
let mut out = Vec::with_capacity(encode_bound(input.len()));
17+
encode_into(input, &mut out);
18+
out
19+
}
20+
21+
/// Encode into a caller-owned buffer (cleared, then filled). Reuses the buffer's
22+
/// allocation across calls — zero allocations per message after warm-up.
23+
pub fn encode_into(input: &[u8], out: &mut Vec<u8>) {
24+
out.clear();
25+
out.reserve(encode_bound(input.len()));
26+
for &b in input {
27+
let (off, len) = ENCODE[b as usize];
28+
let (off, len) = (off as usize, len as usize);
29+
out.extend_from_slice(&MAP_DATA[off..off + len]);
30+
}
31+
}
32+
33+
#[inline]
34+
fn utf8_len(b: u8) -> usize {
35+
if b < 0x80 { 1 } else if b < 0xE0 { 2 } else if b < 0xF0 { 3 } else { 4 }
36+
}
37+
38+
/// Decode printable-binary UTF-8 back to raw bytes (allocating). Unrecognized
39+
/// sequences pass through unchanged, matching the other implementations.
40+
pub fn decode(input: &[u8]) -> Vec<u8> {
41+
let mut out = Vec::with_capacity(input.len());
42+
decode_into(input, &mut out);
43+
out
44+
}
45+
46+
/// Decode into a caller-owned buffer (cleared, then filled).
47+
pub fn decode_into(input: &[u8], out: &mut Vec<u8>) {
48+
out.clear();
49+
let mut i = 0;
50+
while i < input.len() {
51+
let b0 = input[i];
52+
let mut len = utf8_len(b0);
53+
if i + len > input.len() {
54+
len = input.len() - i;
55+
}
56+
let mut matched = false;
57+
match len {
58+
1 => {
59+
let v = DECODE_1[b0 as usize];
60+
if v >= 0 {
61+
out.push(v as u8);
62+
i += 1;
63+
matched = true;
64+
}
65+
}
66+
2 => {
67+
let v = DECODE_2[(b0 & 0x1F) as usize][(input[i + 1] & 0x3F) as usize];
68+
if v >= 0 {
69+
out.push(v as u8);
70+
i += 2;
71+
matched = true;
72+
}
73+
}
74+
3 => {
75+
let key = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8) | input[i + 2] as u32;
76+
if let Ok(idx) = DECODE_3.binary_search_by_key(&key, |&(k, _)| k) {
77+
out.push(DECODE_3[idx].1);
78+
i += 3;
79+
matched = true;
80+
}
81+
}
82+
_ => {}
83+
}
84+
if !matched {
85+
out.extend_from_slice(&input[i..i + len]);
86+
i += len;
87+
}
88+
}
89+
}
90+
91+
#[cfg(test)]
92+
mod tests {
93+
use super::*;
94+
95+
#[test]
96+
fn round_trip_all_256_single_bytes() {
97+
for b in 0u16..256 {
98+
let b = b as u8;
99+
let dec = decode(&encode(&[b]));
100+
assert_eq!(dec, vec![b], "byte {b:#04x} did not round-trip");
101+
}
102+
}
103+
104+
#[test]
105+
fn round_trip_mixed_buffer() {
106+
let mut data: Vec<u8> = (0u16..256).map(|x| x as u8).collect();
107+
data.extend_from_slice(b"Hello, World!\x00\xff\n\t");
108+
assert_eq!(decode(&encode(&data)), data);
109+
}
110+
111+
#[test]
112+
fn zero_alloc_into_matches_allocating() {
113+
let data = b"transport \x00\x01\xfe\xff frame";
114+
let mut ebuf = Vec::new();
115+
encode_into(data, &mut ebuf);
116+
assert_eq!(ebuf, encode(data));
117+
let mut dbuf = Vec::new();
118+
decode_into(&ebuf, &mut dbuf);
119+
assert_eq!(dbuf, data.to_vec());
120+
}
121+
}

rust/src/main.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Minimal CLI for cross-impl verification + the transport smoke path.
2+
// (Provisional surface — the full CLI is a "specifics" decision still pending.)
3+
// Default: encode stdin->stdout. `-d`/`--decode`: decode stdin->stdout.
4+
use printable_binary::{decode, encode};
5+
use std::io::{Read, Write};
6+
7+
fn main() {
8+
let decode_mode = std::env::args().skip(1).any(|a| a == "-d" || a == "--decode");
9+
let mut input = Vec::new();
10+
std::io::stdin().read_to_end(&mut input).expect("read stdin");
11+
let out = if decode_mode { decode(&input) } else { encode(&input) };
12+
std::io::stdout().write_all(&out).expect("write stdout");
13+
}

0 commit comments

Comments
 (0)