ReC98/Pipeline/rules.lua

273 lines
8.5 KiB
Lua

---@alias TupInput string | { [1]: string, extra_inputs: string | string[] }
---@alias TupOutput string | { [1]: string, extra_outputs: string | string[] }
tup.import("ReC98_DOS=")
---@class (exact) Rule
---@field inputs string[]
---@field tool string
---@field args string
---ReC98-specific reimplementation of `tup generate` in Lua. Wraps `tup.rule()`
---and transforms the rules into a full dumb sequential batch file, but with
---the following benefits to create an ideal native 16-/32-bit build:
---
---* Automatic slash-to-backslash conversion for tool filenames
---* Emits `mkdir` commands for *all* referenced output directories, not just
--- the ones that are missing from the current working tree.
---* Bundles all single-file TCC invocations that share the same compiler flags
--- into a single batched one
---* Splits long `echo` lines to fit within Windows 9x limits.
---
---Since Tup forbids writing files from the Tupfile, we have to abuse stdout
---to get the transformed rules back into the repo. All rule lines are printed
---with a fixed prefix, which is then filtered and redirected into the final
---batch file inside `build.bat`.
---@class Rules
---@field private rules Rule[]
---@field private outdirs table<string, boolean> Set of output directories
---@field private batch_root string Root directory for TCC batch response files
---@field private tcc_batch table<string, Rule> Command-line arguments → rule that receives all input files compiled with these arguments
---@field private tcc_batch_count number Because # only works with sequential tables
local Rules = {}
Rules.__index = Rules
---@param batch_root string
function NewRules(batch_root)
---@type Rules
local ret = {
rules = {},
outdirs = {},
batch_root = batch_root,
tcc_batch = {},
tcc_batch_count = 0,
}
return setmetatable(ret, Rules)
end
---@private
---@param inputs TupInput
---@param tool string
---@param args string
---@param outputs TupOutput
---@return string cmd
function Rules:insert(inputs, tool, args, outputs)
---@type string[]
local ins = {}
---@type string[]
local outs = {}
-- Using the function instead of `+=` preserves the `string[]` type in the
-- LSP.
tup_append_assignment(ins, inputs)
tup_append_assignment(outs, outputs)
local replace_table = { f = inputs, i = inputs.extra_inputs }
args = args:gsub("%%(%d+)([fi])", function (i, type)
return replace_table[type][tonumber(i)]
end)
args = args:gsub("%%o", table.concat(outs, " "))
tup_append_assignment(outs, outputs.extra_outputs)
for _, output in ipairs(outs) do
local dir = output:gsub("/", "\\"):match("(.*)\\")
if (dir ~= nil) then
self.outdirs[dir] = true
end
end
if (tool == "tcc") then
local batch = self.tcc_batch[args]
if batch == nil then
local rsp_fn = string.format(
"%sbatch%03d.@c", self.batch_root, self.tcc_batch_count
)
batch = {
inputs = ins,
tool = "echo",
args = string.format("%s>%s", args, rsp_fn:gsub("/", "\\")),
}
table.insert(self.rules, batch)
table.insert(self.rules, {
inputs = { rsp_fn }, tool = "tcc", args = "@%f",
})
self.tcc_batch[args] = batch
self.tcc_batch_count = (self.tcc_batch_count + 1)
else
batch.inputs += ins
end
else
local rule = { inputs = ins, tool = tool, args = args }
table.insert(self.rules, rule)
end
return string.format("%s %s", tool, args)
end
---Adds a rule for a 32-bit build tool. Runs natively in both Tup and the batch
---file.
---@param inputs TupInput
---@param tool string
---@param args string
---@param outputs TupOutput
---@return string[]
function Rules:add_32(inputs, tool, args, outputs)
return tup.rule(inputs, self:insert(inputs, tool, args, outputs), outputs)
end
---Adds a rule for a 16-bit DOS build tool. Runs in `ReC98_DOS` inside Tup, and
---natively in the batch file.
---@param inputs TupInput
---@param tool string
---@param args string
---@param outputs TupOutput
---@return string[]
function Rules:add_16(inputs, tool, args, outputs)
local cmd = self:insert(inputs, tool, args, outputs)
return tup.rule(inputs, (ReC98_DOS .. " " .. cmd), outputs)
end
---Splits `line` into multiple lines to work around the no longer documented
---parsing craziness that Windows 9x's `COMMAND.COM` blindly performs before
---executing any line of a batch file:
---
---* Truncate to 1023 bytes
---* Parse out redirector
---* Strip trailing whitespace of remaining line
---* Split line into up to 64 tokens, around/excluding whitespace and '=' and
--- before/including '/'
---* Throw a `Bad command or file name` error once the token count would reach
--- 65, regardless of whether the command would actually need the tokens
---@param line string
---@param sub_bytes number | nil Subtracted from 1023.
---@param sub_args number | nil Subtracted from 64.
---@return fun(): string | nil,boolean it Iterator over the split lines, plus an indicator of whether the current string is the final one
local function split_batch_line_for_win9x(line, sub_bytes, sub_args)
local max_bytes = (1023 - ((sub_bytes and sub_bytes) or 0))
local max_args = (64 - ((sub_args and sub_args) or 0))
local switchar = string.byte("/")
local equals = string.byte("=")
---@param chunk string
local function cut(chunk)
---@type integer | nil
local prev_end = nil
local argc = 0
local cur_start, cur_end = chunk:find("%S+")
while ((cur_start ~= nil) and (cur_end ~= nil)) do
local cur_argc = 0
for i = (cur_start + 1), (cur_end - 1) do
local c = chunk:byte(i)
if ((c == switchar) or (c == equals)) then
cur_argc = (cur_argc + 1)
-- Subtracting 1 because the surrounding word may or may
-- not be OK.
if ((argc + cur_argc) >= (max_args - 1)) then
-- Back up to the previous word if we can…
if (prev_end and (cur_argc < max_args)) then
return chunk:sub(1, prev_end)
end
-- …or split the string inside the word. Only here for
-- tests, as it would corrupt the response files if it
-- ever happened.
return chunk:sub(1, (i - 1))
end
end
end
-- Separator characters are fine? Yield up to the entire word
argc = (argc + cur_argc + 1)
if (argc >= max_args) then
return chunk:sub(1, cur_end)
end
prev_end = cur_end
cur_start, cur_end = chunk:find("%S+", (cur_end + 1))
end
return chunk
end
---@type string
line = line:match("%s*(.*)%s*$")
return function ()
if (#line == 0) then
return nil, true
end
local chunk
if (#line > max_bytes) then
chunk = cut(line:sub(0, (max_bytes + 1)):match("(.+) "))
else
chunk = cut(line:sub(0, max_bytes))
end
line = line:sub(chunk:len() + 1):match("%s*(.*)%s*$")
return chunk, (#line == 0)
end
end
---Prints the current rules to stdout.
function Rules:print()
print([[
$ : Dumb, full batch build script for 32-bit systems that can't run Tup, but can
$ : natively run DOS-based tools. Automatically generated whenever `Tupfile.lua`
$ : is modified.
$ @echo off
]])
local outdirs_sorted = {}
for dir in pairs(self.outdirs) do
table.insert(outdirs_sorted, dir)
end
table.sort(outdirs_sorted)
for _, dir in pairs(outdirs_sorted) do
print(string.format("$ mkdir %s %%STDERR_IGNORE%%", dir))
end
for _, rule in pairs(self.rules) do
local cmd = rule.args:gsub("%%f", table.concat(rule.inputs, " "))
-- Work around command line length limits by splitting long `echo`
-- lines. This also must be done for the link response files used for
-- the regular Tup build.
local ECHO = "echo"
if (rule.tool == ECHO) then
local redirector = ">"
---@type string, string
local args, fn = cmd:match("(.*)>(.+)")
local rsp_ext = fn:match(".@(.)$")
-- Two spaces rather than one are, in fact, necessary to increase
-- the stability of TCC's optimizer in very rare cases? Not doing
-- this ends up inserting a bunch of useless instructions into the
-- TH01 Elis translation unit, but only on 32-bit Windows 10.
-- Really, I couldn't make this up.
if (rsp_ext == "c") then
args = args:gsub(" ", " ")
end
-- Adding 2 to leave room for the longer `>>` redirector
for chunk, final in split_batch_line_for_win9x(
args, (ECHO:len() + 1 + fn:len() + 2), 1
) do
-- TLINK response files need some separate massaging when split
-- into multiple lines.
if (rsp_ext == "l") then
if chunk:sub(-1) == "," then
chunk = chunk:sub(0, (chunk:len() - 1))
elseif not final then
chunk = (chunk .. "+")
end
end
print(string.format("$ %s %s%s%s", ECHO, chunk, redirector, fn))
redirector = ">>"
end
else
print(string.format("$ %s %s", rule.tool:gsub("/", "\\"), cmd))
end
end
end