commit cbef06a
shrub
·
2026-11-03 18:16:43 +0000 UTC
parent a739960
add handling for zig
10 files changed,
+523,
-21
M
README
+2,
-1
1@@ -10,7 +10,8 @@ todo
2 [X] core build engine
3 [X] higher level handling for C
4 [X] higher level handling for Odin
5-[ ] higher level handling for Zig
6+[X] higher level handling for Zig
7+[ ] higher level handling for Rust
8 [ ] extensions for doing configure script style stuff easily(checking whats installed on the system)
9 [ ] become part of the revo stdlib!
10
M
build.rv
+194,
-9
1@@ -1,23 +1,180 @@
2 const rules = {}
3+const settings = {output_dir = "out"}
4
5+fn clean_path(path) do
6+ const parts = {}
7+ for part in path:split("/") do
8+ if part == ".." do
9+ panic("output paths cannot contain '..':", path)
10+ end
11+ if part != "" and part != "." do
12+ parts:push(part)
13+ end
14+ end
15+ const result = table.join(parts, "/")
16+ if (result == "") panic("empty output path:", path)
17+ if (path:starts_with?("/")) return "/" ~ result
18+ return result
19+end
20+
21+#* set output_dir before declaring tasks or rules; defaults to out *#
22+pub fn configure(opts) do
23+ if (not table.empty?(rules)) panic("configure the engine before declaring tasks or rules")
24+ for key in table.keys(opts) do
25+ if (key != :output_dir) panic("unknown build setting:", key)
26+ settings.output_dir = clean_path(opts[key])
27+ end
28+end
29+
30+#* resolve an output name under output_dir; already resolved paths are preserved *#
31+pub fn output(name) do
32+ const path = clean_path(name)
33+ const prefix = settings.output_dir ~ "/"
34+ let resolved = path
35+ if not path:starts_with?(prefix) do
36+ if (path:starts_with?("/")) panic("output is outside output_dir:", name)
37+ resolved = prefix ~ path
38+ end
39+ const cache = prefix ~ ".rvb"
40+ if resolved == cache or resolved:starts_with?(cache ~ "/") do
41+ panic("output path is reserved for the build cache:", name)
42+ end
43+ return resolved
44+end
45+
46+fn target(name) do
47+ if (table.key?(rules, name)) return name
48+ return output(name)
49+end
50+
51+fn cache_path() do
52+ return settings.output_dir ~ "/.rvb/state.json"
53+end
54+
55+#*
56+ create a json record for a rule, eg compiler args. if it changes the rule will rebuild.
57+*#
58+pub fn track(name, configuration) do
59+ const path = target(name)
60+ if (not table.key?(rules, path)) panic("unknown task:", name)
61+ rules[path].configuration = json.encode(configuration):unwrap()
62+end
63+
64+fn signature(entry) do
65+ const deps = table.sort(table.copy(entry.deps))
66+ const configuration = table.key(entry, :configuration) orelse ""
67+ return json.encode({deps, configuration}):unwrap()
68+end
69+
70+fn load_state() do
71+ const path = cache_path()
72+ if (not fs.exists?(path)) return {}
73+ const file = fs.open(path):unwrap()
74+ const text = file:read():unwrap()
75+ file:close():unwrap()
76+
77+ match json.decode(text)
78+ | {:ok, entries} => do
79+ const state = {}
80+ if (typeof(entries) != :table) return state
81+ for entry in entries do
82+ if typeof(entry) == :table and table.len(entry) == 2 do
83+ if typeof(entry[0]) == :string and typeof(entry[1]) == :string do
84+ state[entry[0]] = entry[1]
85+ end
86+ end
87+ end
88+ return state
89+ end
90+ | _ => return {}
91+end
92+
93+fn save_state(state) do
94+ const directory = settings.output_dir ~ "/.rvb"
95+ fs.mkdir(directory, :true):unwrap()
96+ const path = cache_path()
97+ const temporary = path ~ ".tmp"
98+ const file = fs.open(temporary, "w"):unwrap()
99+ const entries = {}
100+ for name in table.sort(table.keys(state)) do
101+ entries:push({name, state[name]})
102+ end
103+ const text = json.encode(entries):unwrap()
104+ file:write(text):unwrap()
105+ file:close():unwrap()
106+ return fs.rename(temporary, path):unwrap()
107+end
108+
109+#* declare a task that always runs when selected, after its dependencies. action takes no arguments *#
110 pub fn task(name, deps, action) do
111 if (table.key?(rules, name)) panic("duplicate task:", name)
112 rules[name] = {deps = table.unique(deps), action = action, phony = :true}
113 end
114
115-# a file rule must create the file named by path
116-pub fn rule(path, deps, action) do
117+#*
118+ declare a file under output_dir and return its resolved path.
119+ action takes no arguments and must create that path; use output(name) inside custom actions.
120+ runs when the output is missing, inputs change, or a dependency rebuilds.
121+*#
122+pub fn rule(name, deps, action) do
123+ const path = output(name)
124 if (table.key?(rules, path)) panic("duplicate task:", path)
125 rules[path] = {deps = table.unique(deps), action = action, phony = :false}
126+ return path
127 end
128
129+#* declare a generated file, write it only when its content changes *#
130+pub fn generated(name, text, deps) do
131+ const path = output(name)
132+ # changed text forces dependents to rebuild even within the same timestamp
133+ let changed = :true
134+
135+ if fs.exists?(path) do
136+ const existing = fs.open(path):unwrap()
137+ const previous = existing:read():unwrap()
138+ existing:close():unwrap()
139+
140+ changed = previous != text
141+ end
142+
143+ rule(path, deps, fn() do
144+ if fs.exists?(path) do
145+ const existing = fs.open(path):unwrap()
146+ const previous = existing:read():unwrap()
147+ existing:close():unwrap()
148+
149+ if previous == text do
150+ return :ok
151+ end
152+ end
153+
154+ const parts = path:split("/")
155+ parts:pop()
156+ const dir = table.join(parts, "/")
157+
158+ if dir != "" do
159+ fs.mkdir(dir, :true):unwrap()
160+ end
161+
162+ const output = fs.open(path, "w"):unwrap()
163+ output:write(text):unwrap()
164+ return output:close():unwrap()
165+ end)
166+
167+ track(path, text)
168+ rules[path].force = changed
169+ return path
170+end
171+
172+#* set the outputed label for an existing task or rule *#
173 pub fn describe(name, text) do
174- if (not table.key?(rules, name)) panic("unknown task:", name)
175- const entry = rules[name]
176+ const path = target(name)
177+ if (not table.key?(rules, path)) panic("unknown task:", name)
178+ const entry = rules[path]
179 entry.description = text
180 end
181
182-# return true when an output is missing or older than an input
183+#* return :true when output is missing or older than an input; panic on a missing input if output exists *#
184 pub fn timestamp(output, inputs) do
185 if (not fs.exists?(output)) return :true
186 const output_time = fs.stat(output):unwrap().mtime
187@@ -47,11 +204,15 @@ fn visit(name, state, order) do
188 state[name] = :done
189 end
190
191+#*
192+ return selected tasks and rules in dependency order using DFS. they are not executed.
193+ dependencies may name existing input files, cycles and missing inputs cause a panic.
194+*#
195 pub fn mkplan(names) do
196 const state = {}
197 const order = {}
198 for name in names do
199- visit(name, state, order)
200+ visit(target(name), state, order)
201 end
202 return order
203 end
204@@ -63,9 +224,11 @@ fn release(name, pending, followers, ready) do
205 end
206 end
207
208-fn dirty(name, rebuilt) do
209+fn dirty(name, rebuilt, state) do
210 const entry = rules[name]
211 if (entry.phony) return :true
212+ if (table.key(entry, :force) orelse :false) return :true
213+ if (state[name] != signature(entry)) return :true
214 for dep in entry.deps do
215 if table.key?(rules, dep) do
216 if (rules[dep].phony or rebuilt[dep] == :true) return :true
217@@ -75,6 +238,11 @@ fn dirty(name, rebuilt) do
218 end
219
220 fn execute(name, completed) do
221+ if not rules[name].phony do
222+ const parts = name:split("/")
223+ parts:pop()
224+ fs.mkdir(table.join(parts, "/"), :true):unwrap()
225+ end
226 const action = rules[name].action
227 match action()
228 | {:err, reason} => do
229@@ -90,11 +258,17 @@ fn execute(name, completed) do
230 send(completed, name)
231 end
232
233+#*
234+ build the selected target names using kahn's algorithm, cocurrently
235+ skips up-to-date file rules and save state after a successful run
236+*#
237 pub fn kahn(plan, jobs) do
238 if (jobs < 1 or jobs != number.floor(jobs))
239 panic("jobs must be a positive integer")
240
241 const nodes = mkplan(plan)
242+ const state = load_state()
243+ let state_changed = :false
244 const pending = {}
245 const followers = {}
246 const ready = {}
247@@ -135,7 +309,7 @@ pub fn kahn(plan, jobs) do
248 end
249 ready_index += 1
250 reported += 1
251- if dirty(name, rebuilt) do
252+ if dirty(name, rebuilt, state) do
253 print(fmt("[%v/%v] %v", reported, total, description))
254 handles[name] = spawn execute(name, completed)
255 active += 1
256@@ -153,15 +327,25 @@ pub fn kahn(plan, jobs) do
257 active -= 1
258 finished += 1
259 rebuilt[name] = :true
260+ if not rules[name].phony do
261+ state[name] = signature(rules[name])
262+ state_changed = :true
263+ end
264 release(name, pending, followers, ready)
265 end else do
266 if (finished < total) panic("dependency cycle in build plan")
267 end
268 end
269+ if state_changed do
270+ save_state(state)
271+ end
272 return :ok
273 end
274
275-# take a target argument
276+#*
277+ build target names passed after the manifest on the command line, or defaults if none.
278+ use revo.threads() as the maximum number of concurrent actions.
279+*#
280 pub fn run(defaults) do
281 if table.len(argv) > 1 do
282 const targets = {}
283@@ -182,6 +366,7 @@ fn showout(text) do
284 print(text)
285 end
286
287+#* execute a command argument list without a shell. print captured output, and exit on failure *#
288 pub fn cmd(args) do
289 const label = table.join(args, " ")
290
+7,
-4
1@@ -77,7 +77,8 @@ fn output_rule(kind: atom, path: string, opts: table) -> string do
2 else
3 source
4
5- const object = path ~ ".obj/" ~ stem ~ ".o"
6+ const object_name = stem:split("/"):last()
7+ const object = path ~ ".obj/" ~ object_name ~ ".o"
8 const args = cc:merge(combined(opts, :flags))
9
10 for dir in combined(opts, :include_dirs) do
11@@ -102,6 +103,7 @@ fn output_rule(kind: atom, path: string, opts: table) -> string do
12
13 const inputs = deps:merge({source})
14 engine.rule(object, inputs, action(object, args))
15+ engine.track(object, args)
16
17 const label = "cc " ~ source
18 engine.describe(object, label)
19@@ -141,6 +143,7 @@ fn output_rule(kind: atom, path: string, opts: table) -> string do
20 end
21
22 engine.rule(path, inputs, action(path, args))
23+ engine.track(path, args)
24
25 const prefixes = {executable = "ld ", static = "ar ", shared = "shared "}
26 const link_label = prefixes[kind] ~ path
27@@ -151,15 +154,15 @@ end
28
29 #* build an executable *#
30 pub fn executable(path: string, opts: table) -> string
31- return output_rule(:executable, path, opts)
32+ return output_rule(:executable, engine.output(path), opts)
33
34 #* build an static library *#
35 pub fn static_library(path: string, opts: table) -> string
36- return output_rule(:static, path, opts)
37+ return output_rule(:static, engine.output(path), opts)
38
39 #* build an shared library *#
40 pub fn shared_library(path: string, opts: table) -> string
41- return output_rule(:shared, path, opts)
42+ return output_rule(:shared, engine.output(path), opts)
43
44 fn quote(arg: string) -> string do
45 if arg:len() > 0
+4,
-3
1@@ -136,6 +136,7 @@ fn output_rule(kind: atom, path: string, opts: table) -> string do
2 :merge({package, argv[0]})
3
4 engine.rule(path, inputs, action(path, command))
5+ engine.track(path, command)
6
7 const prefixes = {executable = "odin ", static = "odin static ", shared = "odin shared "}
8 const label = prefixes[kind] ~ path
9@@ -146,15 +147,15 @@ end
10
11 #* build an executable from an odin package directory *#
12 pub fn executable(path: string, opts: table) -> string
13- return output_rule(:executable, path, opts)
14+ return output_rule(:executable, engine.output(path), opts)
15
16 #* build a static library *#
17 pub fn static_library(path: string, opts: table) -> string
18- return output_rule(:static, path, opts)
19+ return output_rule(:static, engine.output(path), opts)
20
21 #* build a shared library *#
22 pub fn shared_library(path: string, opts: table) -> string
23- return output_rule(:shared, path, opts)
24+ return output_rule(:shared, engine.output(path), opts)
25
26 pub fn build(outputs: table<string>) -> :ok do
27 return engine.run(outputs)
+274,
-0
1@@ -0,0 +1,274 @@
2+const engine = import "./build.rv"
3+
4+const settings = {
5+ compiler = {"zig"},
6+ target = "",
7+ cpu = "",
8+ optimize = "",
9+ code_model = "",
10+ red_zone = :nil,
11+ single_threaded = :nil,
12+ pic = :nil,
13+ link_libc = :false,
14+ flags = {},
15+ module_flags = {},
16+ source_dirs = {},
17+ deps = {},
18+}
19+
20+type Flags = table<string>
21+ let next_module = 0
22+
23+#* override the global zig settings *#
24+pub fn configure(opts: table) -> :nil do
25+ for key in table.keys(opts) do
26+ if not table.key?(settings, key) do
27+ panic("unknown Zig setting:", key)
28+ end
29+
30+ settings[key] = opts[key]
31+ end
32+
33+ return :nil
34+end
35+
36+fn key_name(key: any) -> string do
37+ const name = string(key)
38+
39+ if typeof(key) == :atom do
40+ return name:sub(1, name:len() - 1)
41+ end
42+
43+ return name
44+end
45+
46+#* declare a compiler module, imports map local names to other module handles *#
47+pub fn module(opts: table) -> table do
48+ const options = settings:merge(opts)
49+ options.module_flags = settings.module_flags:merge(table.key(opts, :module_flags) orelse {})
50+ options.deps = settings.deps:merge(table.key(opts, :deps) orelse {})
51+
52+ const id = "m" ~ string(next_module)
53+ next_module += 1
54+ const imports = {}
55+ const declared_imports = table.key(opts, :imports) orelse {}
56+
57+ for key in table.keys(declared_imports) do
58+ const name = key_name(key)
59+ imports[name] = declared_imports[key]
60+ end
61+
62+ return {
63+ id = id,
64+ root = opts.root,
65+ options = options,
66+ imports = imports,
67+ }
68+end
69+
70+#* connect modules before declaring their output *#
71+pub fn add_import(owner: table, name: string, imported: table) -> :nil do
72+ owner.imports[name] = imported
73+ return :nil
74+end
75+
76+#* a file for @embedFile *#
77+pub fn file(path: string) -> table do
78+ return module({root = path, raw = :true})
79+end
80+
81+fn collect(entry: table, seen: table, modules: table) -> :nil do
82+ if table.key?(seen, entry.id) do
83+ return :nil
84+ end
85+
86+ seen[entry.id] = :true
87+ modules:push(entry)
88+
89+ for key in table.sort(table.keys(entry.imports)) do
90+ collect(entry.imports[key], seen, modules)
91+ end
92+
93+ return :nil
94+end
95+
96+# translate settings into something the zig cli can understand
97+fn module_args(entry: table) -> Flags do
98+ const args: Flags = {}
99+ const opts = entry.options
100+
101+ if not (table.key(opts, :raw) orelse :false) do
102+ for pair in {
103+ {:target, "-target"}, {:cpu, "-mcpu"},
104+ {:optimize, "-O"}, {:code_model, "-mcmodel"},
105+ } do
106+ const value = opts[pair[0]]
107+
108+ if value != "" do
109+ args:push(pair[1], value)
110+ end
111+ end
112+
113+ for pair in {
114+ {:red_zone, "-mred-zone", "-mno-red-zone"},
115+ {:single_threaded, "-fsingle-threaded", "-fno-single-threaded"},
116+ {:pic, "-fPIC", "-fno-PIC"},
117+ } do
118+ const value = opts[pair[0]]
119+
120+ if value == :true do
121+ args:push(pair[1])
122+ end else if value == :false do
123+ args:push(pair[2])
124+ end
125+ end
126+
127+ if opts.link_libc do
128+ args:push("-lc")
129+ end
130+ end
131+
132+ for key in table.sort(table.keys(entry.imports)) do
133+ const name = key_name(key)
134+ const dependency = entry.imports[key]
135+ const mapping = name ~ "=" ~ dependency.id
136+ args:push("--dep", mapping)
137+ end
138+
139+ return args
140+ :merge(opts.module_flags)
141+ :merge({"-M" ~ entry.id ~ "=" ~ entry.root})
142+end
143+
144+fn action(path: string, args: Flags) -> (fn() -> :ok) do
145+ return fn() do
146+ const parts = path:split("/")
147+ parts:pop()
148+ const dir = table.join(parts, "/")
149+
150+ if dir != "" do
151+ fs.mkdir(dir, :true):unwrap()
152+ end
153+
154+ engine.cmd(args)
155+ return fs.rename(path ~ ".tmp", path):unwrap()
156+ end
157+end
158+
159+#* track every file in declared source trees *#
160+fn source_files(dir: string, inputs: Flags) -> :nil do
161+ for entry in fs.readdir(dir):unwrap() do
162+ const path = dir ~ "/" ~ entry.name
163+
164+ if entry.kind == :directory do
165+ source_files(path, inputs)
166+ end else if entry.kind == :file do
167+ inputs:push(path)
168+ end
169+ end
170+
171+ return :nil
172+end
173+
174+fn output_rule(kind: atom, path: string, opts: table) -> string do
175+ const root = table.key(opts, :root_module) orelse module(opts)
176+ const modules = {}
177+ collect(root, {}, modules)
178+
179+ const compiler = table.key(opts, :compiler) orelse settings.compiler
180+ const commands = {executable = "build-exe", static = "build-lib", shared = "build-lib", object = "build-obj"}
181+ let args = compiler:merge({commands[kind]})
182+
183+ if kind == :shared do
184+ const basename = path:split("/"):last()
185+ const soname = "-fsoname=" ~ basename
186+ args:push("-dynamic", soname)
187+ end
188+
189+ const entry = table.key(opts, :entry) orelse ""
190+
191+ if entry != "" do
192+ const symbol = "-fentry=" ~ entry
193+ args:push(symbol)
194+ end
195+
196+ const pie = table.key(opts, :pie)
197+
198+ if pie == :true do
199+ args:push("-fPIE")
200+ end else if pie == :false do
201+ args:push("-fno-PIE")
202+ end
203+
204+ const inputs = settings.deps:merge(table.key(opts, :deps) orelse {})
205+ const script = table.key(opts, :linker_script) orelse ""
206+
207+ if script != "" do
208+ args:push("--script", script)
209+ inputs:push(script)
210+ end
211+
212+ const links = table.key(opts, :link_files) orelse {}
213+ args = args
214+ :merge(settings.flags)
215+ :merge(table.key(opts, :flags) orelse {})
216+ :merge(links)
217+
218+ inputs:push(argv[0])
219+
220+ for dependency in links do
221+ inputs:push(dependency)
222+ end
223+
224+ for entry_module in modules do
225+ inputs:push(entry_module.root)
226+ const dependencies = entry_module.options.deps
227+
228+ for dependency in dependencies do
229+ inputs:push(dependency)
230+ end
231+
232+ args = args:merge(module_args(entry_module))
233+ end
234+
235+ const emitted = "-femit-bin=" ~ path ~ ".tmp"
236+ args:push(emitted)
237+
238+ const dirs = settings.source_dirs:merge(table.key(opts, :source_dirs) orelse {})
239+
240+ for dir in table.unique(dirs) do
241+ source_files(dir, inputs)
242+ end
243+
244+ if table.empty?(dirs) do
245+ engine.task(path, inputs, action(path, args))
246+ end else do
247+ engine.rule(path, inputs, action(path, args))
248+ end
249+ engine.track(path, args)
250+ const label = "zig " ~ path
251+ engine.describe(path, label)
252+
253+ return path
254+end
255+
256+#* build an executable *#
257+pub fn executable(path: string, opts: table) -> string
258+ return output_rule(:executable, engine.output(path), opts)
259+
260+#* build a static library *#
261+pub fn static_library(path: string, opts: table) -> string
262+ return output_rule(:static, engine.output(path), opts)
263+
264+#* build a shared library *#
265+pub fn shared_library(path: string, opts: table) -> string
266+ return output_rule(:shared, engine.output(path), opts)
267+
268+#* build an object file *#
269+pub fn object(path: string, opts: table) -> string
270+ return output_rule(:object, engine.output(path), opts)
271+
272+#* build command-line targets, or the given outputs if none are specified *#
273+pub fn build(outputs: Flags) -> :ok do
274+ return engine.run(outputs)
275+end
+3,
-3
1@@ -14,9 +14,9 @@ c.configure({
2 deps = {"calc.h"},
3 })
4
5-const app = c.executable("out/calculator", {
6- sources = sources,
7- cc = {"cc"}} # we can also override settings only for specific outputs
8+const app = c.executable("calculator", {
9+ sources = sources,
10+ cc = {"cc"}} # we can also override settings only for specific outputs
11 )
12
13 c.build({app})
+1,
-1
1@@ -4,7 +4,7 @@ odin.configure({
2 optimize = "speed",
3 })
4
5-const app = odin.executable("out/calculator", {
6+const app = odin.executable("calculator", {
7 package = ".",
8 debug = :true,
9 defines = {SHOW_DETAILS = :true},
+14,
-0
1@@ -0,0 +1,14 @@
2+const zig = import "../../buildzig.rv"
3+
4+zig.configure({
5+ optimize = "safe",
6+ source_dirs = {"src"},
7+})
8+
9+const arithmetic = zig.module({root = "src/arithmetic.zig"})
10+const app = zig.executable("calculator", {
11+ root = "src/main.zig",
12+ imports = {arithmetic = arithmetic},
13+})
14+
15+zig.build({app})
+15,
-0
1@@ -0,0 +1,15 @@
2+pub fn add(a: i32, b: i32) i32 {
3+ return a + b;
4+}
5+
6+pub fn subtract(a: i32, b: i32) i32 {
7+ return a - b;
8+}
9+
10+pub fn multiply(a: i32, b: i32) i32 {
11+ return a * b;
12+}
13+
14+pub fn divide(a: i32, b: i32) i32 {
15+ return @divTrunc(a, b);
16+}
+9,
-0
1@@ -0,0 +1,9 @@
2+const std = @import("std");
3+const arithmetic = @import("arithmetic");
4+
5+pub fn main() void {
6+ std.debug.print("6 + 3 = {d}\n", .{arithmetic.add(6, 3)});
7+ std.debug.print("6 - 3 = {d}\n", .{arithmetic.subtract(6, 3)});
8+ std.debug.print("6 * 3 = {d}\n", .{arithmetic.multiply(6, 3)});
9+ std.debug.print("6 / 3 = {d}\n", .{arithmetic.divide(6, 3)});
10+}