commit cbef06a

shrub  ·  2026-11-03 18:16:43 +0000 UTC
parent a739960
add handling for zig
10 files changed,  +523, -21
M README
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 
+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+}