refactor(lang): rename the fn keyword to function

Expand the function-declaration keyword to the full word across the whole
language and toolchain:
  fn name(...) -> T { ... }   ->   function name(...) -> T { ... }

Done as a self-hosting migration: teach the parser both spellings, reseed,
rewrite every .ludic definition to `function`, then drop `fn`. The compiler
now rejects `fn`. Touches the parser, all selfhost/tools/runtime/example/test
sources, the grammars (TextMate shared+vscode, ludic_syntax.h, JetBrains
LudicTokens.kt), the LSP and formatter, the Python doc/vocab tools
(check-impl, check-docs, validate, palette, test-lsp), and the docs
(fences, prose, kw-fn -> kw-function).

Reseeded; C-free bootstrap fixpoint holds. All suites green (45 regression,
24 self-host, 29 tool); the docs site generates and check.py passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 01:43:22 +03:00
parent 2f19c8d8e2
commit 4c48077d68
86 changed files with 793 additions and 793 deletions

View file

@ -5,7 +5,7 @@
# compile a Ludic source to a native binary in bin/ via ludicc + clang (-O2).
# Returns true on success. Used for x itself and the editor tools.
fn build_tool(name: ptr, src: ptr) -> bool {
function build_tool(name: ptr, src: ptr) -> bool {
let ll = `build/{name}.ll`
if not shq(`bin/ludicc {src} --emit-llvm -o {ll} 2>/dev/null`) { print(`build failed: {name} (compile)`); return false }
# write to a temp then move, so a running bin/x can rebuild itself in place
@ -18,7 +18,7 @@ fn build_tool(name: ptr, src: ptr) -> bool {
# Both ludicc and ludic are the SAME multi-call binary assembled from the IR seed
# with clang alone; invoked as `ludic` it compiles-and-runs, as `ludicc` it just
# compiles.
fn cmd_build_cli() -> int {
function cmd_build_cli() -> int {
run("mkdir -p bin build")
print("cc: selfhost/ludicc.seed.ll -> bin/ludicc, bin/ludic (from the IR seed, no C compiler)")
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { err("x: seed did not assemble\n"); return 1 }
@ -28,7 +28,7 @@ fn cmd_build_cli() -> int {
}
# ---- build: the whole toolchain (ludicc, ludic, x, ludic-fmt, ludic-lsp) -----
fn cmd_build() -> int {
function cmd_build() -> int {
if cmd_build_cli() != 0 { return 1 }
print("ludicc: tools/x/main.ludic -> bin/x (the task runner rebuilds itself)")
if not build_tool("x", "tools/x/main.ludic") { return 1 }
@ -44,7 +44,7 @@ fn cmd_build() -> int {
# ---- app: compile a Ludic program to a native app ----------------------------
# usage: x app <file.ludic> [--headless|--windowed] [--save-temps]
# windowed is the default for a game; headless renders the last frame to out.ppm.
fn cmd_app() -> int {
function cmd_app() -> int {
ensure_ludicc()
var src = null
var mode = 1 # 1 = windowed, 2 = headless

View file

@ -18,7 +18,7 @@ program X {
import "selfhost_test.ludic"
import "test.ludic"
fn usage() -> void {
function usage() -> void {
print("x — the Ludic task runner (run from the repository root)")
print("")
print("build & run:")
@ -43,7 +43,7 @@ program X {
}
# a positional argument, or a default when absent
fn argn(i: int, dflt: ptr) -> ptr {
function argn(i: int, dflt: ptr) -> ptr {
if (i < arg_count()) { return arg(i) }
return dflt
}

View file

@ -13,7 +13,7 @@
# ---- file IO ----------------------------------------------------------------
# read a whole file into a fresh NUL-terminated buffer (null if it cannot open)
fn read_file(path: ptr) -> ptr {
function read_file(path: ptr) -> ptr {
let f = file_open(path, "rb")
if (f == null) { return null }
file_seek(f, 0, 2)
@ -27,7 +27,7 @@ fn read_file(path: ptr) -> ptr {
}
# overwrite `path` with `s`; returns false if it could not be opened
fn write_file(path: ptr, s: ptr) -> bool {
function write_file(path: ptr, s: ptr) -> bool {
let f = file_open(path, "wb")
if (f == null) { return false }
file_write(f, s, len(s))
@ -35,22 +35,22 @@ fn write_file(path: ptr, s: ptr) -> bool {
return true
}
fn file_exists(path: ptr) -> bool { return shq(`test -e {path}`) }
fn is_exec(path: ptr) -> bool { return shq(`test -x {path}`) }
function file_exists(path: ptr) -> bool { return shq(`test -e {path}`) }
function is_exec(path: ptr) -> bool { return shq(`test -x {path}`) }
# is `a` newer than `b` (like the shell's `-nt`)?
fn newer(a: ptr, b: ptr) -> bool { return shq(`test {a} -nt {b}`) }
function newer(a: ptr, b: ptr) -> bool { return shq(`test {a} -nt {b}`) }
# ---- process control --------------------------------------------------------
# `run` returns the raw wait status; the program's exit code is the high byte.
fn exit_code(st: int) -> int { return (st >> 8) & 255 }
function exit_code(st: int) -> int { return (st >> 8) & 255 }
# run a command, returning its exit code (0 = success)
fn sh(cmd: ptr) -> int { return exit_code(run(cmd)) }
function sh(cmd: ptr) -> int { return exit_code(run(cmd)) }
# run a command, true when it succeeded
fn shq(cmd: ptr) -> bool { return exit_code(run(cmd)) == 0 }
function shq(cmd: ptr) -> bool { return exit_code(run(cmd)) == 0 }
# run `cmd` and return its stdout (stderr discarded). Never null.
fn capture(cmd: ptr) -> ptr {
function capture(cmd: ptr) -> ptr {
let tmp = "/tmp/x_capture.out"
run(`{cmd} > {tmp} 2>/dev/null`)
let s = read_file(tmp)
@ -60,16 +60,16 @@ fn capture(cmd: ptr) -> ptr {
# run `cmd`, join its output lines with single spaces and trim — the Ludic twin
# of the shell idiom `$(cmd | tr '\n' ' ' | sed 's/ *$//')`.
fn capture_line(cmd: ptr) -> ptr {
function capture_line(cmd: ptr) -> ptr {
return capture(`{cmd} | tr '\n' ' ' | sed 's/ *$//'`)
}
# the last line of a command's output (for one-line error messages)
fn capture_tail(cmd: ptr) -> ptr {
function capture_tail(cmd: ptr) -> ptr {
return capture(`{cmd} 2>&1 | tail -1`)
}
fn getenv_or(name: ptr, dflt: ptr) -> ptr {
function getenv_or(name: ptr, dflt: ptr) -> ptr {
let v = getenv(name)
if (v == null) { return dflt }
return v
@ -78,40 +78,40 @@ fn getenv_or(name: ptr, dflt: ptr) -> ptr {
# ---- stdout helpers ---------------------------------------------------------
# write `s` with no trailing newline (print() always adds one)
fn out(s: ptr) -> void { file_write(file_stdout(), s, len(s)) }
fn err(s: ptr) -> void { file_write(file_stderr(), s, len(s)) }
function out(s: ptr) -> void { file_write(file_stdout(), s, len(s)) }
function err(s: ptr) -> void { file_write(file_stderr(), s, len(s)) }
# an ESC byte — the lexer has no \033, so build it by hand
fn esc() -> ptr { let b = bytes(2); b[0] = 27; b[1] = 0; return b }
fn c_green() -> ptr { return esc() + "[32m" }
fn c_red() -> ptr { return esc() + "[31m" }
fn c_reset() -> ptr { return esc() + "[0m" }
function esc() -> ptr { let b = bytes(2); b[0] = 27; b[1] = 0; return b }
function c_green() -> ptr { return esc() + "[32m" }
function c_red() -> ptr { return esc() + "[31m" }
function c_reset() -> ptr { return esc() + "[0m" }
# ---- the PASS/FAIL test harness ---------------------------------------------
var PASS: int = 0
var FAIL: int = 0
fn ok(msg: ptr) -> void {
function ok(msg: ptr) -> void {
PASS = PASS + 1
print(` {c_green()}PASS{c_reset()} {msg}`)
}
fn bad(msg: ptr) -> void {
function bad(msg: ptr) -> void {
FAIL = FAIL + 1
print(` {c_red()}FAIL{c_reset()} {msg}`)
}
fn bad2(msg: ptr, detail: ptr) -> void {
function bad2(msg: ptr, detail: ptr) -> void {
bad(msg)
print(` {detail}`)
}
# assert two strings equal, reporting the mismatch
fn check(label: ptr, got: ptr, want: ptr) -> void {
function check(label: ptr, got: ptr, want: ptr) -> void {
if (got == want) { ok(label) }
else { bad2(label, `expected [{want}] got [{got}]`) }
}
# print the "== N passed, M failed ==" footer and return the process exit code
fn report() -> int {
function report() -> int {
print("")
print(`== {str(PASS)} passed, {str(FAIL)} failed ==`)
if (FAIL == 0) { return 0 }

View file

@ -6,11 +6,11 @@
# now lives here once, in selfhost_frags().
# the C toolchain driver (clang) that assembles and links the emitted IR
fn cc() -> ptr { return getenv_or("LUDIC_CC", "clang") }
function cc() -> ptr { return getenv_or("LUDIC_CC", "clang") }
# the self-host compiler's source fragments, in link order. This is THE list;
# the old scripts each carried their own copy.
fn selfhost_frags() -> []ptr {
function selfhost_frags() -> []ptr {
let f = new []ptr
push(f, "selfhost/str.ludic")
push(f, "selfhost/buf.ludic")
@ -52,7 +52,7 @@ fn selfhost_frags() -> []ptr {
# concatenate the fragments into `program SelfHost { ... }` at `outpath`.
# This is the pure-Ludic equivalent of the scripts' `{ echo; for; cat; }` block.
fn write_selfhost_src(outpath: ptr) -> bool {
function write_selfhost_src(outpath: ptr) -> bool {
let f = file_open(outpath, "wb")
if (f == null) { return false }
let hdr = "program SelfHost {\n"
@ -72,13 +72,13 @@ fn write_selfhost_src(outpath: ptr) -> bool {
}
# count the lines in a file (for the progress notes the scripts printed)
fn line_count(path: ptr) -> ptr {
function line_count(path: ptr) -> ptr {
return capture_line(`wc -l < {path}`)
}
# ensure bin/ludicc exists and is current with the seed. Built from the checked-in
# IR seed with clang alone — no C compiler is ever involved.
fn ensure_ludicc() -> void {
function ensure_ludicc() -> void {
run("mkdir -p bin build")
if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") {
print("cc: selfhost/ludicc.seed.ll -> bin/ludicc (from the IR seed, no C compiler)")
@ -90,7 +90,7 @@ fn ensure_ludicc() -> void {
# ---- selfhost-build: assemble + compile the self-host compiler ---------------
# usage: x selfhost-build [ludicc] [outbin] (defaults: bin/ludicc, build/selfhost)
fn cmd_selfhost_build(lc: ptr, outbin: ptr) -> int {
function cmd_selfhost_build(lc: ptr, outbin: ptr) -> int {
run("mkdir -p build")
let src = "build/selfhost.ludic"
if not write_selfhost_src(src) { err("x: cannot write build/selfhost.ludic\n"); return 1 }
@ -104,7 +104,7 @@ fn cmd_selfhost_build(lc: ptr, outbin: ptr) -> int {
# ---- sh-compile: compile one .ludic with a given selfhost binary and link ----
# usage: x sh-compile <selfhost-binary> <input.ludic> <output-binary>
fn cmd_sh_compile(shbin: ptr, in: ptr, outbin: ptr) -> int {
function cmd_sh_compile(shbin: ptr, in: ptr, outbin: ptr) -> int {
if not shq(`{shbin} {in} > {outbin}.ll`) { return 1 }
if not shq(`{cc()} {outbin}.ll -o {outbin}`) { return 1 }
return 0
@ -116,7 +116,7 @@ fn cmd_sh_compile(shbin: ptr, in: ptr, outbin: ptr) -> int {
# the quiet core, reused by the test suites; returns true on success. On failure
# the self-host/link diagnostics are left in /tmp/x_gb.err.
fn game_build_ok(shbin: ptr, game: ptr, outbin: ptr) -> bool {
function game_build_ok(shbin: ptr, game: ptr, outbin: ptr) -> bool {
let ll = `{outbin}.ll`
if not shq(`{shbin} {game} > {ll} 2>/tmp/x_gb.err`) { return false }
if not shq(`{cc()} -O2 {ll} -o {outbin} 2>/tmp/x_gb.err`) { return false }
@ -125,7 +125,7 @@ fn game_build_ok(shbin: ptr, game: ptr, outbin: ptr) -> bool {
}
# usage: x game-build <selfhost-binary> <game.ludic> <out-binary>
fn cmd_game_build(shbin: ptr, game: ptr, outbin: ptr) -> int {
function cmd_game_build(shbin: ptr, game: ptr, outbin: ptr) -> int {
if game_build_ok(shbin, game, outbin) { print(`built {outbin}`); return 0 }
print("build failed:")
out(capture("grep -i error /tmp/x_gb.err | head -5"))
@ -136,7 +136,7 @@ fn cmd_game_build(shbin: ptr, game: ptr, outbin: ptr) -> int {
# stage0 bin/ludicc compiles the self-host source -> gen1 (built by a different
# compiler, so its IR legitimately differs); gen1 -> gen2, gen2 -> gen3, and
# gen2.ll == gen3.ll is the fixpoint that proves the compiler reproduces itself.
fn cmd_bootstrap() -> int {
function cmd_bootstrap() -> int {
ensure_ludicc()
run("mkdir -p build/boot")
if cmd_selfhost_build("bin/ludicc", "build/boot/gen1") != 0 { print("FAIL: stage0 build"); return 1 }
@ -163,7 +163,7 @@ fn cmd_bootstrap() -> int {
# The seed is the compiler's own IR, a proven fixed point: clang assembles it,
# the seed-built compiler compiles its own source, and the result must equal the
# seed byte for byte.
fn cmd_bootstrap_cfree() -> int {
function cmd_bootstrap_cfree() -> int {
run("mkdir -p build/cfree")
if not shq(`{cc()} selfhost/ludicc.seed.ll -o build/cfree/sh_seed 2>/dev/null`) { print("FAIL: assemble seed"); return 1 }
print(" seed.ll --clang--> sh_seed (no C compiler used)")
@ -184,7 +184,7 @@ fn cmd_bootstrap_cfree() -> int {
# fixed point of the NEW compiler, not a one-step image of the old. Falls back to
# bin/ludicc only if the seed itself no longer assembles. Verify with
# `x bootstrap-cfree` afterwards.
fn cmd_reseed() -> int {
function cmd_reseed() -> int {
run("mkdir -p build/cfree")
if not write_selfhost_src("build/cfree/selfhost.ludic") { print("FAIL: write source"); return 1 }
if shq(`{cc()} selfhost/ludicc.seed.ll -o build/cfree/sh_old 2>/dev/null`) {

View file

@ -3,7 +3,7 @@
# fixpoint). Replaces selfhost/test.sh.
# compile selfhost/tests/<name>.ludic with bin/ludicc, assemble, run, compare
fn sh_case(name: ptr, exp: ptr) -> void {
function sh_case(name: ptr, exp: ptr) -> void {
let ll = `/tmp/x_sh_{name}.ll`
let er = `/tmp/x_sh_{name}.err`
let bn = `/tmp/x_sh_{name}`
@ -16,14 +16,14 @@ fn sh_case(name: ptr, exp: ptr) -> void {
}
# compile examples/<name>.ludic as a game and diff its render against the golden
fn game_case(name: ptr, keys: ptr) -> void {
function game_case(name: ptr, keys: ptr) -> void {
if not game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_g_{name}`) { bad(`{name}: build`); return }
run(`printf '%s' '{keys}' | /tmp/x_g_{name} >/dev/null 2>&1`)
if shq(`cmp -s selfhost/golden/{name}.ppm out.ppm`) { ok(`{name} matches the golden render`) }
else { bad(`{name}: differs from golden`) }
}
fn cmd_selfhost_test() -> int {
function cmd_selfhost_test() -> int {
PASS = 0
FAIL = 0

View file

@ -9,7 +9,7 @@
# compile a feature example as a game, run it (optional keys on stdin) and match
# its stdout against the expected space-joined output.
fn feat_case(name: ptr, keys: ptr, exp: ptr, label: ptr) -> void {
function feat_case(name: ptr, keys: ptr, exp: ptr, label: ptr) -> void {
if not game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_e_{name}`) {
bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return
}
@ -20,7 +20,7 @@ fn feat_case(name: ptr, keys: ptr, exp: ptr, label: ptr) -> void {
}
# a "does it still compile" smoke test (parse -> lower -> link), no run
fn qsmoke(name: ptr) -> void {
function qsmoke(name: ptr) -> void {
if game_build_ok("bin/ludicc", `examples/{name}.ludic`, `/tmp/x_q_{name}`) {
ok(`{name} compiles (@Queries desugars to S_QUERY)`)
} else { bad2(name, capture_line("grep -i error /tmp/x_gb.err | head -1")) }
@ -28,7 +28,7 @@ fn qsmoke(name: ptr) -> void {
# a pure-Ludic example that drives and asserts itself: compile headless to IR,
# let clang assemble it, run, and compare stdout. No C host and no C compiled.
fn net_case(name: ptr, exp: ptr) -> void {
function net_case(name: ptr, exp: ptr) -> void {
let ll = `/tmp/x_n_{name}.ll`
let log = `/tmp/x_n_{name}.out`
if not shq(`bin/ludicc --headless examples/{name}.ludic --emit-llvm -o {ll} > {log} 2>&1`) {
@ -43,7 +43,7 @@ fn net_case(name: ptr, exp: ptr) -> void {
if (got == exp) { ok(`{name} ({got})`) } else { bad2(name, `got [{got}] want [{exp}]`) }
}
fn cmd_test() -> int {
function cmd_test() -> int {
PASS = 0
FAIL = 0

View file

@ -7,7 +7,7 @@
# driven through python3/node (they are not project shell scripts), invoked here.
# refresh one editor-copy of a shared grammar file if it has drifted
fn sync_one(shared: ptr, dst: ptr) -> void {
function sync_one(shared: ptr, dst: ptr) -> void {
if not shq(`cmp -s {shared} {dst}`) {
run(`cp {shared} {dst}`)
print(`sync: {shared} -> {dst}`)
@ -16,7 +16,7 @@ fn sync_one(shared: ptr, dst: ptr) -> void {
# VS Code needs the grammar inside its own extension directory, so it gets a
# copy that build refreshes rather than trusts (test-tools asserts they match).
fn sync_vscode_grammar() -> void {
function sync_vscode_grammar() -> void {
if not shq("test -d tools/editors/vscode") { return }
run("mkdir -p tools/editors/vscode/syntaxes")
sync_one("tools/editors/shared/ludic.tmLanguage.json", "tools/editors/vscode/syntaxes/ludic.tmLanguage.json")
@ -25,7 +25,7 @@ fn sync_vscode_grammar() -> void {
}
# is `flag` present anywhere in argv?
fn has_flag(flag: ptr) -> bool {
function has_flag(flag: ptr) -> bool {
var i = 2
while i < arg_count() { if (arg(i) == flag) { return true }; i = i + 1 }
return false
@ -33,7 +33,7 @@ fn has_flag(flag: ptr) -> bool {
# ---- tools: build the editor toolchain --------------------------------------
# usage: x tools [--install] [--test]
fn cmd_tools() -> int {
function cmd_tools() -> int {
ensure_ludicc()
print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt (Ludic -> LLVM IR -> binary, no C)")
if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 }
@ -53,7 +53,7 @@ fn cmd_tools() -> int {
}
# ---- test-tools: the editor-toolchain regression suite -----------------------
fn cmd_test_tools() -> int {
function cmd_test_tools() -> int {
PASS = 0
FAIL = 0
let fmt = "bin/ludic-fmt"
@ -172,13 +172,13 @@ fn cmd_test_tools() -> int {
return report()
}
fn test_json(path: ptr) -> void {
function test_json(path: ptr) -> void {
let bn = capture_line(`basename {path}`)
if shq(`python3 -c 'import json,sys;json.load(open(sys.argv[1]))' {path} 2>/dev/null`) {
ok(`valid JSON: {bn}`)
} else { bad(`invalid JSON: {path}`) }
}
fn test_xml(path: ptr) -> void {
function test_xml(path: ptr) -> void {
let bn = capture_line(`basename {path}`)
if shq(`python3 -c 'import xml.dom.minidom,sys;xml.dom.minidom.parse(sys.argv[1])' {path} 2>/dev/null`) {
ok(`valid XML: {bn}`)