dev: compile the generated C, and fix what that caught

Thirty-one cases only ever emitted C and never built it -- m5 owned/defer, and
every ok* acceptance fixture in m6 and m7. They asserted that fec produced a
file, not that the file was a program, so a malformed emission sat unnoticed.
Compile each one with wcl386 -c. Fixtures without a main cannot be run, so this
is the floor for them; it is a conformance check on the backend's output, not an
assertion about C, so a future backend swaps the command rather than the intent.

It found two defects in the M6 emitter immediately, both predating M7:

&s[0] took the address of an rvalue. Borrowing went through emit_expr, which
lowers an index to the bounds-checking accessor, so `&s[0]` became
`&fe_idx_slice_type_2(s, 0)`. emit_lvalue spells the same element as `s.p[0]`.
The M7 emitter already did this correctly; only the M6 path was wrong.

line.trim() was never lowered. SPEC lists trim among the built-in alias methods
on str and the checker accepts it, but no emitter case existed, so it emitted
`fe_l_line_0.trim()` -- a member access on a slice struct. Emit the helper, and
only for programs that actually trim.

Also set core=dynamic and cycles=max. core=auto uses the interpreter in real
mode, which is where the 16-bit compiler build spends its time; nothing here is
timing sensitive. m6 drops from 31s to 17s. Raise the DOSBox timeout to match a
whole-suite run, which now pays a process spawn per compile check.

