feat(lang): L11 views and templates - the UI is markup, not code

A `view Name { field = x; function q(..); on e(..) }` declaration is the one bridge between a
program and its UI: it writes view_<name>() -> UiView, whose model is a Value of every field and
whose call runs a query or an event by name.

ludic.ui is a template runtime:
- HTML-shaped XML screens and components, loaded at run time;
- {expression} bindings, if/else/each, props, slots, per-instance state;
- on-press / onclick actions (event, set, emit);
- component libraries (export="true", <import src as>).

Styling:
- stylesheets in <style> or importable .lss files (@import);
- CSS selectors (#id, .class, [attr=v], descendant and > combinators, :hover, :disabled,
  :first-child, :last-child, :nth-child, :not) weighed by specificity;
- the box model and flex under CSS's property names.

Also:
- default parameters, and positional-then-named calls;
- Value gains a float kind;
- a function shadowing a runtime one is refused;
- an index is evaluated before the slice is read;
- runtime errors name the right file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 15:58:47 +03:00
parent 334469ef61
commit 66a2bc2214
64 changed files with 74806 additions and 63975 deletions

View file

@ -50,13 +50,16 @@ function emit_index_addr(e: Node) -> pointer {
return r
}
let el = slice_elem(base.ty)
# the index is evaluated BEFORE the elements' address is read: evaluating it may grow this
# very slice (xs[slot_of(k)] where slot_of pushes), and a push can move the elements
let ix = emit_expr(e.b)
# load the data pointer from the slice header (field 0)
let dp = nreg()
emit(" "); emit(dp); emit(" = getelementptr inbounds %LSlice, ptr ")
emit(base.code); emit(", i32 0, i32 0\n")
let data = nreg()
emit(" "); emit(data); emit(" = load ptr, ptr "); emit(dp); emit("\n")
let ix = emit_expr(e.b) # (evaluate index BEFORE setting
# (the index was evaluated first, above, and before g_addr_ty is set
# THE INDEX IS CHECKED AGAINST THE LENGTH. A slice has carried { ptr, len, cap } since it
# existed, and nothing ever read the len: every index was a bare getelementptr, so running
# off the end of one wrote into whatever the allocator had put next and the program died
@ -73,7 +76,7 @@ function emit_index_addr(e: Node) -> pointer {
let lbad = lbl("ixbad")
emit(` br i1 {okc}, label %{lok}, label %{lbad}\n`)
emit(`{lbad}:\n`)
let bmsg = emit_str_const(`{g_src_name}:{itoa(e.line)}: index out of range: `)
let bmsg = emit_str_const(`{src_name_of(e)}:{itoa(e.line)}: index out of range: `)
let bse = emit_bind(stdstream_rhs(2))
emit(` call i32 (ptr, ptr, ...) @fprintf(ptr {bse}, ptr @.fmt_bounds, ptr {bmsg}, i32 {ix.code}, i32 {lnv})\n`)
emit(" call void @exit(i32 1)\n unreachable\n")

View file

@ -523,7 +523,7 @@ function emit_call(e: Node) -> Val {
if (name == "self") { if nself == 0 { return val("0", "entity") }; return val(emit_bind(`load i32, ptr {self_stk[nself - 1]}`), "entity") }
if (name == "key") { return val(emit_bind("load i32, ptr @L_key"), "int") }
if (name == "save") { emit(" call void @L_save()\n"); return val("0", "void") }
if (name == "ui_build") { emit(" call void @ui_build()\n"); return val("0", "void") }
if (name == "ui_build") and (find_fn(name) == null) { emit(" call void @ui_build()\n"); return val("0", "void") }
if (name == "load") { return val(emit_bind("call i32 @L_load()"), "bool") }
if (name == "world_size") { return val(emit_bind("call i32 @L_world_size()"), "int") }
if (name == "world_save") { # world_save(buf) -> bytes written
@ -906,6 +906,7 @@ function emit_call(e: Node) -> Val {
cname = rtname
}
vis_check(fn2, name)
call_fill_defaults(e, fn2) # L11 - for code the checker does not walk
reorder_named(e, param_labels(fn2))
# evaluate args first (their IR is emitted before the call instruction), coercing
# each to the parameter's declared type so an int passed for a `long` widens.

View file

@ -70,6 +70,11 @@ function emit_sized_slice(el: pointer, n: Val) -> Val {
emit(` store i32 {n.code}, ptr {slice_field(h.code, 2)}\n`)
return h
}
# the file a runtime error names: the one the expression is written in, not the program's own
function src_name_of(e: Node) -> pointer {
if e == null or e.file == null { return g_src_name }
return e.file
}
function emit_view(e: Node) -> Val {
let xs = emit_expr(e.kids[0])
if not is_slice_ty(xs.ty) { perr(`view takes a slice, and this is {xs.ty}`) }
@ -88,7 +93,7 @@ function emit_view(e: Node) -> Val {
emit(` br i1 {ok}, label %{lok}, label %{lbad}\n`)
emit(`{lbad}:\n`)
g_uses_bounds = true
let bmsg = emit_str_const(`{g_src_name}:{itoa(e.line)}: view past the end: from `)
let bmsg = emit_str_const(`{src_name_of(e)}:{itoa(e.line)}: view past the end: from `)
let bse = emit_bind(stdstream_rhs(2))
emit(` call i32 (ptr, ptr, ...) @fprintf(ptr {bse}, ptr @.fmt_bounds, ptr {bmsg}, i32 {endv}, i32 {ln})\n`)
emit(" call void @exit(i32 1)\n unreachable\n")

View file

@ -13,6 +13,7 @@ function ck_params(f: Node, labels: []pointer, tys: []pointer) -> void {
}
}
function ck_call_fn(e: Node, name: pointer, f: Node) -> pointer {
call_fill_defaults(e, f)
let labels = new []pointer
let tys = new []pointer
ck_params(f, labels, tys)
@ -21,6 +22,7 @@ function ck_call_fn(e: Node, name: pointer, f: Node) -> pointer {
return f.ty
}
function ck_call_alias(e: Node, name: pointer, al: int, f: Node) -> pointer {
if g_al_labels[al] == null { call_fill_defaults(e, f) } else { call_mixed_to_named(e, ns_alias_labels(al)) }
let labels = new []pointer
let tys = new []pointer
ck_params(f, labels, tys)
@ -50,11 +52,18 @@ function ck_args(e: Node, name: pointer, labels: []pointer, tys: []pointer) -> v
let vt = ck_expr(fi.a)
let at = ck_label_at(labels, fi.s)
if at >= 0 { ck_give(tys[at], vt, fi.a, `argument {fi.s} of {name}`) }
if at < 0 and len(labels) > 0 { ck_err("arity", fi.a, `{name} has no parameter {fi.s}`) }
j += 1
}
var m = 0
while m < len(labels) {
if not ck_named_has(e, labels[m]) { ck_err("arity", e, `this call to {name} leaves out {labels[m]}, which has no default`) }
m += 1
}
return
}
if n != len(tys) { ck_err("arity", e, `{name} takes {itoa(len(tys))} argument(s) and this call gives {itoa(n)}`) }
if n < len(tys) and len(labels) == len(tys) { ck_err("arity", e, `this call to {name} leaves out {labels[n]}, which has no default`) }
else { if n != len(tys) { ck_err("arity", e, `{name} takes {itoa(len(tys))} argument(s) and this call gives {itoa(n)}`) } }
var i = 0
while i < n {
if i < len(tys) { ck_expect = tys[i] }
@ -63,6 +72,14 @@ function ck_args(e: Node, name: pointer, labels: []pointer, tys: []pointer) -> v
i += 1
}
}
function ck_named_has(e: Node, label: pointer) -> bool {
var i = 0
while i < len(e.kids) {
if (e.kids[i].s == label) { return true }
i += 1
}
return false
}
function ck_walk_args(e: Node) -> void {
var i = 0
while i < len(e.kids) {

View file

@ -45,6 +45,7 @@ function gen_arg_nodes(e: Node, labels: []pointer) -> []Node {
}
function gen_call(e: Node, name: pointer, t: Node) -> pointer {
let expect = ck_call_expect
call_fill_defaults(e, t)
let tps = gen_split(t.tps)
let labels = new []pointer
let ptys = new []pointer

View file

@ -147,10 +147,24 @@ function ck_listener(l: Node) -> void {
ck_block(l.a)
ck_pop(m)
}
# a program's function named like one of the runtime's takes every call the runtime makes to its
# own - the runtime's ui_measure started answering to a package's - so the name is refused
function ck_runtime_shadows() -> void {
var r = g_prog_user_end
while r < len(prog) {
let d = prog[r]
if d.kind == N_FN {
let u = ck_fn(d.s)
if u != null and u != d { ck_err("shadow", u, `{d.s} is also a function of the engine runtime ({d.file}); choose another name`) }
}
r += 1
}
}
# the pass also makes the generics real (check_gen.ludic), so it always runs
function check_program() -> void {
gen_collect()
ck_index()
ck_runtime_shadows()
var i = 0
while i < len(prog) {
let d = prog[i]

View file

@ -64,6 +64,7 @@ function ns_alias_labels(i: int) -> []pointer {
return out
}
function emit_alias_call(i: int, e: Node) -> Val {
call_mixed_to_named(e, ns_alias_labels(i))
reorder_named(e, ns_alias_labels(i))
let id = node(E_ID)
id.s = g_al_target[i]

View file

@ -0,0 +1,96 @@
# defaults.ludic — L11: a parameter may have a default, `pad: float = 8.0`, and a call may leave it
# out - positionally from the end, or by not naming it. That is what makes a component's props
# usable: `button(label: "Buy", on_press: fn buy)` rather than every prop of a button spelt out.
# The default is an expression written where the function is and evaluated for each call.
# `f(a, b, size: 2)`: the leading arguments are the first parameters, by position, and the rest
# are named - the natural call of a component, whose first prop is the one it is about
function call_mixed_to_named(e: Node, labels: []pointer) -> void {
var first_named = -1
var i = 0
while i < len(e.kids) {
if e.kids[i].kind == E_FINIT and first_named < 0 { first_named = i }
if e.kids[i].kind != E_FINIT and first_named >= 0 {
g_err_file = e.file
g_err_line = e.line
perr("a positional argument after a named one")
}
i += 1
}
if first_named <= 0 { return }
let out = new []Node
var k = 0
while k < len(e.kids) {
var a = e.kids[k]
if k < first_named and k < len(labels) {
let fi = node(E_FINIT)
fi.s = labels[k]
fi.a = a
fi.line = a.line
fi.file = a.file
a = fi
}
push(out, a)
k += 1
}
e.kids = out
}
function call_fill_defaults(e: Node, f: Node) -> void {
if f == null { return }
let labels = new []pointer
let defs = new []Node
var any = false
var i = 0
while i < len(f.kids) {
let p = f.kids[i]
if p.kind == N_PARAM {
push(labels, p.s)
push(defs, p.a)
if p.a != null { any = true }
}
i += 1
}
call_mixed_to_named(e, labels)
if not any { return }
if len(e.kids) > 0 and e.kids[0].kind == E_FINIT {
let out = new []Node
var li = 0
while li < len(labels) {
var found: Node = null
var k = 0
while k < len(e.kids) {
if (e.kids[k].s == labels[li]) { found = e.kids[k] }
k += 1
}
if found == null and defs[li] != null {
found = node(E_FINIT)
found.s = labels[li]
found.a = default_copy(defs[li], e)
}
if found != null { push(out, found) }
li += 1
}
# a label the function does not have stays, so the call is refused for it
var k2 = 0
while k2 < len(e.kids) {
if not default_label_in(labels, e.kids[k2].s) { push(out, e.kids[k2]) }
k2 += 1
}
e.kids = out
return
}
while len(e.kids) < len(labels) and defs[len(e.kids)] != null { push(e.kids, default_copy(defs[len(e.kids)], e)) }
}
function default_label_in(labels: []pointer, s: pointer) -> bool {
var i = 0
while i < len(labels) {
if (labels[i] == s) { return true }
i += 1
}
return false
}
# each call gets its own copy of the default, at the call's line for its errors
function default_copy(d: Node, at: Node) -> Node {
let c = gen_clone(d, new []pointer, new []pointer)
c.line = at.line
return c
}

View file

@ -577,7 +577,9 @@ function parse_fn() -> Node {
eat_op("(")
if is_reserved_word(n.s) { perr(`'{n.s}' is a reserved word and cannot name a function`) }
while not is_op(")") {
let p = node(N_PARAM); p.s = eat_id(); eat_op(":"); p.ty = ptype(); push(n.kids, p)
let p = node(N_PARAM); p.s = eat_id(); eat_op(":"); p.ty = ptype()
if is_op("=") { pi += 1; p.a = expr() } # L11: a default, for a call that leaves it out
push(n.kids, p)
if is_op(",") { pi += 1 }
}
eat_op(")")
@ -1014,6 +1016,7 @@ function parse_one_decl() -> void {
if is_id("namespace") { parse_namespace(); return } # #76 namespace block
if is_id("registry") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "of") { parse_registry(); return } # L8
if is_id("def") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].kind == TK_ID) { parse_def(); return } # L8
if is_id("view") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 2].text == "{") { parse_view(); return } # L11
if is_id("var") { push(prog, parse_var()); return }
if is_id("const") { push(prog, parse_const()); return }
var is_unsafe = false
@ -1510,4 +1513,5 @@ function parse_program() -> void {
parse_one_decl()
}
registries_finish() # L8: each registry gets its defs
views_finish() # L11: each view gets its model and call
}

View file

@ -0,0 +1,113 @@
# view.ludic — L11: a view is the one bridge between a program and its UI templates. The
# program says what a template may read and what it may do, and nothing else crosses:
#
# view Yard {
# purse = hm_purse # a field: the global's own type
# spots: int = yard_spots() # any expression, with its type said
# function placed(hf: int) -> int { return yard_placed(hf) } # a query a template may ask
# on buy(hf: int) { yard_buy(hf) } # an event a template may send
# }
#
# gives view_yard() -> UiView, whose model() is a Value object of every field (a record is an
# object of its fields, a list and a registry a list) and whose call(name, args) runs a query or
# an event by name with its arguments out of a Value list. Everything generated belongs to the
# view's own file, so it is in the view's module and exported only when the view is.
var g_vw_name: []pointer = new []pointer
var g_vw_node: []Node = new []Node # view_<name>(), which carries the file, line and export
var g_vw_fields: [][]Node = new [][]Node # the getters, one per field
var g_vw_fnames: [][]pointer = new [][]pointer
var g_vw_calls: [][]Node = new [][]Node # the queries and the events
var g_vw_cnames: [][]pointer = new [][]pointer
var g_vw_helpers: []pointer = new []pointer # conversion helpers already written, program-wide
function vw_prefix(name: pointer) -> pointer { return `view_{reg_lower(name)}_` }
# view NAME { members }
function parse_view() -> void {
pi += 1
let name = eat_id()
let px = vw_prefix(name)
g_uses_value = true
let fields = new []Node
let fnames = new []pointer
let calls = new []Node
let cnames = new []pointer
skipnl()
eat_op("{")
while true {
skipnl()
if is_op("}") { break }
if is_id("function") or is_id("on") {
let ev = is_id("on")
if ev { toks[pi].text = "function" }
let f = parse_fn()
push(cnames, f.s)
if ev { f.s = px + "on_" + f.s } else { f.s = px + f.s }
push(calls, f)
push(prog, f)
continue
}
push(fnames, eat_id())
push(fields, vw_field(px, fnames[len(fnames) - 1]))
}
eat_op("}")
let head = vw_head(name, px)
push(g_vw_name, name)
push(g_vw_node, head)
push(g_vw_fields, fields)
push(g_vw_fnames, fnames)
push(g_vw_calls, calls)
push(g_vw_cnames, cnames)
}
# one field: NAME [: TYPE] = EXPR, as a getter function whose type the finish fills in if unsaid
function vw_field(px: pointer, fname: pointer) -> Node {
let g = node(N_FN)
g.s = px + "get_" + fname
g.ty = null
if is_op(":") {
pi += 1
g.ty = ptype()
}
if not is_op("=") { perr(`view field {fname}: say what it is - {fname} = some_global`) }
pi += 1
let r = node(S_RETURN)
r.a = expr()
let body = node(N_BLOCK)
push(body.kids, r)
g.a = body
push(prog, g)
return g
}
# view_<name>() -> UiView, written now so it takes the export the declaration is under
function vw_head(name: pointer, px: pointer) -> Node {
let src = `function view_{reg_lower(name)}() -> UiView {{\n let v = new UiView\n v.name = "{name}"\n v.model = fn {px}model\n v.call = fn {px}call\n return v\n}}\n`
let at = len(prog)
vw_parse(src, g_parse_file, toks[pi].line, 0)
return prog[at]
}
# parse generated source into the program, as if it were written in `file` at `line`
function vw_parse(src: pointer, file: pointer, line: int, vis: int) -> void {
let saved_toks = toks
let saved_pi = pi
let saved_file = g_parse_file
let saved_parsing = g_parsing
g_parse_file = file
g_parsing = true
lex_at(src, line)
pi = 0
let at = len(prog)
while true {
skipnl()
if toks[pi].kind == TK_EOF { break }
parse_one_decl()
}
var k = at
while k < len(prog) {
prog[k].vis = vis
k += 1
}
toks = saved_toks
pi = saved_pi
g_parse_file = saved_file
g_parsing = saved_parsing
}

View file

@ -0,0 +1,68 @@
# view_conv.ludic — L11: a value of the program as a Value a template reads. A number, a bool
# and a string are themselves; a list is a list of its items; a record is an object of every
# field a template could read (a field it could not - a function, a buffer - is left out).
# Anything else answers null, and the caller says so.
function vw_conv(v: int, ty: pointer, x: pointer, head: Node) -> pointer {
if (ty == "int") { return `value_int({x})` }
if (ty == "float") { return `value_float({x})` }
if (ty == "string") { return `value_str({x})` }
if (ty == "bool") {
vw_helper(`function view_val_bool(b: bool) -> Val {{\n if b {{ return value_bool(1) }}\n return value_bool(0)\n}}\n`, "view_val_bool", head)
return `view_val_bool({x})`
}
if len(ty) > 2 and ty[0] == '[' and ty[1] == ']' {
let et: pointer = ty[2..len(ty)]
let name = `view_val_list_{vw_mangle(et)}`
if vw_have(name) { return `{name}({x})` }
let item = vw_conv(v, et, "xs[i]", head)
if item == null { return null }
push(g_vw_helpers, name)
vw_parse(`function {name}(xs: {ty}) -> Val {{\n let out = value_list()\n var i = 0\n while i < len(xs) {{\n value_add(out, {item})\n i += 1\n }}\n return out\n}}\n`, head.file, head.line, 1)
return `{name}({x})`
}
let comp = find_comp(ty)
if comp == null { return null }
let name = `view_val_{vw_mangle(ty)}`
if vw_have(name) { return `{name}({x})` }
push(g_vw_helpers, name)
var src = `function {name}(r: {ty}) -> Val {{\n if r == null {{ return value_null() }}\n let o = value_object()\n`
var i = 0
while i < len(comp.kids) {
let f = comp.kids[i]
if f.ty != null {
let c = vw_conv(v, f.ty, `r.{f.s}`, head)
if c != null { src = src + ` value_put(o, "{f.s}", {c})\n` }
}
i += 1
}
vw_parse(src + " return o\n}\n", head.file, head.line, 1)
return `{name}({x})`
}
function vw_have(name: pointer) -> bool {
var i = 0
while i < len(g_vw_helpers) {
if (g_vw_helpers[i] == name) { return true }
i += 1
}
return false
}
function vw_helper(src: pointer, name: pointer, head: Node) -> void {
if vw_have(name) { return }
push(g_vw_helpers, name)
vw_parse(src, head.file, head.line, 1)
}
# a type as part of a name: []int -> list_int, a.B -> a_B
function vw_mangle(ty: pointer) -> pointer {
let n = len(ty)
let out = bytes(n + 1)
var i = 0
while i < n {
var c = ty[i]
let ok = (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9')
if not ok { c = '_' }
out[i] = c
i += 1
}
out[n] = 0
return out
}

View file

@ -0,0 +1,84 @@
# view_finish.ludic — L11: when the program is read, each view gets its model and its call.
# A field's type is the one it was given or its global's; converting it to a Value is a helper
# written once for the whole program per type (view_val_<type>), so two views of one record
# share it.
function views_finish() -> void {
var v = 0
while v < len(g_vw_name) {
vw_finish(v)
v += 1
}
}
function vw_err(v: int, msg: pointer) -> void {
g_err_file = g_vw_node[v].file
g_err_line = g_vw_node[v].line
g_parsing = false
perr(`view {g_vw_name[v]}: {msg}`)
}
function vw_finish(v: int) -> void {
let head = g_vw_node[v]
if find_comp("UiView") == null { vw_err(v, `a view is shown by ludic.ui - import "ludic.ui"`) }
let px = vw_prefix(g_vw_name[v])
var model = `function {px}model() -> Val {{\n let o = value_object()\n`
let fields = g_vw_fields[v]
var i = 0
while i < len(fields) {
let g = fields[i]
let fname = g_vw_fnames[v][i]
if g.ty == null { g.ty = vw_infer(v, g, fname) }
let conv = vw_conv(v, g.ty, `{g.s}()`, head)
if conv == null { vw_err(v, `{fname} is a {g.ty}, which a template cannot read`) }
model = model + ` value_put(o, "{fname}", {conv})\n`
i += 1
}
model = model + " return o\n}\n"
vw_parse(model, head.file, head.line, head.vis)
vw_parse(vw_call_src(v, px, head), head.file, head.line, head.vis)
}
# a field written `name = some_global` has that global's declared type
function vw_infer(v: int, g: Node, fname: pointer) -> pointer {
let e = g.a.kids[0].a
if e.kind == E_ID {
let d = find_global(e.s)
if d != null and d.ty != null { return d.ty }
}
g_vw_node[v].line = g.line
vw_err(v, `say what type {fname} is - {fname}: int = ...`)
return "int"
}
# call(name, args): each query and event by its name, its arguments out of the list
function vw_call_src(v: int, px: pointer, head: Node) -> pointer {
var src = `function {px}call(name: string, args: Val) -> Val {{\n`
let calls = g_vw_calls[v]
var i = 0
while i < len(calls) {
let f = calls[i]
var args = ""
var p = 0
while p < len(f.kids) {
if p > 0 { args = args + ", " }
args = args + vw_arg(v, f.kids[p], p)
p += 1
}
let call = `{f.s}({args})`
src = src + ` if name == "{g_vw_cnames[v][i]}" {{\n`
if f.ty == "void" { src = src + ` {call}\n return value_null()\n` } else {
let conv = vw_conv(v, f.ty, call, head)
if conv == null { vw_err(v, `{g_vw_cnames[v][i]} answers a {f.ty}, which a template cannot read`) }
src = src + ` return {conv}\n`
}
src = src + " }\n"
i += 1
}
return src + " return value_null()\n}\n"
}
# a parameter out of the template's argument list
function vw_arg(v: int, p: Node, i: int) -> pointer {
let at = `value_at(args, {i})`
if (p.ty == "int") { return `value_as_int({at})` }
if (p.ty == "float") { return `value_as_float({at})` }
if (p.ty == "bool") { return `value_as_int({at}) != 0` }
if (p.ty == "string") { return `value_as_str({at})` }
vw_err(v, `{p.s} is a {p.ty}; a template can pass an int, a float, a bool or a string`)
return ""
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff