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