Verified: --only m6, 57 passed.
This commit is contained in:
2026-08-17 01:14:47 +09:00
parent 23079ba9fb
commit aaf31302d5
3 changed files with 90 additions and 11 deletions
+39 -1
View File
@@ -199,6 +199,22 @@ static void emit_drop_helpers(FeEmitter *e)
}
}
/* SPEC 12.3 lists `trim` among the built-in alias methods on `str`, called as
`line.trim()`. The checker accepts it; this emits the lowering. Only the
helper for slice types actually reached by a trim call is emitted, so a
program that never trims does not carry it. */
static int node_uses_trim(FeNode *n)
{
FeNode *x;
if (!n) return 0;
if (n->kind==FE_N_CALL && n->a && n->a->kind==FE_N_MEMBER &&
n->a->b && n->a->b->text && strcmp(n->a->b->text,"trim")==0) return 1;
if (node_uses_trim(n->a) || node_uses_trim(n->b) || node_uses_trim(n->c))
return 1;
for (x=n->children; x; x=x->next) if (node_uses_trim(x)) return 1;
return 0;
}
static void emit_type_helpers(FeEmitter *e)
{
FeType *t;
@@ -278,6 +294,14 @@ static void emit_type_helpers(FeEmitter *e)
fprintf(e->out,"return %s(x.p+a,b-a); }\n",t->maker);
fprintf(e->out,"static %s %s(%s x) { return %s(x,0,x.n); }\n",t->cname,t->full_slicer,t->cname,t->slicer);
fprintf(e->out,"static %s %s(%s x, unsigned long a) { return %s(x,a,x.n); }\n",t->cname,t->tail_slicer,t->cname,t->slicer);
if (node_uses_trim(e->check->ast->root) && !t->ref_mut) {
fprintf(e->out,
"static %s fe_trim_%s(%s s) { unsigned long a=0; unsigned long b=s.n;"
" while (a<b && (s.p[a]==' '||s.p[a]=='\\t'||s.p[a]=='\\r'||s.p[a]=='\\n')) ++a;"
" while (b>a && (s.p[b-1]==' '||s.p[b-1]=='\\t'||s.p[b-1]=='\\r'||s.p[b-1]=='\\n')) --b;"
" return %s(s.p+a,b-a); }\n",
t->cname,t->cname,t->cname,t->maker);
}
}
}
}
@@ -812,7 +836,12 @@ static void emit_expr(FeEmitter *e, FeNode *n)
fputc('(', e->out);
fputs(strcmp(op,"&mut")==0 ? "&" : op, e->out);
}
emit_expr(e, n->a);
/* Borrowing needs a place, not a value. emit_expr lowers an index to
the bounds-checking accessor, and the address of that call is not an
lvalue -- `&s[0]` became `&fe_idx_slice_type_2(s, 0)`, which C
rejects. emit_lvalue spells the same element as `s.p[0]`. */
if (strcmp(op,"&")==0 || strcmp(op,"&mut")==0) emit_lvalue(e, n->a);
else emit_expr(e, n->a);
fputc(')', e->out);
break;
case FE_N_BINARY:
@@ -871,6 +900,15 @@ static void emit_expr(FeEmitter *e, FeNode *n)
fputc('(',e->out); emit_expr(e,n->children->next); fputc(')',e->out);
special=1;
}
else if(n->a && n->a->kind==FE_N_MEMBER && n->a->b && n->a->b->text &&
strcmp(n->a->b->text,"trim")==0 && !n->children &&
n->a->a && n->a->a->sem_type &&
n->a->a->sem_type->kind==FE_TYPE_SLICE &&
n->a->a->sem_type->cname) {
fputs("fe_trim_",e->out); fputs(n->a->a->sem_type->cname,e->out);
fputc('(',e->out); emit_expr(e,n->a->a); fputc(')',e->out);
special=1;
}
else if(n->a && n->a->kind==FE_N_MEMBER && n->a->a &&
n->a->a->kind==FE_N_IDENT && n->a->a->text &&
strcmp(n->a->a->text,"mem")==0 && n->a->b &&
+11 -2
View File
@@ -262,7 +262,13 @@ def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False,
console = run_root / "CONSOLE.LOG"
config = run_root / "DOSBOX.CON"
config.write_text(
f"[log]\nlogfile={console}\n[dosbox]\nlog console=quiet\n",
# core=auto falls back to the interpreter in real mode, which is
# where the 16-bit compiler build spends its time. Nothing here is
# timing sensitive -- it is a compiler and a batch file -- so ask for
# the recompiler and uncapped cycles explicitly.
f"[log]\nlogfile={console}\n"
f"[dosbox]\nlog console=quiet\n"
f"[cpu]\ncore=dynamic\ncycles=max\n",
encoding="ascii",
)
(fec / "RUN.BAT").write_text(
@@ -279,7 +285,10 @@ def run_suite(cases: list[Case], *, keep: bool = False, show_dos: bool = False,
"-c", f'mount W "{watcom}" -ro',
"-c", "C:", "-c", "cd \\FEC", "-c", "RUN.BAT",
])
completed = subprocess.run(command, check=False, timeout=300)
# Every case pays a DOS process spawn, and the compile-only checks spawn
# wcc386 once each, so the whole-suite run is minutes rather than the
# under-a-minute a single milestone takes.
completed = subprocess.run(command, check=False, timeout=1800)
if completed.returncode != 0:
raise DosboxError(f"DOSBox-X exited with status {completed.returncode}")
if not (fec / "RUN.OK").is_file():
+40 -8
View File
@@ -74,6 +74,41 @@ def _wcl(exe: str, *sources: str, bits: int = 32, strict: bool = False,
return " ".join(parts)
def _wcc(source: str, obj: str) -> str:
"""Compile the generated C without linking.
A fixture with no ``main`` cannot be run, so this is the floor for it: the
backend's output has to survive the compiler the project actually ships
with. It catches a malformed emission -- an unnamed assignment target, a
helper that is called but never defined, an initializer C89 rejects -- which
otherwise sits unnoticed in a case that only ever emitted text.
This asserts nothing about the C itself; it is a conformance check on the
backend's output, so a future non-C backend swaps the command rather than
the intent. Never grep the generated C to prove a language feature -- write a
fixture whose exit code differs instead, as TESTS\\M3\\NOCHK.FE does.
"""
# Through the wcl386 driver with -c rather than calling wcc386 directly:
# wcc386 writes its diagnostics to stderr, which COMMAND.COM cannot
# redirect, so a failure would report a count and no messages. The driver
# leaves an .ERR file, which the runner already collects.
return f"WCL386 -q -za -wx -wcd=202 -bt=dos -c -fo={obj} {source}"
def _accepts(milestone: int, directory: str, names: tuple[str, ...], *,
output: str = OUT, output_first: bool = True) -> list[Case]:
"""Fixtures that must compile: emit the C, then build it."""
cases: list[Case] = []
for name in names:
cfile = f"{output}\\{name.upper()}.C"
cases.append(_case(milestone, name,
_emit(_fe(directory, name), cfile,
output_first=output_first)))
cases.append(_case(milestone, f"{name}-cc",
_wcc(cfile, f"{OUT}\\{name.upper()}.OBJ")))
return cases
def _dump_ast(milestone: int, directory: str, names: tuple[str, ...], *,
suffix: str, ok: bool = True, prefix: str = "") -> list[Case]:
return [
@@ -171,8 +206,7 @@ CASES: list[Case] = [
"bad-writ", "bad-bufw", "bad-many", "bad-open", "bad-cls")),
# -- M5: defer and ownership ----------------------------------------------
_case(5, "defer", _emit(_fe(M5, "defer"), f"{M5}\\DEFER.C")),
_case(5, "owned", _emit(_fe(M5, "owned"), f"{M5}\\OWNED.C")),
*_accepts(5, M5, ("defer", "owned"), output=M5, output_first=False),
*_rejects(5, M5, ("bad-move", "bad-dest", "bad-drop", "bad-dbl", "bad-cond",
"bad-proj", "bad-clos", "bad-loop")),
# The runtime case links the generated C against a hand-written allocator
@@ -189,22 +223,20 @@ CASES: list[Case] = [
"badinv", "badlocsl", "badloop", "badmove", "badmut", "badmut2", "badptr",
"badret", "badrfld", "badridx", "badscop", "badself", "badshwr", "badslfld",
"badtwo", "badup", "badweak")],
*[_case(6, name, _emit(_fe(M6, name), f"{OUT}\\{name.upper()}.C",
output_first=True)) for name in (
*_accepts(6, M6, (
"okbranch", "okdefer", "okglobcp", "oklast", "okr8free", "okr8join",
"okr8meth", "okr8stat", "okrebor", "okrtlast", "okshare", "okslreb",
"okstatic", "oktemp", "oktrim", "okwcall")],
"okstatic", "oktemp", "oktrim", "okwcall")),
# -- M7: optionals and error unions ---------------------------------------
*[_case(7, name, f"FEC.EXE --check {_fe(M7, name)}", False) for name in (
"badcatch", "baddef", "baddir", "badercod", "badernam", "badetype",
"badnull", "badoref", "badorel", "badproj", "badqmark", "badret",
"badsome", "badtry", "badzero")],
*[_case(7, name, _emit(_fe(M7, name), f"{OUT}\\{name.upper()}.C",
output_first=True)) for name in (
*_accepts(7, M7, (
"okcatch", "okcatmov", "okcvoid", "okdeflt", "okiflet", "okmatch",
"oknull", "okorelse", "okpatvw", "okproj", "okrepl", "oktrdef",
"oktry")],
"oktry")),
]
MAX_MILESTONE: int = max(case.milestone for case in CASES)