ludic/selfhost/main.ludic
Orkuncakilkaya e738741521 feat(cli): ludic deps - the module graph as the compiler resolved it
With LUDIC_DEPS=<file> the compiler records every reference the
visibility pass resolves (from module, to module) and every assignment
to another module's global. ludic deps prints the modules,
dependencies, largest cycle, cross writes and globals written from
outside, with --graph, --dot, --writes, --uses MOD, --check FILE and
--baseline FILE. Reseed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:16:34 +03:00

361 lines
16 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
# ludicc app.ludic --target x86_64-pc-windows-msvc --emit-llvm -o app.ll
# # IR for another platform
#
# 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.
#
# The target is the host unless --target names another. Only its platform is
# read, and only to choose between two runtimes: macOS (libSystem, Cocoa) and
# Windows (the UCRT and Win32, emit_win.ludic). The IR carries no triple either
# way; clang assembles it for the machine it runs on.
# basename: the part of a path after the last separator.
function base_name(path: pointer) -> pointer {
var last = -1
var i = 0
while path[i] != 0 { if is_sep(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]
}
# does `s` contain `sub`?
function has_sub(s: pointer, sub: pointer) -> bool {
let n = len(s); let m = len(sub)
var i = 0
while i + m <= n {
if s[i..i + m] == sub { return true }
i += 1
}
return false
}
# ---- the host shell ------------------------------------------------------------
# run() is the C library's system(), which is /bin/sh on macOS and cmd.exe on
# Windows. The compiler asks the shell for three things only - make a directory,
# delete a file, run a program - and says each in the host's own words.
# a copy of `p` with every '/' as '\', for cmd.exe, which reads '/' as a switch
function win_path(p: pointer) -> pointer {
let n = len(p)
let out = bytes(n + 1)
var i = 0
while i < n { if p[i] == '/' { out[i] = 92 } else { out[i] = p[i] }; i += 1 }
out[n] = 0
return out
}
function make_dir(d: pointer) -> void {
if host_is_windows() { let w = win_path(d); run(`if not exist "{w}" mkdir "{w}"`) }
else { run(`mkdir -p {d}`) }
}
function remove_file(p: pointer) -> void {
if host_is_windows() { run(`del /f /q "{win_path(p)}" 2>nul`) }
else { run(`rm -f {p}`) }
}
# the clang that assembles and links: $LUDIC_CC, else `clang` from $PATH - or, on a
# Windows host, the LLVM installer's own location when it is there, because that
# installer does not put clang on %PATH% unless asked to.
function find_cc() -> pointer {
let env = getenv("LUDIC_CC")
if (env != null) { return env }
if host_is_windows() {
let llvm = "C:/Program Files/LLVM/bin/clang.exe"
let f = file_open(llvm, "rb")
if (f != null) { file_close(f); return `"{llvm}"` }
}
return "clang"
}
# 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
var target = null # --target <triple>: null means the host
var title = null # --title <name>: game_title() - the window's first title - instead of the program name
var gui = false # --gui: a Windows GUI-subsystem program (no console behind the window)
var links = new []pointer # --link <file>: an extra input to the link (a compiled resource)
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 == "--unsafe" { g_unsafe_all = true } # L7: this program's own files may write `unsafe`
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 == "--target" { ai += 1; if ai < arg_count() { target = arg(ai) } }
else if a == "--gui" { gui = true }
else if a == "--title" { ai += 1; if ai < arg_count() { title = arg(ai) } }
else if a == "--link" { ai += 1; if ai < arg_count() { push(links, arg(ai)) } }
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] [--target triple] [--emit-llvm] [--save-temps]\n")
}
if (target != null) { g_target_win = has_sub(target, "windows") }
else { g_target_win = host_is_windows() }
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)
# `ludic build` passes the package's `app name`, so the window opens under the name the
# player knows rather than the `program` name and is not retitled a moment later
if (title != null) and (len(title) > 0) { g_game_name = title } # 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
privates_rename() # L3: a module's private names are its own (privates.ludic)
check_duplicate_fns() # two declarations of one name: the cause, before its symptoms
check_duplicate_decls()
check_program() # L4: the types agree before anything is emitted
emit_program()
deps_flush() # LUDIC_DEPS=<file>: the module graph (ludic deps)
# --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) {
if host_is_windows() { out = `{fwd_slashes(getenv_or("TEMP", "."))}/ludic-run-{strip_ludic(base_name(path))}` }
else { out = `/tmp/ludic-run-{strip_ludic(base_name(path))}` }
}
# a Windows program is found and run by its extension
if g_target_win {
let n = len(out)
var has_exe = false
if n >= 4 { has_exe = (out[n - 4..n] == ".exe") }
if not has_exe { out = out + ".exe" }
}
# make the output directory if the user asked for e.g. bin/app
let odir = dir_of(out)
if len(odir) > 0 { make_dir(odir) }
let ll = `{out}.ll`
if not ir_flush(ll) { die("ludicc: cannot write IR\n") }
let cc = find_cc()
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_target_win {
# The Windows window: win32.ll (user32, gdi32 for the software present, xinput
# for pads), and audio_win.ll when the program uses Audio.*, as on macOS.
if g_windowed {
let win32 = join_path(home, "runtime/native/win32.ll")
# xinput.lib carries an import named DllMain, which lld skips with a warning on every
# link; it is the SDK's, not a mistake in the program, so it is silenced
# ole32 for WIC, which decodes the splash and the window icon
cmd = `{cmd} {win32} -luser32 -lgdi32 -lxinput -lole32 -Wl,/ignore:importeddllmain`
if g_uses_audio {
let audiow = join_path(home, "runtime/native/audio_win.ll")
cmd = `{cmd} {audiow} -lxaudio2 -lole32`
}
}
}
else { 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.
# On Windows the transport is http_win.ll, over WinHTTP.
# Job.* / Promise.* / Sync.* link the OS threads behind Job.parallel_for and Sync.*:
# threads.ll (pthreads) or threads_win.ll (Win32).
if g_uses_jobs {
if g_target_win { cmd = `{cmd} {join_path(home, "runtime/native/threads_win.ll")}` }
else { cmd = `{cmd} {join_path(home, "runtime/native/threads.ll")}` }
}
# Udp.* links the socket layer: BSD sockets from libSystem on macOS, Winsock (ws2_32) on
# Windows.
if g_uses_udp {
if g_target_win { cmd = `{cmd} {join_path(home, "runtime/native/udp_win.ll")} -lws2_32` }
else { cmd = `{cmd} {join_path(home, "runtime/native/udp.ll")}` }
}
# Process.* links the child-process layer: posix_spawn from libSystem on macOS,
# CreateProcessW from kernel32 on Windows.
# Crypto.random_* / Uuid.* read RtlGenRandom on Windows, which advapi32 exports
if g_target_win and g_uses_cryptort { cmd = `{cmd} -ladvapi32` }
if g_uses_process {
if g_target_win { cmd = `{cmd} {join_path(home, "runtime/native/process_win.ll")}` }
else { cmd = `{cmd} {join_path(home, "runtime/native/process.ll")}` }
}
if g_uses_http {
if g_target_win {
let httpw = join_path(home, "runtime/native/http_win.ll")
cmd = `{cmd} {httpw} -lwinhttp`
} else {
let http = join_path(home, "runtime/native/http.ll")
cmd = `{cmd} {http} -Wl,-needed_framework,Foundation`
}
}
# Gl.* links the OpenGL backend: gl.ll (offscreen contexts, float helpers), the
# generated per-entry-point ABI thunks, and OpenGL.framework. Windowed or not.
# On Windows the same pair is gl_win.ll (a WGL context) and gl_thunks_win.ll (the
# thunks through a table wglGetProcAddress fills), against opengl32/gdi32/user32,
# with the window half from win32_gl.ll - or, headless, gl_win_nowin.ll's stubs.
if g_uses_gl {
if g_target_win {
let glw = join_path(home, "runtime/native/gl_win.ll")
let gltw = join_path(home, "runtime/native/gl_thunks_win.ll")
var glhalf = join_path(home, "runtime/native/gl_win_nowin.ll")
if g_windowed { glhalf = join_path(home, "runtime/native/win32_gl.ll") }
cmd = `{cmd} {glw} {gltw} {glhalf} -luser32 -lgdi32 -lopengl32`
} else {
let gl = join_path(home, "runtime/native/gl.ll")
let glt = join_path(home, "runtime/native/gl_thunks.ll")
cmd = `{cmd} {gl} {glt} -framework OpenGL`
}
}
# Vk.* links the generated thunks and the loader that fills them at run time:
# vk_win.ll (vulkan-1.dll) on Windows, vk_mac.ll (libvulkan.1.dylib, MoltenVK) on
# macOS. Neither links a Vulkan library, so the program starts without one.
if g_uses_vk {
let vkt = join_path(home, "runtime/native/vk_thunks.ll")
var vkl = join_path(home, "runtime/native/vk_mac.ll")
if g_target_win { vkl = join_path(home, "runtime/native/vk_win.ll") }
cmd = `{cmd} {vkt} {vkl}`
}
# A shipped Windows game is a GUI-subsystem program: started from Explorer, a
# console-subsystem one opens a console window behind the game. mainCRTStartup keeps
# the C runtime's ordinary main() entry rather than WinMain.
if g_target_win and gui { cmd = `{cmd} -Wl,/subsystem:windows -Wl,/entry:mainCRTStartup` }
var li = 0
while li < len(links) { cmd = `{cmd} "{links[li]}"`; li += 1 }
cmd = `{cmd} -o {out}`
# cmd.exe takes the first and last quote off a line that starts with one and holds more
# than two - which a quoted clang path plus a quoted --link file is - so on Windows the
# whole line wears one more pair for it to remove
var line = cmd
if host_is_windows() { line = `"{cmd}"` }
let rc = run(line)
if not save { remove_file(ll) }
if rc != 0 { die("ludicc: link failed\n") }
# run mode: execute the binary we just built and forward its exit code.
# system() hands back a wait status on POSIX and the exit code itself on Windows.
if run {
if host_is_windows() { exit(run(`"{win_path(out)}"`)) }
let st = run(out)
exit(((st >> 8) & 255))
}
}