Skip to content

Commit ea2fd73

Browse files
committed
feat(diag): report unknown types and fields in Xi terms; add web payload test
Extends the Xi-level diagnostics so more mistakes are caught before they reach the C compiler and are worded against the source, not the generated C: - constructing an unknown type — `Uzer { ... }` now reports `unknown type 'Uzer' — no type, event, or sum-type variant ...` instead of a cascading C `undeclared identifier` mess. A capitalized name before `{` in expression position is only ever a constructor, so this is unambiguous. - unknown field in a constructor — `User { nam: ... }` reports `type 'User' has no field 'nam'`. - reading an unknown field — `u.aeg` reports the same. Field presence is tested by name against the declared field set (hasFieldC), not the field's resolved xtype, so a valid field whose type is a sum type (which resolves to "") is never mis-flagged — this was the one false positive found on machine_phase_demo and is now covered. All checks are gated to declared compound types and verified to have zero false positives across every example. Also adds examples/web/web_payload_test.xi: a server-free test of the web controller payload path (res.send / req.parse) — it binds JsonTransport and round-trips a DTO through WebTransport over the auto-derived JSON codecs.
1 parent b104e08 commit ea2fd73

6 files changed

Lines changed: 116 additions & 0 deletions

File tree

compiler/impl/codegen/expr.xi

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ mapper genTypeLiteral(toks: Token[], pos: Integer, ctx: GCtx) -> ExprRes {
2727
let first = true
2828
while toks.kindAt(p) != 103 and toks.kindAt(p) != 0 {
2929
let fname = toks.textAt(p)
30+
// A field name the type doesn't declare is a typo — report it in Xi terms
31+
// rather than emitting a C `field designator does not refer to any field`.
32+
if (ctx.prog).isCompoundTypeC(typeName) and not (ctx.prog).hasFieldC(typeName, fname) {
33+
diag_error(tokenArrGet(toks, p).line, "type '" + typeName + "' has no field '" + fname + "'")
34+
}
3035
p = p + 1
3136
if toks.kindAt(p) == 108 { p = p + 1 }
3237
let e = genExpr(toks, p, ctx)
@@ -520,6 +525,14 @@ mapper genPrimary(toks: Token[], pos: Integer, ctx: GCtx) -> ExprRes {
520525
diag_error(tokenArrGet(toks, pos).line, txt + "." + toks.textAt(pos + 2) + "(...) — the '" + txt + "' namespace isn't imported; add: import \"" + nsImp + "\"")
521526
}
522527
}
528+
// Constructor `Name { ... }` for a name that resolves to nothing: neither a
529+
// sum-type variant (488) nor a type/event/class (492) matched, and it's not
530+
// a local. A capitalized name before `{` in expression position is only ever
531+
// a constructor, so report the unknown type in Xi terms instead of letting
532+
// the bare name leak out as a cryptic C `undeclared identifier` cascade.
533+
if toks.kindAt(pos + 1) == 102 and txt.startsUpper() and string_len(ctx.lookupVar(txt)) == 0 {
534+
diag_error(tokenArrGet(toks, pos).line, "unknown type '" + txt + "' — no type, event, or sum-type variant with that name is declared or imported")
535+
}
523536
return ExprRes { code: txt, pos: pos + 1, xtyp: ctx.lookupVar(txt) , owned: false }
524537
}
525538
return ExprRes { code: txt, pos: pos + 1, xtyp: "" , owned: false }

compiler/impl/codegen/postfix.xi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,14 @@ mapper genPostfix(toks: Token[], pos: Integer, ctx: GCtx) -> ExprRes {
486486
typ = "Integer"
487487
} else {
488488
let ft = (ctx.prog).fieldTypeNameC(typ, fld)
489+
// Field access on a declared compound type whose field
490+
// set is fixed: a name that is not a declared field is a
491+
// typo. Report it in Xi terms instead of leaking a C
492+
// `no member named`. (Test name presence, not ft — a
493+
// valid field of sum-type resolves ft to "".)
494+
if (ctx.prog).isCompoundTypeC(typ) and not (ctx.prog).hasFieldC(typ, fld) {
495+
diag_error(tokenArrGet(toks, p + 1).line, "type '" + typ + "' has no field '" + fld + "'")
496+
}
489497
code = code + "." + fld
490498
typ = ft
491499
}

compiler/impl/codegen/program_query.xi

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,32 @@ mapper Program.fieldCheckFn(typeName: String, field: String) -> String {
245245
return ""
246246
}
247247

