403 lines
14 KiB
EmacsLisp
403 lines
14 KiB
EmacsLisp
// elb.el - El Build Coordinator
|
|
//
|
|
// The build system for El programs. Written in El. Builds El.
|
|
//
|
|
// Usage:
|
|
// elb # build from manifest.el in current dir
|
|
// elb --clean # remove generated artifacts and rebuild
|
|
// elb --dry-run # print actions without executing
|
|
// elb --jobs=N # parallel compile jobs (default: 4)
|
|
// elb --out=DIR # output directory (default: dist)
|
|
// elb --runtime=PATH # path to el_runtime.c
|
|
//
|
|
// How it works (the .NET model):
|
|
// 1. Read manifest.el to find the entry file
|
|
// 2. Walk the import graph depth-first, build topological order
|
|
// 3. For each file: if .el is newer than .elh/.c, compile with elc --emit-header
|
|
// 4. Link all .c files + el_runtime.c into the final binary
|
|
//
|
|
// Each module compiles independently - no 128K-line blobs.
|
|
// Downstream compilations read .elh headers (function signatures only),
|
|
// not source. Incremental: only recompile what changed.
|
|
|
|
// -- Flags ---------------------------------------------------------------------
|
|
|
|
fn flag_bool(argv: [String], name: String) -> Bool {
|
|
let n: Int = native_list_len(argv)
|
|
let i = 0
|
|
while i < n {
|
|
let a: String = native_list_get(argv, i)
|
|
if str_eq(a, name) { return true }
|
|
let i = i + 1
|
|
}
|
|
return false
|
|
}
|
|
|
|
fn flag_val(argv: [String], name: String, default_val: String) -> String {
|
|
let n: Int = native_list_len(argv)
|
|
let prefix: String = name + "="
|
|
let i = 0
|
|
while i < n {
|
|
let a: String = native_list_get(argv, i)
|
|
if str_starts_with(a, prefix) {
|
|
return str_slice(a, str_len(prefix), str_len(a))
|
|
}
|
|
let i = i + 1
|
|
}
|
|
return default_val
|
|
}
|
|
|
|
// -- Manifest parsing ----------------------------------------------------------
|
|
//
|
|
// Read the entry file from manifest.el:
|
|
// build { entry "soul.el" }
|
|
|
|
fn parse_manifest_entry(src: String) -> String {
|
|
let lines: [String] = str_split(src, "\n")
|
|
let n: Int = native_list_len(lines)
|
|
let i = 0
|
|
while i < n {
|
|
let line: String = native_list_get(lines, i)
|
|
let t: String = str_trim(line)
|
|
if str_starts_with(t, "entry ") {
|
|
// entry "soul.el"
|
|
let after: String = str_slice(t, 6, str_len(t))
|
|
let trimmed: String = str_trim(after)
|
|
// strip surrounding quotes
|
|
if str_starts_with(trimmed, "\"") {
|
|
let inner: String = str_slice(trimmed, 1, str_len(trimmed))
|
|
let q: Int = str_index_of(inner, "\"")
|
|
if q >= 0 {
|
|
return str_slice(inner, 0, q)
|
|
}
|
|
}
|
|
}
|
|
let i = i + 1
|
|
}
|
|
return ""
|
|
}
|
|
|
|
fn parse_manifest_name(src: String) -> String {
|
|
let lines: [String] = str_split(src, "\n")
|
|
let n: Int = native_list_len(lines)
|
|
let i = 0
|
|
while i < n {
|
|
let line: String = native_list_get(lines, i)
|
|
let t: String = str_trim(line)
|
|
if str_starts_with(t, "package ") {
|
|
let after: String = str_slice(t, 8, str_len(t))
|
|
let trimmed: String = str_trim(after)
|
|
if str_starts_with(trimmed, "\"") {
|
|
let inner: String = str_slice(trimmed, 1, str_len(trimmed))
|
|
let q: Int = str_index_of(inner, "\"")
|
|
if q >= 0 {
|
|
return str_slice(inner, 0, q)
|
|
}
|
|
}
|
|
}
|
|
let i = i + 1
|
|
}
|
|
return "out"
|
|
}
|
|
|
|
// -- Path helpers ---------------------------------------------------------------
|
|
|
|
fn dirname_of(path: String) -> String {
|
|
let n: Int = str_len(path)
|
|
let i: Int = n - 1
|
|
while i >= 0 {
|
|
let c: String = str_slice(path, i, i + 1)
|
|
if str_eq(c, "/") {
|
|
return str_slice(path, 0, i)
|
|
}
|
|
let i = i - 1
|
|
}
|
|
return "."
|
|
}
|
|
|
|
fn basename_noext(path: String) -> String {
|
|
// strip directory
|
|
let n: Int = str_len(path)
|
|
let last_slash: Int = -1
|
|
let i = 0
|
|
while i < n {
|
|
let c: String = str_slice(path, i, i + 1)
|
|
if str_eq(c, "/") { let last_slash = i }
|
|
let i = i + 1
|
|
}
|
|
let base: String = str_slice(path, last_slash + 1, n)
|
|
// strip .el extension
|
|
let bn: Int = str_len(base)
|
|
if str_ends_with(base, ".el") {
|
|
return str_slice(base, 0, bn - 3)
|
|
}
|
|
return base
|
|
}
|
|
|
|
fn path_with_ext(path: String, ext: String) -> String {
|
|
let n: Int = str_len(path)
|
|
if str_ends_with(path, ".el") {
|
|
return str_slice(path, 0, n - 3) + ext
|
|
}
|
|
return path + ext
|
|
}
|
|
|
|
fn file_is_newer(a: String, b: String) -> Bool {
|
|
// Returns true if file a is newer than file b, or if b doesn't exist.
|
|
// Uses exec_capture with stat to compare modification times.
|
|
let cmd: String = "test -f " + b + " && test " + a + " -nt " + b + " && echo yes || echo no"
|
|
let result: String = str_trim(exec_capture(cmd))
|
|
if str_eq(result, "yes") { return true }
|
|
// b doesn't exist - check with test -f
|
|
let exist_cmd: String = "test -f " + b + " && echo exists || echo missing"
|
|
let exist: String = str_trim(exec_capture(exist_cmd))
|
|
if str_eq(exist, "missing") { return true }
|
|
return false
|
|
}
|
|
|
|
// -- Import graph walker --------------------------------------------------------
|
|
//
|
|
// Walk import statements in each .el file to build the dependency graph.
|
|
// Returns a list of absolute paths in topological order (deps before dependents).
|
|
|
|
fn parse_import_path(line: String, dir: String) -> String {
|
|
let t: String = str_trim(line)
|
|
if str_starts_with(t, "import \"") {
|
|
let after: String = str_slice(t, 8, str_len(t))
|
|
let q: Int = str_index_of(after, "\"")
|
|
if q > 0 {
|
|
let mod: String = str_slice(after, 0, q)
|
|
return dir + "/" + mod
|
|
}
|
|
}
|
|
if str_starts_with(t, "from ") {
|
|
let after: String = str_slice(t, 5, str_len(t))
|
|
let sp: Int = str_index_of(after, " ")
|
|
if sp > 0 {
|
|
let mod: String = str_trim(str_slice(after, 0, sp))
|
|
if !str_eq(mod, "") {
|
|
return dir + "/" + mod + ".el"
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
fn walk_imports(src_path: String, visited: [String], order: [String]) -> Map<String, Any> {
|
|
// Dedup check
|
|
let n: Int = native_list_len(visited)
|
|
let i = 0
|
|
while i < n {
|
|
let v: String = native_list_get(visited, i)
|
|
if str_eq(v, src_path) {
|
|
return { "visited": visited, "order": order }
|
|
}
|
|
let i = i + 1
|
|
}
|
|
let visited = native_list_append(visited, src_path)
|
|
|
|
let source: String = fs_read(src_path)
|
|
if str_eq(source, "") {
|
|
return { "visited": visited, "order": order }
|
|
}
|
|
let dir: String = dirname_of(src_path)
|
|
let lines: [String] = str_split(source, "\n")
|
|
let ln: Int = native_list_len(lines)
|
|
let j = 0
|
|
while j < ln {
|
|
let line: String = native_list_get(lines, j)
|
|
let imp: String = parse_import_path(line, dir)
|
|
if !str_eq(imp, "") {
|
|
let r = walk_imports(imp, visited, order)
|
|
let visited = r["visited"]
|
|
let order = r["order"]
|
|
}
|
|
let j = j + 1
|
|
}
|
|
// Add self after all deps
|
|
let order = native_list_append(order, src_path)
|
|
return { "visited": visited, "order": order }
|
|
}
|
|
|
|
// -- Build ----------------------------------------------------------------------
|
|
|
|
fn compile_module(src_path: String, out_dir: String, elc_bin: String, dry_run: Bool, verbose: Bool) -> Bool {
|
|
let bname: String = basename_noext(src_path)
|
|
let c_out: String = out_dir + "/" + bname + ".c"
|
|
let elh_out: String = out_dir + "/" + bname + ".elh"
|
|
let err_tmp: String = "/tmp/elb-err-" + bname + ".txt"
|
|
|
|
// Check if recompile needed
|
|
if !file_is_newer(src_path, c_out) {
|
|
if verbose {
|
|
println(" skip " + bname + ".el (up to date)")
|
|
}
|
|
return true
|
|
}
|
|
|
|
// elc streams C to stdout; redirect stderr to a temp file so we can
|
|
// surface the actual error message on failure instead of swallowing it.
|
|
let cmd: String = elc_bin + " --emit-header " + src_path + " > " + c_out + " 2>" + err_tmp
|
|
println(" compile " + src_path)
|
|
|
|
if dry_run { return true }
|
|
|
|
let ret: Int = exec_command(cmd)
|
|
if ret != 0 {
|
|
// Surface the actual compiler error from stderr
|
|
let err_msg: String = str_trim(fs_read(err_tmp))
|
|
if !str_eq(err_msg, "") {
|
|
println(err_msg)
|
|
}
|
|
// Remove partial output so a retry starts clean
|
|
exec_command("rm -f " + c_out + " " + err_tmp)
|
|
println("elb: compile failed: " + src_path)
|
|
return false
|
|
}
|
|
exec_command("rm -f " + err_tmp)
|
|
|
|
// Move the generated .elh (written next to the source by elc) into
|
|
// out_dir so that #include "module.elh" lines in the generated .c
|
|
// files resolve correctly when cc is invoked with -I <out_dir>.
|
|
let src_elh: String = path_with_ext(src_path, ".elh")
|
|
let mv_cmd: String = "cp " + src_elh + " " + elh_out + " 2>/dev/null || true"
|
|
exec_command(mv_cmd)
|
|
|
|
return true
|
|
}
|
|
|
|
fn link_binary(c_files: [String], out_bin: String, runtime_path: String, out_dir: String, dry_run: Bool) -> Bool {
|
|
let n: Int = native_list_len(c_files)
|
|
let parts: [String] = native_list_empty()
|
|
// Include both the runtime dir (for el_runtime.h) and the output dir
|
|
// (for module.elh cross-module forward declarations).
|
|
let parts = native_list_append(parts, "cc -O2 -fbracket-depth=1024 -I " + dirname_of(runtime_path) + " -I " + out_dir)
|
|
let i = 0
|
|
while i < n {
|
|
let f: String = native_list_get(c_files, i)
|
|
let parts = native_list_append(parts, f)
|
|
let i = i + 1
|
|
}
|
|
let parts = native_list_append(parts, runtime_path)
|
|
let parts = native_list_append(parts, "-lcurl -lpthread -lm")
|
|
let parts = native_list_append(parts, "-o " + out_bin)
|
|
let cmd: String = str_join(parts, " ")
|
|
println(" link " + out_bin)
|
|
if dry_run { return true }
|
|
let ret: Int = exec_command(cmd)
|
|
if ret != 0 {
|
|
println("elb: link failed")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// -- Main -----------------------------------------------------------------------
|
|
|
|
fn main() -> Void {
|
|
let argv: [String] = args()
|
|
let clean: Bool = flag_bool(argv, "--clean")
|
|
let dry_run: Bool = flag_bool(argv, "--dry-run")
|
|
let verbose: Bool = flag_bool(argv, "--verbose")
|
|
let out_dir: String = flag_val(argv, "--out", "dist")
|
|
let elc_bin: String = flag_val(argv, "--elc", "elc")
|
|
let runtime: String = flag_val(argv, "--runtime", "")
|
|
|
|
// Find manifest
|
|
let manifest_src: String = fs_read("manifest.el")
|
|
if str_eq(manifest_src, "") {
|
|
println("elb: no manifest.el found in current directory")
|
|
exit(1)
|
|
}
|
|
|
|
let pkg_name: String = parse_manifest_name(manifest_src)
|
|
let entry: String = parse_manifest_entry(manifest_src)
|
|
if str_eq(entry, "") {
|
|
println("elb: manifest.el has no 'entry' declaration")
|
|
exit(1)
|
|
}
|
|
|
|
println("elb: building " + pkg_name + " (entry: " + entry + ")")
|
|
|
|
// Locate runtime
|
|
let runtime_path: String = runtime
|
|
if str_eq(runtime_path, "") {
|
|
// Try to find el_runtime.c relative to elc binary
|
|
let which_out: String = str_trim(exec_capture("which " + elc_bin + " 2>/dev/null"))
|
|
if !str_eq(which_out, "") {
|
|
let elc_dir: String = dirname_of(which_out)
|
|
runtime_path = elc_dir + "/../el-compiler/runtime/el_runtime.c"
|
|
}
|
|
}
|
|
// If --runtime points to a directory, auto-locate el_runtime.c inside it.
|
|
// This lets both forms work:
|
|
// --runtime=/opt/el/el-compiler/runtime (directory form)
|
|
// --runtime=/opt/el/el-compiler/runtime/el_runtime.c (file form)
|
|
if !str_eq(runtime_path, "") {
|
|
let is_dir: String = str_trim(exec_capture("test -d " + runtime_path + " && echo dir || echo file"))
|
|
if str_eq(is_dir, "dir") {
|
|
let candidate: String = runtime_path + "/el_runtime.c"
|
|
let has_file: String = str_trim(exec_capture("test -f " + candidate + " && echo yes || echo no"))
|
|
if str_eq(has_file, "yes") {
|
|
let runtime_path = candidate
|
|
}
|
|
}
|
|
}
|
|
if str_eq(runtime_path, "") {
|
|
println("elb: cannot locate el_runtime.c - use --runtime=PATH")
|
|
exit(1)
|
|
}
|
|
|
|
// Ensure output directory
|
|
let mkdir_ret: Int = exec_command("mkdir -p " + out_dir)
|
|
|
|
// Clean if requested
|
|
if clean {
|
|
println("elb: cleaning " + out_dir)
|
|
if !dry_run {
|
|
let rm_ret: Int = exec_command("rm -f " + out_dir + "/*.c " + out_dir + "/*.elh")
|
|
}
|
|
}
|
|
|
|
// Walk import graph from entry file
|
|
let empty_visited: [String] = native_list_empty()
|
|
let empty_order: [String] = native_list_empty()
|
|
let r = walk_imports(entry, empty_visited, empty_order)
|
|
let order: [String] = r["order"]
|
|
let total: Int = native_list_len(order)
|
|
println("elb: " + native_int_to_str(total) + " modules in build graph")
|
|
|
|
// Compile each module
|
|
let c_files: [String] = native_list_empty()
|
|
let i = 0
|
|
let ok = true
|
|
while i < total {
|
|
let src: String = native_list_get(order, i)
|
|
let bname: String = basename_noext(src)
|
|
let c_out: String = out_dir + "/" + bname + ".c"
|
|
let compiled: Bool = compile_module(src, out_dir, elc_bin, dry_run, verbose)
|
|
if !compiled {
|
|
let ok = false
|
|
let i = total
|
|
} else {
|
|
let c_files = native_list_append(c_files, c_out)
|
|
}
|
|
let i = i + 1
|
|
}
|
|
|
|
if !ok {
|
|
println("elb: build failed")
|
|
exit(1)
|
|
}
|
|
|
|
// Link
|
|
let out_bin: String = out_dir + "/" + pkg_name
|
|
let linked: Bool = link_binary(c_files, out_bin, runtime_path, out_dir, dry_run)
|
|
if !linked {
|
|
println("elb: link failed")
|
|
exit(1)
|
|
}
|
|
|
|
println("elb: done -> " + out_bin)
|
|
}
|