|
| 1 | +//! Cross-generator sync between the Rust reference C emitter (`CEmitter`) and |
| 2 | +//! the self-hosted C generator (`selfhost/cgen.x`). |
| 3 | +//! |
| 4 | +//! There are two independent implementations of the same IR -> C contract: |
| 5 | +//! * `CEmitter` (Rust, in `xb-compiler`) — the reference. |
| 6 | +//! * `cgen.x` (XBasic, in `selfhost/`) — the self-hosted generator that the |
| 7 | +//! native bootstrap actually ships. |
| 8 | +//! |
| 9 | +//! Every other cgen/bootstrap test checks ONE generator against the interpreter |
| 10 | +//! or the golden output. Nothing pins the two generators to EACH OTHER, so they |
| 11 | +//! can silently drift: a codegen rule fixed in one but not the other only breaks |
| 12 | +//! a test if some corpus program happens to exercise it. These tests close that |
| 13 | +//! gap by requiring both generators to agree, program-for-program. |
| 14 | +//! |
| 15 | +//! Sync is asserted on OBSERVABLE BEHAVIOR (native run output), not on the |
| 16 | +//! emitted C text: the two generators' fixed runtime *preludes* are not yet |
| 17 | +//! byte-identical (helper ordering/formatting and a few semantic helper diffs — |
| 18 | +//! tracked in `docs/16-cgen-cemitter-sync-roadmap.md`, item CG-PRELUDE). Output |
| 19 | +//! equivalence is the contract that actually governs a correct bootstrap. |
| 20 | +
|
| 21 | +mod common; |
| 22 | + |
| 23 | +use std::fs; |
| 24 | +use std::io::Write; |
| 25 | +use std::path::{Path, PathBuf}; |
| 26 | +use std::process::{Command, Stdio}; |
| 27 | +use xb_compiler::{CEmitter, FrontendUnit, TextIrEmitter, TextIrParser}; |
| 28 | +use xb_runtime::Interpreter; |
| 29 | + |
| 30 | +fn root() -> PathBuf { |
| 31 | + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") |
| 32 | +} |
| 33 | + |
| 34 | +/// Build the native `cgen` executable from `selfhost/cgen.x` using the Rust |
| 35 | +/// `CEmitter` (this is exactly how the native bootstrap seeds its C generator). |
| 36 | +fn build_native_cgen(tmp: &Path) -> PathBuf { |
| 37 | + let cgen_src = fs::read_to_string(root().join("selfhost/cgen.x")).expect("read cgen.x"); |
| 38 | + let cgen_prog = FrontendUnit::parse(&cgen_src) |
| 39 | + .expect("parse cgen.x") |
| 40 | + .lower_ir() |
| 41 | + .expect("lower cgen.x"); |
| 42 | + let cgen_c = CEmitter::new().emit_program(&cgen_prog); |
| 43 | + let c_path = tmp.join("cgen.c"); |
| 44 | + let exe = tmp.join("cgen"); |
| 45 | + fs::write(&c_path, &cgen_c).expect("write cgen.c"); |
| 46 | + let cc = Command::new(common::cc::cc()) |
| 47 | + .args(["-o", exe.to_str().unwrap(), c_path.to_str().unwrap()]) |
| 48 | + .output() |
| 49 | + .expect("run cc for cgen"); |
| 50 | + assert!( |
| 51 | + cc.status.success(), |
| 52 | + "cc cgen failed: {}", |
| 53 | + String::from_utf8_lossy(&cc.stderr) |
| 54 | + ); |
| 55 | + exe |
| 56 | +} |
| 57 | + |
| 58 | +/// Feed text IR to the native cgen on stdin; return the emitted C source bytes. |
| 59 | +fn cgen_emit(cgen_exe: &Path, ir: &str) -> Vec<u8> { |
| 60 | + let mut child = Command::new(common::exe_path(cgen_exe)) |
| 61 | + .stdin(Stdio::piped()) |
| 62 | + .stdout(Stdio::piped()) |
| 63 | + .stderr(Stdio::piped()) |
| 64 | + .spawn() |
| 65 | + .expect("spawn cgen"); |
| 66 | + child |
| 67 | + .stdin |
| 68 | + .take() |
| 69 | + .expect("cgen stdin") |
| 70 | + .write_all(ir.as_bytes()) |
| 71 | + .expect("write IR to cgen"); |
| 72 | + let out = child.wait_with_output().expect("wait cgen"); |
| 73 | + assert!( |
| 74 | + out.status.success(), |
| 75 | + "cgen failed: {}", |
| 76 | + String::from_utf8_lossy(&out.stderr) |
| 77 | + ); |
| 78 | + out.stdout |
| 79 | +} |
| 80 | + |
| 81 | +/// Compile C source bytes to a native exe, run it with optional stdin, and |
| 82 | +/// return stdout decoded byte-faithfully (matching how the goldens were made). |
| 83 | +fn compile_and_exec(tmp: &Path, name: &str, c: &[u8], input: Option<&str>) -> String { |
| 84 | + let c_path = tmp.join(format!("{name}.c")); |
| 85 | + let exe = tmp.join(name); |
| 86 | + fs::write(&c_path, c).expect("write c"); |
| 87 | + let cc = Command::new(common::cc::cc()) |
| 88 | + .args(["-o", exe.to_str().unwrap(), c_path.to_str().unwrap()]) |
| 89 | + .output() |
| 90 | + .expect("run cc"); |
| 91 | + assert!( |
| 92 | + cc.status.success(), |
| 93 | + "cc {name} failed: {}", |
| 94 | + String::from_utf8_lossy(&cc.stderr) |
| 95 | + ); |
| 96 | + let mut cmd = Command::new(common::exe_path(&exe)); |
| 97 | + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); |
| 98 | + cmd.stdin(if input.is_some() { |
| 99 | + Stdio::piped() |
| 100 | + } else { |
| 101 | + Stdio::null() |
| 102 | + }); |
| 103 | + let mut child = cmd.spawn().expect("spawn native exe"); |
| 104 | + if let Some(inp) = input { |
| 105 | + child |
| 106 | + .stdin |
| 107 | + .take() |
| 108 | + .expect("native stdin") |
| 109 | + .write_all(inp.as_bytes()) |
| 110 | + .expect("write input"); |
| 111 | + } |
| 112 | + let out = child.wait_with_output().expect("wait native exe"); |
| 113 | + assert!( |
| 114 | + out.status.success(), |
| 115 | + "native {name} failed: {}", |
| 116 | + String::from_utf8_lossy(&out.stderr) |
| 117 | + ); |
| 118 | + out.stdout.iter().map(|&b| b as char).collect() |
| 119 | +} |
| 120 | + |
| 121 | +/// The Rust `CEmitter` and the self-hosted `cgen.x` must produce native |
| 122 | +/// executables whose output is byte-identical to each other AND to the golden |
| 123 | +/// `.out`, for every program in the positive corpus. |
| 124 | +/// |
| 125 | +/// This simultaneously adds the previously-missing coverage of `CEmitter` over |
| 126 | +/// the whole corpus (it was only ever run on one hand-written program) and the |
| 127 | +/// direct cross-generator equality that locks the two backends together. |
| 128 | +#[test] |
| 129 | +fn cemitter_and_cgen_agree_on_positive_corpus() { |
| 130 | + let tmp = std::env::temp_dir().join("xb_sync_pos_corpus"); |
| 131 | + fs::create_dir_all(&tmp).expect("mkdir"); |
| 132 | + let cgen_exe = build_native_cgen(&tmp); |
| 133 | + let corpus = root().join("fixtures/corpus/v0.1/positive"); |
| 134 | + |
| 135 | + let mut cases: Vec<String> = Vec::new(); |
| 136 | + for entry in fs::read_dir(&corpus).expect("read_dir positive corpus") { |
| 137 | + let path = entry.expect("dir entry").path(); |
| 138 | + if path.extension().and_then(|e| e.to_str()) == Some("ir") { |
| 139 | + cases.push( |
| 140 | + path.file_stem() |
| 141 | + .expect("stem") |
| 142 | + .to_str() |
| 143 | + .expect("utf8 stem") |
| 144 | + .to_string(), |
| 145 | + ); |
| 146 | + } |
| 147 | + } |
| 148 | + cases.sort(); |
| 149 | + assert!( |
| 150 | + cases.len() >= 50, |
| 151 | + "expected the full positive corpus (>=50 cases), found {}", |
| 152 | + cases.len() |
| 153 | + ); |
| 154 | + |
| 155 | + for stem in &cases { |
| 156 | + let ir = fs::read_to_string(corpus.join(format!("{stem}.ir"))).expect("read .ir"); |
| 157 | + let golden = fs::read_to_string(corpus.join(format!("{stem}.out"))).expect("read .out"); |
| 158 | + let in_path = corpus.join(format!("{stem}.in")); |
| 159 | + let input = if in_path.exists() { |
| 160 | + Some(fs::read_to_string(&in_path).expect("read .in")) |
| 161 | + } else { |
| 162 | + None |
| 163 | + }; |
| 164 | + let input_ref = input.as_deref(); |
| 165 | + |
| 166 | + // Rust CEmitter path: text IR -> IrProgram -> C -> native. |
| 167 | + let prog = TextIrParser::parse(&ir) |
| 168 | + .unwrap_or_else(|e| panic!("TextIrParser failed for {stem}: {e:?}")); |
| 169 | + let rust_c = CEmitter::new().emit_program(&prog); |
| 170 | + let rust_out = compile_and_exec(&tmp, &format!("{stem}_rust"), rust_c.as_bytes(), input_ref); |
| 171 | + |
| 172 | + // Self-hosted cgen.x path: text IR -> C (native cgen) -> native. |
| 173 | + let self_c = cgen_emit(&cgen_exe, &ir); |
| 174 | + let self_out = compile_and_exec(&tmp, &format!("{stem}_self"), &self_c, input_ref); |
| 175 | + |
| 176 | + assert_eq!( |
| 177 | + rust_out, golden, |
| 178 | + "CEmitter-built {stem} output differs from golden .out" |
| 179 | + ); |
| 180 | + assert_eq!( |
| 181 | + self_out, golden, |
| 182 | + "cgen.x-built {stem} output differs from golden .out" |
| 183 | + ); |
| 184 | + assert_eq!( |
| 185 | + rust_out, self_out, |
| 186 | + "SYNC BREAK: CEmitter and cgen.x disagree on {stem}" |
| 187 | + ); |
| 188 | + } |
| 189 | + let _ = fs::remove_dir_all(&tmp); |
| 190 | +} |
| 191 | + |
| 192 | +/// The two generators must also agree on the self-hosting toolchain itself |
| 193 | +/// (compiler, lexer, parser, cgen), and both must match the interpreter (the |
| 194 | +/// semantic reference) on each tool's own input. This is the sync that directly |
| 195 | +/// underwrites the bootstrap: whichever C backend seeds the native tools, the |
| 196 | +/// tools behave identically. |
| 197 | +#[test] |
| 198 | +fn cemitter_and_cgen_agree_on_selfhost_tools() { |
| 199 | + let tmp = std::env::temp_dir().join("xb_sync_selfhost_tools"); |
| 200 | + fs::create_dir_all(&tmp).expect("mkdir"); |
| 201 | + let cgen_exe = build_native_cgen(&tmp); |
| 202 | + |
| 203 | + for tool in ["compiler", "lexer", "parser", "cgen"] { |
| 204 | + let src = fs::read_to_string(root().join(format!("selfhost/{tool}.x"))) |
| 205 | + .unwrap_or_else(|e| panic!("read selfhost/{tool}.x: {e}")); |
| 206 | + let prog = FrontendUnit::parse(&src) |
| 207 | + .unwrap_or_else(|e| panic!("parse {tool}: {e:?}")) |
| 208 | + .lower_ir() |
| 209 | + .unwrap_or_else(|e| panic!("lower {tool}: {e:?}")); |
| 210 | + let ir = TextIrEmitter::new().emit_program(&prog); |
| 211 | + |
| 212 | + // Deterministic stdin: the tool's committed `.in` if present, else the |
| 213 | + // tool's own text IR (cgen consumes IR, so this is meaningful work). |
| 214 | + let in_path = root().join(format!("selfhost/{tool}.in")); |
| 215 | + let input = if in_path.exists() { |
| 216 | + fs::read_to_string(&in_path).expect("read .in") |
| 217 | + } else { |
| 218 | + ir.clone() |
| 219 | + }; |
| 220 | + let input_lines: Vec<String> = input.lines().map(String::from).collect(); |
| 221 | + |
| 222 | + let rust_c = CEmitter::new().emit_program(&prog); |
| 223 | + let rust_out = |
| 224 | + compile_and_exec(&tmp, &format!("{tool}_rust"), rust_c.as_bytes(), Some(&input)); |
| 225 | + |
| 226 | + let self_c = cgen_emit(&cgen_exe, &ir); |
| 227 | + let self_out = compile_and_exec(&tmp, &format!("{tool}_self"), &self_c, Some(&input)); |
| 228 | + |
| 229 | + let mut interp = Vec::new(); |
| 230 | + Interpreter::new() |
| 231 | + .execute_main_with_input(&prog, input_lines, &mut interp) |
| 232 | + .unwrap_or_else(|e| panic!("interpret {tool}: {e:?}")); |
| 233 | + let interp_out: String = interp.into_iter().map(|l| format!("{l}\n")).collect(); |
| 234 | + |
| 235 | + assert_eq!( |
| 236 | + rust_out, interp_out, |
| 237 | + "CEmitter-built {tool} differs from interpreter" |
| 238 | + ); |
| 239 | + assert_eq!( |
| 240 | + self_out, interp_out, |
| 241 | + "cgen.x-built {tool} differs from interpreter" |
| 242 | + ); |
| 243 | + assert_eq!( |
| 244 | + rust_out, self_out, |
| 245 | + "SYNC BREAK: CEmitter and cgen.x disagree on selfhost tool {tool}" |
| 246 | + ); |
| 247 | + } |
| 248 | + let _ = fs::remove_dir_all(&tmp); |
| 249 | +} |
0 commit comments