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

@ -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 ""
}