248+
// Does the declared compound type `typeName` have a field literally named
249+
// `field`? Tests name presence only — unlike fieldTypeNameC, it does not depend
250+
// on the field's type resolving to a non-empty xtype (a field of sum-type or
251+
// otherwise unresolvable type still counts), so it is a sound basis for the
252+
// "type has no field" diagnostic.
253+
predicate Program.hasFieldC(typeName: String, field: String) {
254+
if field == "ok" or field == "err" or field == "value" { return true }
255+
let i = 0
256+
let n = typeSpecLen(this.types)
257+
while i < n {
258+
let ts = typeSpecGet(this.types, i)
259+
if ts.name == typeName {
260+
let fi = 0
261+
let fn2 = stringArrLen(ts.fields)
262+
while fi < fn2 {
263+
let fentry = stringArrGet(ts.fields, fi)
264+
let colon = findChar(fentry, 58)
265+
if string_slice(fentry, 0, colon) == field { return true }
266+
fi = fi + 1
267+
}
268+
}
269+
i = i + 1
270+
}
271+
return false
272+
}
273+
248274
mapper Program.fieldTypeNameC(typeName: String, field: String) -> String {
249275
// Result<T> fields: .ok is Bool, .err is String, .value is T.
250276
if field == "ok" { return "Bool" }

compiler/impl/strings.xi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ predicate String.endsWith2(suffix: String) {
1818
return string_slice(this, n - sl, n) == suffix
1919
}
2020

21+
// Does the name start with an ASCII uppercase letter? Type / event / variant
22+
// names are Capitalized by convention, so this gates type-shaped diagnostics.
23+
predicate String.startsUpper() {
24+
if string_len(this) == 0 { return false }
25+
let c = string_char_at(this, 0)
26+
return c >= 65 and c <= 90
27+
}
28+
2129
// Membership test over a String[] — `names.includes(x)`. Lets a long run of
2230
// `if x == "a" { return true } ...` collapse to a single set-style query.
2331
predicate String[].includes(s: String) {

docs/internals.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,26 @@ not the transient `.gen.c` — and correctly across imports, so an error inside
113113
imported helper points at that helper. Directives are deduped and deterministic,
114114
so the self-hosting fixpoint is unaffected.
115115

116+
### Semantic diagnostics (Xi errors, not C errors)
117+
118+
Beyond `#line`, codegen catches the most common mistakes *before* they reach the
119+
C compiler and reports them in Xi terms at the source line. Each check is placed
120+
where a name legitimately fails to resolve and is gated so it never fires on
121+
valid code (verified: zero false positives across every example):
122+
123+
| Mistake | Message |
124+
| --- | --- |
125+
| call to an un-imported std function | `int_to_string(...) is defined in std/convert.xi — add: import "std/convert.xi"` |
126+
| use of an un-imported std namespace | `json.foo(...) — the 'json' namespace isn't imported; add: import "std/json.xi"` |
127+
| constructing an unknown type | `unknown type 'Uzer' — no type, event, or sum-type variant with that name is declared or imported` |
128+
| unknown field in a constructor | `type 'User' has no field 'nam'` |
129+
| reading an unknown field | `type 'User' has no field 'aeg'` |
130+
131+
The field checks test name presence against the type's declared field set
132+
(`hasFieldC`), not the field's resolved type, so a valid field of sum-type is
133+
never mis-flagged. Anything not caught here still compiles to C and, if it
134+
fails, is reported against the Xi source via `#line`.
135+
116136
## What runs at runtime
117137

118138
DI resolution, overload dispatch tables, and refined-type layout are decided at

examples/web/web_payload_test.xi

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Feature: web controller payload (de)serialization.
2+
//
3+
// A controller replies with `res.send(dto)` and reads a typed body with
4+
// `req.parse(T)`. Both route the payload through the DI-resolved `WebTransport`
5+
// over the compiler's auto-derived JSON codecs (`dto as Json` / `json as T`).
6+
// This test exercises that exact path without standing up a server: bind the
7+
// default `JsonTransport`, then serialize a DTO to the wire and parse it back.
8+
import "std/web.xi"
9+
10+
// The kind of payload a controller sends back (res.send) ...
11+
event Created = { id: Integer, name: String, active: Bool }
12+
// ... and the kind it accepts in a request body (req.parse).
13+
type NewUser = { name: String, age: Integer }
14+
15+
module WebPayload { bind WebTransport -> JsonTransport as singleton }
16+
17+
test "serialize a controller response payload (res.send path)" {
18+
let tx = WebPayload.resolve(WebTransport)
19+
let dto = Created { id: 7, name: "John Doe", active: true }
20+
// res.send(dto) === transport.serialize(dto as Json)
21+
let wire = tx.serialize(dto as Json)
22+
assertEq(wire, "{\"id\":7,\"name\":\"John Doe\",\"active\":true}")
23+
}
24+
25+
test "deserialize a request body payload (req.parse path)" {
26+
let tx = WebPayload.resolve(WebTransport)
27+
let body = "{\"name\":\"John Doe\",\"age\":44}"
28+
// req.parse(NewUser) === transport.deserialize(body) as NewUser
29+
let u = (tx.deserialize(body)) as NewUser
30+
assertEq(u.name, "John Doe")
31+
assertEq(u.age, 44)
32+
}
33+
34+
test "round-trip a payload through the transport" {
35+
let tx = WebPayload.resolve(WebTransport)
36+
let sent = Created { id: 3, name: "Ada", active: false }
37+
let back = (tx.deserialize(tx.serialize(sent as Json))) as Created
38+
assertEq(back.id, 3)
39+
assertEq(back.name, "Ada")
40+
assertEq(back.active, false)
41+
}

0 commit comments

Comments
 (0)