mistress neutrino.lua
  1-- neutrino - a simple and opinionated build system for C
  2
  3local neu = {}
  4
  5local function check(s)
  6   assert(s:match("^[%u_]+$"), ("name '%s' invalid"):format(s))
  7end
  8
  9local function memq(elt, tbl)
 10   for _, x in pairs(tbl) do if elt == x then return true end end
 11   return false
 12end
 13
 14local bools = {}
 15function neu.bool(name)
 16   check(name)
 17   assert(not bools[name], ("option '%s' redeclared"):format(name))
 18   bools[name] = #(os.getenv(name) or "") > 0
 19   return bools[name]
 20end
 21
 22local enums = {}
 23function neu.enum(name, set)
 24   check(name)
 25   assert(not enums[name], ("option '%s' redeclared"):format(name))
 26   local val = os.getenv(name)
 27   if not val then enums[name] = set[1]
 28   else
 29      assert(memq(val, set), ("value '%s' not in { '%s' }")
 30         :format(val, table.concat(set, " ")))
 31      enums[name] = val
 32   end
 33   return enums[name]
 34end
 35
 36local strs = {}
 37function neu.str(name, dfl)
 38   check(name)
 39   assert(not strs[name], ("option '%s' redeclared"):format(name))
 40   strs[name] = os.getenv(name) or dfl
 41   return strs[name]
 42end
 43
 44local no_color = neu.bool("NO_COLOR")
 45local build_type = neu.enum("BUILD_TYPE", { "debug", "smol", "zomg" })
 46
 47local bn = assert(io.open("build.ninja", "w"))
 48
 49local function src2obj(s)
 50   return "obj/" .. s:gsub(".c$", ".o"):gsub("/", ".")
 51end
 52
 53local function writevar(name, value)
 54   bn:write(name, "=", value, "\n")
 55end
 56
 57local function writerule(name, tbl)
 58   bn:write("rule ", name, "\n")
 59   for k, v in pairs(tbl) do writevar(" " .. k, v, "\n") end
 60end
 61
 62local function writebuild(tgt, rule, prereq, ideps)
 63   if type(prereq) == "table" then
 64      prereq = table.concat(prereq, " ")
 65   end
 66   if type(ideps) == "table" then
 67      ideps = table.concat(ideps, " ")
 68   end
 69   ideps = ideps and "|" .. ideps or ""
 70   bn:write("build ", tgt, ":", rule, " ", prereq, ideps, "\n")
 71end
 72
 73local depmeta = {}
 74local function blessdep(tbl) return setmetatable(tbl, depmeta) end
 75local function isdep(tbl) return getmetatable(tbl) == depmeta end
 76
 77local function aggr(name, rule, srcs, opts, ideps, vars)
 78   opts = opts or {}
 79   opts.std = opts.std or "c17"
 80   local stds = { c17 = true, c23 = true }
 81   local objs = {}
 82   local lf = {}
 83   for i, x in ipairs(srcs) do objs[i] = src2obj(x) end
 84   writebuild("obj/" .. name, rule, objs, ideps)
 85   if vars then
 86      for k, v in pairs(vars) do writevar(" " .. k, v) end
 87   end
 88   for i = 1, #srcs do
 89      writebuild(objs[i], "cc", srcs[i])
 90      local cf = {}
 91      if stds[opts.std] then table.insert(cf, "-std=" .. opts.std)
 92      else error(("std '%s' invalid"):format(opts.std)) end
 93      if opts.includedirs then
 94         for _, d in ipairs(opts.includedirs) do
 95            table.insert(cf, "-I'" .. d .. "'")
 96         end 
 97      end
 98      writevar(" _cflags", table.concat(cf, " "))
 99   end
100end 
101
102function neu.bin(bin, srcs, opts)
103   local ideps = {}
104   local lf = {}
105   if opts.deps then
106      for _, d in ipairs(opts.deps) do
107         assert(isdep(d), "deps contain invalid object")
108         if d.file then table.insert(ideps, "obj/" .. d.file) end
109         table.insert(lf, d.ldflags)
110      end
111   end
112   aggr(bin, "ld", srcs, opts, ideps, {
113      _ldflags = table.concat(lf, " "),
114   })
115end
116
117function neu.lib(lib, srcs, opts)
118   local fn = table.concat({ "lib", lib, ".a" })
119   aggr(fn, "ar", srcs, opts)
120   return blessdep({ file = fn, ldflags = "-Lobj -l" .. lib })
121end
122
123if not package.searchpath then
124   function package.searchpath(name, path)
125      name = name:gsub("%.", "/"):gsub("%%", "%%%%")
126      for t in path:gmatch("[^;]+") do
127         local fn = t:gsub("%?", name)
128         local fd = io.open(fn, "r")
129         if fd and (fd:close() or true) then return fn end
130      end
131      return nil, "not found"
132   end
133end
134
135return function(argv)
136   writevar("builddir", "obj")
137   writevar("ar", neu.str("AR", "ar"))
138   writevar("cc", neu.str("CC", "cc"))
139   writevar(
140      "cflags",
141      table.concat({
142         no_color and "-fno-diagnostics-color" or "-fdiagnostics-color",
143         "-Wall -Wextra -Wpedantic",
144         ({
145            debug = "-g",
146            smol = "-Os",
147            zomg = "-O3"
148         })[build_type],
149         "-fno-semantic-interposition",
150         neu.str("CFLAGS", ""),
151      }, " ")
152   )
153   writevar(
154      "ldflags",
155      table.concat({
156         "-static",
157         -- XXX: -s or strip when installing? should research more
158         build_type == "debug" and "" or "-s",
159         neu.str("LDFLAGS", ""),
160      }, " ")
161   )
162   writerule("ar", {
163      command = "$ar rcs $out $in",
164      description = "archiving $out",
165   })
166   writerule("cc", {
167      command = "$cc $_cflags $cflags -MD -MF $out.d -c -o $out $in",
168      depfile = "$out.d",
169      deps = "gcc",
170      description = "compiling $out",
171   })
172   writerule("ld", {
173      command = "$cc $_ldflags $ldflags -o $out $in",
174      description = "linking $out",
175   })
176   local ourpath = package.searchpath("neutrino", package.path)
177   local regencmd = {}
178   for k, v in pairs(bools) do
179      table.insert(regencmd, k .. "=" .. (v and "1" or ""))
180   end
181   for k, v in pairs(enums) do
182      table.insert(regencmd, k .. "='" .. v .. "'")
183   end
184   for k, v in pairs(strs) do
185      table.insert(regencmd, k .. "='" .. v .. "'")
186   end
187   table.insert(regencmd, argv[-1])
188   table.insert(regencmd, "$in")
189   writerule("regen", {
190      command = table.concat(regencmd, " "),
191      description = "regenerating $out",
192   })
193   writebuild("build.ninja", "regen", argv[0], ourpath)
194   return neu
195end