main
build.rv
1const rules = {}
2const settings = {output_dir = "out"}
3
4fn clean_path(path) do
5 const parts = {}
6 for part in path:split("/") do
7 if part == ".." do
8 panic("output paths cannot contain '..':", path)
9 end
10 if part != "" and part != "." do
11 parts:push(part)
12 end
13 end
14 const result = table.join(parts, "/")
15 if (result == "") panic("empty output path:", path)
16 if (path:starts_with?("/")) return "/" ~ result
17 return result
18end
19
20#* set output_dir before declaring tasks or rules; defaults to out *#
21pub fn configure(opts) do
22 if (not table.empty?(rules)) panic("configure the engine before declaring tasks or rules")
23 for key in table.keys(opts) do
24 if (key != :output_dir) panic("unknown build setting:", key)
25 settings.output_dir = clean_path(opts[key])
26 end
27end
28
29#* resolve an output name under output_dir; already resolved paths are preserved *#
30pub fn output(name) do
31 const path = clean_path(name)
32 const prefix = settings.output_dir ~ "/"
33 let resolved = path
34 if not path:starts_with?(prefix) do
35 if (path:starts_with?("/")) panic("output is outside output_dir:", name)
36 resolved = prefix ~ path
37 end
38 const cache = prefix ~ ".rvb"
39 if resolved == cache or resolved:starts_with?(cache ~ "/") do
40 panic("output path is reserved for the build cache:", name)
41 end
42 return resolved
43end
44
45fn target(name) do
46 if (table.key?(rules, name)) return name
47 return output(name)
48end
49
50fn cache_path() do
51 return settings.output_dir ~ "/.rvb/state.json"
52end
53
54#*
55 create a json record for a rule, eg compiler args. if it changes the rule will rebuild.
56*#
57pub fn track(name, configuration) do
58 const path = target(name)
59 if (not table.key?(rules, path)) panic("unknown task:", name)
60 rules[path].configuration = json.encode(configuration):unwrap()
61end
62
63fn signature(entry) do
64 const deps = table.sort(table.copy(entry.deps))
65 const configuration = table.key(entry, :configuration) orelse ""
66 return json.encode({deps, configuration}):unwrap()
67end
68
69fn load_state() do
70 const path = cache_path()
71 if (not fs.exists?(path)) return {}
72 const file = fs.open(path):unwrap()
73 const text = file:read():unwrap()
74 file:close():unwrap()
75
76 match json.decode(text)
77 | {:ok, entries} => do
78 const state = {}
79 if (typeof(entries) != :table) return state
80 for entry in entries do
81 if typeof(entry) == :table and table.len(entry) == 2 do
82 if typeof(entry[0]) == :string and typeof(entry[1]) == :string do
83 state[entry[0]] = entry[1]
84 end
85 end
86 end
87 return state
88 end
89 | _ => return {}
90end
91
92fn save_state(state) do
93 const directory = settings.output_dir ~ "/.rvb"
94 fs.mkdir(directory, :true):unwrap()
95 const path = cache_path()
96 const temporary = path ~ ".tmp"
97 const file = fs.open(temporary, "w"):unwrap()
98 const entries = {}
99 for name in table.sort(table.keys(state)) do
100 entries:push({name, state[name]})
101 end
102 const text = json.encode(entries):unwrap()
103 file:write(text):unwrap()
104 file:close():unwrap()
105 return fs.rename(temporary, path):unwrap()
106end
107
108#* declare a task that always runs when selected, after its dependencies. action takes no arguments *#
109pub fn task(name, deps, action) do
110 if (table.key?(rules, name)) panic("duplicate task:", name)
111 rules[name] = {deps = table.unique(deps), action = action, phony = :true}
112end
113
114#*
115 declare a file under output_dir and return its resolved path.
116 action takes no arguments and must create that path; use output(name) inside custom actions.
117 runs when the output is missing, inputs change, or a dependency rebuilds.
118*#
119pub fn rule(name, deps, action) do
120 const path = output(name)
121 if (table.key?(rules, path)) panic("duplicate task:", path)
122 rules[path] = {deps = table.unique(deps), action = action, phony = :false}
123 return path
124end
125
126#* declare a generated file, write it only when its content changes *#
127pub fn generated(name, text, deps) do
128 const path = output(name)
129 # changed text forces dependents to rebuild even within the same timestamp
130 let changed = :true
131
132 if fs.exists?(path) do
133 const existing = fs.open(path):unwrap()
134 const previous = existing:read():unwrap()
135 existing:close():unwrap()
136
137 changed = previous != text
138 end
139
140 rule(path, deps, fn() do
141 if fs.exists?(path) do
142 const existing = fs.open(path):unwrap()
143 const previous = existing:read():unwrap()
144 existing:close():unwrap()
145
146 if previous == text do
147 return :ok
148 end
149 end
150
151 const parts = path:split("/")
152 parts:pop()
153 const dir = table.join(parts, "/")
154
155 if dir != "" do
156 fs.mkdir(dir, :true):unwrap()
157 end
158
159 const output = fs.open(path, "w"):unwrap()
160 output:write(text):unwrap()
161 return output:close():unwrap()
162 end)
163
164 track(path, text)
165 rules[path].force = changed
166 return path
167end
168
169#* set the outputed label for an existing task or rule *#
170pub fn describe(name, text) do
171 const path = target(name)
172 if (not table.key?(rules, path)) panic("unknown task:", name)
173 const entry = rules[path]
174 entry.description = text
175end
176
177#* return :true when output is missing or older than an input; panic on a missing input if output exists *#
178pub fn timestamp(output, inputs) do
179 if (not fs.exists?(output)) return :true
180 const output_time = fs.stat(output):unwrap().mtime
181 for input in inputs do
182 if (not fs.exists?(input)) panic("missing input:", input)
183 if (fs.stat(input):unwrap().mtime > output_time) return :true
184 end
185 return :false
186end
187
188fn visit(name, state, order) do
189 if (state[name] == :done) return :ok
190 if (state[name] == :visiting) panic("dep cycle:", name)
191
192 if not table.key?(rules, name) do
193 if (fs.exists?(name)) return :ok
194 panic("unknown task or missing input:", name)
195 end
196 const entry = rules[name]
197
198 state[name] = :visiting
199 for dep in entry.deps do
200 visit(dep, state, order)
201 end
202
203 order:push(name)
204 state[name] = :done
205end
206
207#*
208 return selected tasks and rules in dependency order using DFS. they are not executed.
209 dependencies may name existing input files, cycles and missing inputs cause a panic.
210*#
211pub fn mkplan(names) do
212 const state = {}
213 const order = {}
214 for name in names do
215 visit(target(name), state, order)
216 end
217 return order
218end
219
220fn release(name, pending, followers, ready) do
221 for next in followers[name] do
222 pending[next] -= 1
223 if (pending[next] == 0) ready:push(next)
224 end
225end
226
227fn dirty(name, rebuilt, state) do
228 const entry = rules[name]
229 if (entry.phony) return :true
230 if (table.key(entry, :force) orelse :false) return :true
231 if (state[name] != signature(entry)) return :true
232 for dep in entry.deps do
233 if table.key?(rules, dep) do
234 if (rules[dep].phony or rebuilt[dep] == :true) return :true
235 end
236 end
237 return timestamp(name, entry.deps)
238end
239
240fn execute(name, completed) do
241 if not rules[name].phony do
242 const parts = name:split("/")
243 parts:pop()
244 fs.mkdir(table.join(parts, "/"), :true):unwrap()
245 end
246 const action = rules[name].action
247 match action()
248 | {:err, reason} => do
249 print("failed:", name, reason)
250 exit(1)
251 end
252 | _ => :ok
253
254 if not rules[name].phony and not fs.exists?(name) do
255 print("failed: rule did not create output:", name)
256 exit(1)
257 end
258 send(completed, name)
259end
260
261#*
262 build the selected target names using kahn's algorithm, cocurrently
263 skips up-to-date file rules and save state after a successful run
264*#
265pub fn kahn(plan, jobs) do
266 if (jobs < 1 or jobs != number.floor(jobs))
267 panic("jobs must be a positive integer")
268
269 const nodes = mkplan(plan)
270 const state = load_state()
271 let state_changed = :false
272 const pending = {}
273 const followers = {}
274 const ready = {}
275 const rebuilt = {}
276 const handles = {}
277 const completed = chan(jobs)
278 const total = table.len(nodes)
279
280 for name in nodes do
281 pending[name] = 0
282 followers[name] = {}
283 end
284 for name in nodes do
285 for dep in rules[name].deps do
286 if table.key?(rules, dep) do
287 pending[name] += 1
288 const next = followers[dep]
289 next:push(name)
290 end
291 end
292 if (pending[name] == 0) ready:push(name)
293 end
294
295 let ready_index = 0
296 let active = 0
297 let finished = 0
298 let reported = 0
299 while finished < total do
300 while active < jobs do
301 if ready_index >= table.len(ready) do
302 break
303 end
304 const name = ready[ready_index]
305 const entry = rules[name]
306 let description = name
307 if table.key?(entry, :description) do
308 description = entry.description
309 end
310 ready_index += 1
311 reported += 1
312 if dirty(name, rebuilt, state) do
313 print(fmt("[%v/%v] %v", reported, total, description))
314 handles[name] = spawn execute(name, completed)
315 active += 1
316 end else do
317 print(fmt("[%v/%v] %v (up to date)", reported, total, description))
318 rebuilt[name] = :false
319 finished += 1
320 release(name, pending, followers, ready)
321 end
322 end
323
324 if active > 0 do
325 const name = recv(completed)
326 join(handles[name])
327 active -= 1
328 finished += 1
329 rebuilt[name] = :true
330 if not rules[name].phony do
331 state[name] = signature(rules[name])
332 state_changed = :true
333 end
334 release(name, pending, followers, ready)
335 end else do
336 if (finished < total) panic("dependency cycle in build plan")
337 end
338 end
339 if state_changed do
340 save_state(state)
341 end
342 return :ok
343end
344
345#*
346 build target names passed after the manifest on the command line, or defaults if none.
347 use revo.threads() as the maximum number of concurrent actions.
348*#
349pub fn run(defaults) do
350 if table.len(argv) > 1 do
351 const targets = {}
352 for i in 1..table.len(argv) do
353 targets:push(argv[i])
354 end
355 return kahn(targets, revo.threads())
356 end
357 return kahn(defaults, revo.threads())
358end
359
360fn showout(text) do
361 if (text == "") return :ok
362
363 if (text:ends_with?("\n"))
364 print(text:sub(0, text:len() - 1))
365 else
366 print(text)
367end
368
369#* execute a command argument list without a shell. print captured output, and exit on failure *#
370pub fn cmd(args) do
371 const label = table.join(args, " ")
372
373 match system(args)
374 | {:ok, {out, err}} => do
375 showout(out)
376 showout(err)
377 end
378 | {:err, reason} => do
379 print("failed:", label)
380 print(reason)
381 exit(1)
382 end
383end