Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions bootstrap/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18523,6 +18523,37 @@ impl CCodegen {
}
}
};
// Every position that can now NAME the struct must also be able
// to cause it to be emitted: parameters, struct fields and locals
// joined the return type when `param_type_to_c` learned tuples.
fn walk_locals(
nodes: &[Node],
out: &mut Vec<String>,
) {
for n in nodes {
if n.kind == NodeKind::StmtLocal && !n.extra_type.is_empty() {
out.push(n.extra_type.clone());
}
walk_locals(&n.children, out);
}
}
let mut extra: Vec<String> = Vec::new();
for st in &structs {
for field in &st.children {
if !field.extra_type.is_empty() {
extra.push(field.extra_type.clone());
}
}
}
for f in &functions {
for (_, ptype) in &f.params {
extra.push(ptype.clone());
}
walk_locals(&f.children, &mut extra);
}
for ty in &extra {
consider(ty, &mut seen, &mut typedefs);
}
for f in &functions {
consider(&f.extra_return_type, &mut seen, &mut typedefs);
for stmt in &f.children {
Expand Down Expand Up @@ -20320,6 +20351,25 @@ impl CCodegen {
}

fn param_type_to_c(ty: &str) -> String {
// A TUPLE, in any position. The hoisted `t27_tuple_*` struct already
// existed and was consulted by `c_return_type_r` alone, so a tuple in a
// parameter, a struct field or a local reached C as the t27 text:
//
// int32_t probe(H h, (u8, i32) t);
// struct H { (u8, i32) f; };
//
// neither of which is C. Both halves were missing and only one was
// obvious: the use sites did not consult this, AND the typedef
// collection considered only a return type and a destructured call's
// return type. Naming the struct without emitting it is WORSE than the
// t27 text -- `unknown type name 't27_tuple_uint8_t_int32_t'` -- which
// is what the first attempt produced.
//
// Corpus population is ZERO: no spec puts a tuple in these positions
// today, and that is said here rather than left implied.
if let Some((name, _)) = Self::c_tuple_info(ty) {
return name;
}
// A dotted foreign type (`std.mem.Allocator`) has no C spelling at all.
// It reached the header as `std.mem.Allocator x;`, which is not C;
// `void*` is the honest lowering and what a hand-written binding uses.
Expand Down
2 changes: 1 addition & 1 deletion bootstrap/stage0/FROZEN_HASH
Original file line number Diff line number Diff line change
@@ -1 +1 @@
cd001774cfbbc0c8f024a7e3c746fa820a95c3d0b31a7eecb0a4c7412099d273 bootstrap/src/compiler.rs
92f44987b4d6a21aac752dc2bf1b1ebc28f6b5ba695a16cf560258064975d0b4 bootstrap/src/compiler.rs
151 changes: 151 additions & 0 deletions bootstrap/tests/c_tuple_positions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//! A tuple must reach C as its hoisted struct in EVERY position, not only as a
//! return type.
//!
//! The `t27_tuple_*` typedef already existed and `c_return_type_r` was the only
//! caller, so a tuple in a parameter, a struct field or a local reached C as
//! the t27 text:
//!
//! int32_t probe(H h, (u8, i32) t);
//! struct H { (u8, i32) f; };
//! (u8, i32) x;
//!
//! none of which is C.
//!
//! BOTH HALVES WERE MISSING and only one was obvious. Teaching the use sites to
//! name the struct, without also teaching the typedef collection to emit it,
//! produced `unknown type name 't27_tuple_uint8_t_int32_t'` -- WORSE than the
//! t27 text, because it looks right. That is why every test here hands the
//! header to `cc` instead of matching on the type name.
//!
//! Corpus population is ZERO: no spec puts a tuple in these positions today.
//! Measured, and the change alters not one generated file.

use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};

static N: AtomicUsize = AtomicUsize::new(0);

fn cc_present() -> bool {
Command::new("cc").arg("--version").output().map(|o| o.status.success()).unwrap_or(false)
}

fn gen_c(spec: &str, tag: &str) -> (String, std::path::PathBuf) {
let d = std::env::temp_dir().join(format!(
"t27c-ctuple-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("dir");
let p = d.join("in.t27");
std::fs::write(&p, spec).expect("write");
let out = Command::new(env!("CARGO_BIN_EXE_t27c"))
.arg("gen-c")
.arg(&p)
.output()
.expect("run t27c");
assert!(out.status.success(), "gen-c failed: {}", String::from_utf8_lossy(&out.stderr));
(String::from_utf8_lossy(&out.stdout).to_string(), d)
}

/// Compile the header and return cc's stderr.
fn compile(h: &str, d: &std::path::Path) -> String {
let p = d.join("h.h");
std::fs::write(&p, h).expect("write");
let out = Command::new("cc")
.args(["-std=c11", "-Wall", "-Wextra", "-Wno-unused-parameter",
"-ferror-limit=0", "-fsyntax-only", "-x", "c"])
.arg(&p)
.output()
.expect("cc");
String::from_utf8_lossy(&out.stderr).to_string()
}

const ALL_THREE: &str = r#"
module P {
struct H { f : (u8, i32), g : i32, }
fn probe(h: H, t: (u8, i32)) -> i32 { var x : (u8, i32); return 0; }
}
"#;

#[test]
fn a_tuple_in_every_position_compiles() {
// The assertion the first attempt would have failed. Naming the hoisted
// struct is not enough; it has to exist.
if !cc_present() {
eprintln!("SKIP a_tuple_in_every_position_compiles: no cc on PATH");
return;
}
let (h, d) = gen_c(ALL_THREE, "all");
let e = compile(&h, &d);
assert!(
!e.contains("error"),
"a tuple in a field, a parameter and a local must all compile:\n{e}"
);
}

#[test]
fn the_hoisted_struct_is_both_named_and_defined() {
let (h, _d) = gen_c(ALL_THREE, "typedef");
assert!(
h.contains("} t27_tuple_uint8_t_int32_t;"),
"the typedef must be emitted:\n{h}"
);
for spot in ["t27_tuple_uint8_t_int32_t f;", "t27_tuple_uint8_t_int32_t t)", "t27_tuple_uint8_t_int32_t x;"] {
assert!(h.contains(spot), "expected `{spot}` in:\n{h}");
}
assert!(
!h.contains("(u8, i32)"),
"and no t27 tuple text may survive into C:\n{h}"
);
}

#[test]
fn a_tuple_return_still_works() {
// The position that already worked. A change to the shared path could
// have taken it away without any other test noticing.
let (h, d) = gen_c(
"module P {\n fn probe(v: i32) -> (u8, i32) { return (1, 2); }\n}\n",
"ret",
);
assert!(
h.contains("t27_tuple_uint8_t_int32_t probe("),
"the return position keeps its hoisted struct:\n{h}"
);
if cc_present() {
let e = compile(&h, &d);
assert!(!e.contains("error"), "and still compiles:\n{e}");
}
}

#[test]
fn a_non_tuple_parenthesised_type_is_untouched() {
// The negative control. `c_tuple_info` requires a comma, and a detector
// that fired on any parenthesised type would rewrite things it must not.
let (h, _d) = gen_c(
"module P {\n struct Pair { a : i32, b : i32, }\n fn probe(p: Pair) -> i32 { return p.a; }\n}\n",
"plain",
);
assert!(h.contains("int32_t probe(Pair p)"), "a plain struct parameter is unchanged:\n{h}");
assert!(!h.contains("t27_tuple"), "and no tuple struct is invented:\n{h}");
}

#[test]
fn a_tuple_that_appears_ONLY_as_a_local_is_still_hoisted() {
// Each position needs its own fixture. `ALL_THREE` uses one tuple type in
// three places, so the typedef is collected from any one of them -- and a
// mutant dropping LOCALS from the collection passed every other test here.
// The subject has to be alone in the position under test.
let (h, d) = gen_c(
"module P {\n fn probe(v: i32) -> i32 { var x : (u16, bool); return 0; }\n}\n",
"localonly",
);
assert!(
h.contains("} t27_tuple_uint16_t_bool;"),
"a tuple used only as a local must still hoist its struct:\n{h}"
);
if cc_present() {
let e = compile(&h, &d);
assert!(!e.contains("error"), "and the header must compile:\n{e}");
}
}
11 changes: 11 additions & 0 deletions docs/now/2026-09-08-naming-a-struct-without-emitting-it.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# NOW -- Naming a struct without emitting it (2026-09-08)

## Naming a struct without emitting it (Closes #3455)

- Fifth round of the parity table, the cross product of combinations against all four positions. **Every finding was latent**: three real defects, all with a corpus population of **zero**. Closest to a dry round yet, and not one -- the stopping rule wants two consecutive rounds finding nothing, and this found three things.
- The `t27_tuple_*` hoisted struct existed and `c_return_type_r` was its ONLY caller, so a tuple in a parameter, a field or a local reached C as the t27 text -- `struct H { (u8, i32) f; };`, which is not C.
- **Both halves were missing and only one was obvious.** Teaching the use sites to name the struct, without teaching the typedef collection to emit it, produced `unknown type name 't27_tuple_uint8_t_int32_t'` -- **worse** than the t27 text, because it looks right. That was my first attempt, and it is why every test here hands the header to `cc` rather than matching on the type name. The collection considered a return type and a destructured call's return type; parameters, fields and locals were absent.
- Population zero, and measured rather than asserted: the change alters **not one generated file** across 651 specs, errors 15126 before and after. Said up front so nobody reads it as a fire.
- **A mutant survived because one fixture covered three positions at once.** Dropping LOCALS from the collection changed nothing any test caught -- the fixture used one tuple type as a parameter, a field AND a local, so the typedef was still collected from the other two. Each position needs its subject alone in it; the added test uses a tuple that appears only as a local, and the mutant dies.
- Two further defects from the round are filed rather than fixed, both population zero: a tuple whose element is a fixed array gives `uint8_t* f0` -- the #3446 defect inside a tuple struct -- and `[]*i32` reaches C as `*i32* x` in every position.
- Three instrument failures in one pass, all mine: `grep` read a leading `->` as a flag; a bracket class with escaped square brackets matched nothing, and its **control failed**, which is the only reason the zero was not published; and a matcher for tuple parameters caught `Map(K, V)` again, the same shape collision as the previous pass.
Loading