The engine runtime and the bundled ludic.* packages belong to the toolchain,
not to the project, so the compiler has to know where the toolchain lives. It
derived that from the directory the binary sits in, which is `bin` — so an
in-repo build only found runtime/native/cocoa.ll when the caller set
LUDIC_HOME=., and an installed compiler had no way to find it at all.
ludic_home() derives it properly instead: $LUDIC_HOME when set, else the
binary's directory with a trailing `bin/` stripped, else a $PATH scan for
argv[0] (an install is invoked by bare name, which carries no directory). Both
layouts that exist then have the same shape — ~/.ludic/{bin,runtime,packages}
and a repo checkout — so the same rule serves both and LUDIC_HOME becomes an
override rather than a requirement.
`import "ludic.core/…"` also falls back to $LUDIC_HOME/packages, after the
project's own ludic_modules/, so a project that has not fetched its own copy
gets the packages that shipped with the toolchain instead of a symlink farm.
The `ludic` multi-call name is dropped from the compiler: that name now belongs
to the CLI, and --run is the flag it drives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
7.6 KiB
Text
194 lines
7.6 KiB
Text
# main.ludic — the self-hosted Ludic front-end.
|
|
#
|
|
# ludicc lexes, parses, checks and lowers a .ludic file to LLVM IR, then drives
|
|
# clang (the IR assembler + system linker, the same role rustc/swiftc give it)
|
|
# to produce a native binary. No C is compiled: the emitted IR is Ludic's, and
|
|
# clang only assembles and links it.
|
|
#
|
|
# ludicc app.ludic -o bin/app # native binary (windowed if a game)
|
|
# ludicc app.ludic -o bin/app --headless # force headless
|
|
# ludicc app.ludic --emit-llvm -o app.ll # stop at the IR
|
|
# ludicc app.ludic --run # compile to a temp binary and run it
|
|
#
|
|
# This is the compiler, not the command line a user of the language meets: that
|
|
# is `ludic` (tools/ludic-cli), which drives this. With no -o and no --run, IR
|
|
# still goes to stdout — the contract `ludic dev reseed` and `ludic dev
|
|
# bootstrap` rely on, so the bootstrap is untouched.
|
|
|
|
# basename: the part of a path after the last '/'.
|
|
function base_name(path: pointer) -> pointer {
|
|
var last = -1
|
|
var i = 0
|
|
while path[i] != 0 { if path[i] == '/' { last = i }; i += 1 }
|
|
return path[last + 1..i]
|
|
}
|
|
|
|
# drop a trailing ".ludic" if present
|
|
function strip_ludic(name: pointer) -> pointer {
|
|
let n = len(name)
|
|
if n > 6 {
|
|
if (name[n - 6..n] == ".ludic") { return name[0..n - 6] }
|
|
}
|
|
return name
|
|
}
|
|
|
|
# env var with a fallback when unset
|
|
function getenv_or(name: pointer, dflt: pointer) -> pointer {
|
|
let v = getenv(name)
|
|
if (v == null) { return dflt }
|
|
return v
|
|
}
|
|
|
|
# guarantee a directory string ends in '/' so join_path concatenates cleanly
|
|
function ensure_slash(d: pointer) -> pointer {
|
|
let n = len(d)
|
|
if n == 0 { return d }
|
|
if d[n - 1] == '/' { return d }
|
|
return d + "/"
|
|
}
|
|
|
|
function die(msg: pointer) -> void {
|
|
file_write(file_stderr(), msg, len(msg))
|
|
exit(1)
|
|
}
|
|
|
|
# strip trailing newline/carriage-return/space (the VERSION file's trailing \n)
|
|
function chomp(s: pointer) -> pointer {
|
|
var n = len(s)
|
|
while (n > 0) and ((s[n - 1] == '\n') or (s[n - 1] == '\r') or (s[n - 1] == ' ')) { n -= 1 }
|
|
return s[0..n]
|
|
}
|
|
|
|
# print "ludic <version>" and exit. The version is read at runtime from the
|
|
# VERSION file at the install root (the single source of truth a release bumps),
|
|
# so a version change never requires reseeding the compiler.
|
|
function show_version() -> void {
|
|
let v = read_file(join_path(ludic_home(), "VERSION"))
|
|
if (v == null) { print("ludic (version unknown)") }
|
|
else { print(`ludic {chomp(v)}`) }
|
|
exit(0)
|
|
}
|
|
|
|
entry {
|
|
var path = null
|
|
var out = null
|
|
var want = 0 # 0 = auto, 1 = windowed, 2 = headless
|
|
var emit_ir = false # --emit-llvm: stop after writing IR
|
|
var fmt = false # --fmt: lex + parse only, then exit (the doc-check gate)
|
|
var save = false # --save-temps: keep the intermediate .ll
|
|
var run = false # compile then execute the result
|
|
g_coverage = false # --coverage: instrument each statement with a per-line hit counter
|
|
|
|
var ai = 1
|
|
while ai < arg_count() {
|
|
let a = arg(ai)
|
|
if a == "--version" { show_version() }
|
|
else if a == "--windowed" { want = 1 }
|
|
else if a == "--headless" { want = 2 }
|
|
else if a == "--emit-llvm" { emit_ir = true }
|
|
else if a == "--emit-module" { g_emit_module = true; emit_ir = true } # issue #64: prebuilt binary module IR
|
|
else if a == "--fmt" { fmt = true }
|
|
else if a == "--save-temps" { save = true }
|
|
else if a == "--run" { run = true }
|
|
else if a == "--coverage" { g_coverage = true }
|
|
else if a == "-o" { ai += 1; if ai < arg_count() { out = arg(ai) } }
|
|
else if a[0] == '-' {
|
|
# an unknown flag is ignored (with a note) rather than mistaken for the input file
|
|
let m = `ludicc: ignoring unknown flag {a}\n`
|
|
file_write(file_stderr(), m, len(m))
|
|
}
|
|
else { path = a }
|
|
ai += 1
|
|
}
|
|
|
|
if (path == null) {
|
|
die("usage: ludicc <file.ludic> [-o out] [--windowed|--headless] [--emit-llvm] [--save-temps]\n")
|
|
}
|
|
let src = read_file(path)
|
|
if (src == null) { die("ludicc: cannot open input\n") }
|
|
|
|
cur_dir = dir_of(path)
|
|
g_src_name = base_name(path) # for panic/expect file:line messages
|
|
g_parse_file = path # for the compiler's own file:line diagnostics
|
|
lex(src)
|
|
parse_program()
|
|
g_prog_user_end = len(prog) # decls from the user's source; runtime splice appends after
|
|
|
|
# --fmt is the doc-check gate: reaching here means it lexed and parsed. A parse
|
|
# error would already have exited nonzero, so a clean parse exits 0. (Canonical
|
|
# formatting output is not yet reimplemented on the self-hosted toolchain.)
|
|
if fmt { exit(0) }
|
|
|
|
maybe_splice_runtime()
|
|
ui_register_gotos() # `goto:` buttons -> generated UiClicked listeners
|
|
|
|
# a game gets a window by default; a plain program stays headless. An explicit
|
|
# flag always wins. The stdout-IR path (no target) also stays headless, which
|
|
# is what `ludic dev reseed` compiles the compiler itself with.
|
|
let has_target = run or (out != null)
|
|
if want == 1 { g_windowed = true }
|
|
else if want == 2 { g_windowed = false }
|
|
else { g_windowed = has_target and has_systems() }
|
|
|
|
g_parsing = false # lowering errors now locate by statement, not token
|
|
emit_program()
|
|
|
|
# --emit-llvm, or no binary target: emit IR and stop (stdout when out is null).
|
|
if emit_ir { ir_flush(out); return }
|
|
if not has_target { ir_flush(null); return }
|
|
|
|
# a run with no explicit -o lands in a temp file
|
|
if run and (out == null) {
|
|
out = `/tmp/ludic-run-{strip_ludic(base_name(path))}`
|
|
}
|
|
|
|
# make the output directory if the user asked for e.g. bin/app
|
|
let odir = dir_of(out)
|
|
if len(odir) > 0 { run(`mkdir -p {odir}`) }
|
|
|
|
let ll = `{out}.ll`
|
|
if not ir_flush(ll) { die("ludicc: cannot write IR\n") }
|
|
|
|
let cc = getenv_or("LUDIC_CC", "clang")
|
|
let home = ludic_home()
|
|
|
|
# clang twice: assemble the IR, then link. A windowed build adds the macOS
|
|
# platform layer and the Cocoa framework.
|
|
# -Wno-override-module: the emitted IR carries no target triple, so clang
|
|
# substitutes the host's and warns about it on every single build. There is
|
|
# nothing to act on — the host default is exactly what we want — so it is off.
|
|
var cmd = `{cc} -O2 -Wno-override-module {ll}`
|
|
if g_windowed {
|
|
let cocoa = join_path(home, "runtime/native/cocoa.ll")
|
|
# GameController holds no symbol cocoa.ll references directly (its classes are
|
|
# reached by name through objc_getClass), so a plain -framework link gets
|
|
# dead-stripped; -needed_framework forces the load command so the class is
|
|
# registered and #51's gamepad polling can see it.
|
|
cmd = `{cmd} {cocoa} -framework Cocoa -Wl,-needed_framework,GameController -Wl,-rpath,@loader_path`
|
|
# Audio.* (#22) pulls in the AVAudioPlayer backend and AVFoundation, but only
|
|
# when the program actually uses it — the snd_* calls are is_windowed()-guarded
|
|
# yet still live in a windowed build, so the backend must link.
|
|
if g_uses_audio {
|
|
let audio = join_path(home, "runtime/native/audio.ll")
|
|
cmd = `{cmd} {audio} -Wl,-needed_framework,AVFoundation`
|
|
}
|
|
}
|
|
# Http.* (#6) links the native transport and Foundation (NSURLConnection etc.,
|
|
# reached by name). Works headless too, so it is outside the windowed block —
|
|
# macOS-only for now, which is where the toolchain runs.
|
|
if g_uses_http {
|
|
let http = join_path(home, "runtime/native/http.ll")
|
|
cmd = `{cmd} {http} -Wl,-needed_framework,Foundation`
|
|
}
|
|
cmd = `{cmd} -o {out}`
|
|
|
|
let rc = run(cmd)
|
|
if not save { run(`rm -f {ll}`) }
|
|
if rc != 0 { die("ludicc: link failed\n") }
|
|
|
|
# run mode: execute the binary we just built and forward its exit code
|
|
if run {
|
|
let st = run(out)
|
|
exit(((st >> 8) & 255))
|
|
}
|
|
}
|