From 02194b17f04d0f94836768b1ad49f13500466d5a Mon Sep 17 00:00:00 2001 From: Christopher Monsanto Date: Sat, 15 Aug 2026 22:09:51 -0400 Subject: [PATCH] Remove tup: delete Lua build config, tupctime, and tup state The build is now driven by tools/build (see Buildfile.ts). tupctime is obsolete: the new tool hashes content behind a stat cache, so ctime/mtime churn without content changes no longer causes rebuilds. Co-Authored-By: Claude Fable 5 --- .gitignore | 4 +- README.md | 55 ++++---- Tupfile.ini | 0 Tupfile.lua | 257 ----------------------------------- Tuprules.lua | 53 -------- pnpm-lock.yaml | 18 --- tools/tupctime/index.ts | 75 ---------- tools/tupctime/package.json | 10 -- tools/tupctime/tsconfig.json | 3 - tsconfig-workspace.json | 1 - util/lua-ext.lua | 86 ------------ util/sprites.lua | 32 ----- util/strict.lua | 32 ----- util/tup-ext.lua | 104 -------------- 14 files changed, 28 insertions(+), 702 deletions(-) delete mode 100644 Tupfile.ini delete mode 100644 Tupfile.lua delete mode 100644 Tuprules.lua delete mode 100644 tools/tupctime/index.ts delete mode 100644 tools/tupctime/package.json delete mode 100644 tools/tupctime/tsconfig.json delete mode 100644 util/lua-ext.lua delete mode 100644 util/sprites.lua delete mode 100644 util/strict.lua delete mode 100644 util/tup-ext.lua diff --git a/.gitignore b/.gitignore index 5eb4e277..65571d87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /build/ -.tup node_modules pnpm-debug.log -tup.config .cache/ +.build/ +build.config diff --git a/README.md b/README.md index babf9fa0..d9b1cbae 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,13 @@ This project depends on -- [tup](http://gittup.org/tup/) - [ImageMagick](http://www.imagemagick.org/) >= 7 +- [gifsicle](https://www.lcdf.org/gifsicle/) - [AdvPng](http://www.advancemame.it/doc-advpng.html) (optional) - [OptiPNG](http://optipng.sourceforge.net/) (optional) - [pngquant](https://pngquant.org/) (optional) - [pnpm](https://pnpm.js.org) - [node.js](https://nodejs.org) >= 24 -- [wine](https://www.winehq.org/) (optional) - cwebp ### Windows @@ -23,21 +22,10 @@ Windows binaries of these dependencies can be found on the download pages of the ### Linux ``` -$ sudo apt install nodejs imagemagick advancecomp optipng pngquant wine webp +$ sudo apt install nodejs imagemagick gifsicle advancecomp optipng pngquant webp $ sudo npm install -g pnpm ``` -Build tup from source: - -``` -$ sudo apt install build-essential pkg-config fuse3 libfuse3-dev libpcre3-dev -$ git clone git://github.com/gittup/tup.git -$ cd tup -$ ./bootstrap.sh -$ sudo cp tup /usr/local/bin/tup -$ sudo cp tup.1 /usr/local/share/man -``` - You may have to build imagemagick from source to get version 7. ``` @@ -55,8 +43,7 @@ $ sudo ldconfig /usr/local/lib Using [`brew`](https://brew.sh/) on a macOS: ``` -$ brew cask install osxfuse wine-stable -$ brew install tup imagemagick advancecomp optipng pngquant +$ brew install imagemagick gifsicle advancecomp optipng pngquant webp ``` ## Building @@ -64,34 +51,44 @@ $ brew install tup imagemagick advancecomp optipng pngquant Install dependencies once with `pnpm install`. Then, to build: ``` -$ tup +$ node tools/build/index.ts ``` +The rules live in `Buildfile.ts`. Build state (content hashes, rule records) +is kept in `.build/`; outputs of removed rules are deleted automatically, and +renamed sources are detected and their outputs copied instead of rebuilt. + +Useful flags: `-j ` parallelism, `-n` dry run, `-v` verbose, +`--adopt` record already-existing `build/` outputs as up to date instead of +rebuilding them (useful when `build/` was produced elsewhere). + ## Configuration -Build settings are configurable in `tup.config`. +Build settings are configurable in `build.config` (not tracked by git). -- `CONFIG_DEFAULT_OPTIPNG`: Command line to pass to `optipng`. -- `CONFIG_DEFAULT_ADVPNG`: Command line to pass to `advpng`. -- `CONFIG_DEFAULT_PNGQUANT`: Command line to pass to `pngquant`. +- `DEFAULT_OPTIPNG`: Command line to pass to `optipng`. +- `DEFAULT_ADVPNG`: Command line to pass to `advpng`. +- `DEFAULT_PNGQUANT`: Command line to pass to `pngquant`. There are src-specific versions of these settings: -- `CONFIG_TRAINERS_`: Compression options for `trainers/` only. -- `CONFIG_DEX_`: Compression options for `dex/` only. -- `CONFIG_MODELS_`: Compression options for `models/` only. -- `CONFIG_SPRITESHEET_`: Compression options for spritesheets only. -- `CONFIG_MINISPRITE_`: Compression options for `minisprites/` only. +- `TRAINERS_`: Compression options for `trainers/` only. +- `DEX_`: Compression options for `dex/` only. +- `MODELS_`: Compression options for `models/` only. +- `SPRITESHEET_`: Compression options for spritesheets only. +- `MINISPRITE_`: Compression options for `minisprites/` only. For example, these settings reflect the compression settings for the files chaos uploaded in `src/`: ``` -CONFIG_DEFAULT_OPTIPNG=-o7 -CONFIG_DEFAULT_ADVPNG=-z4 -i5000 +DEFAULT_OPTIPNG=-o7 +DEFAULT_ADVPNG=-z4 -i5000 ``` ## Gotchas -- Tup, like Git, tracks files, not directories. If you `readdir()` and forget to declare a dependency it won't catch it, like it would for `read()`. You can work around this by having build tools `stat()` any filenames they acquire. +- The build tool only tracks the inputs a rule declares. If a build tool reads + files that aren't on its command line (e.g. it does a `readdir()`), declare + them with the rule's `deps:` in `Buildfile.ts` so changes are detected. ## License diff --git a/Tupfile.ini b/Tupfile.ini deleted file mode 100644 index e69de29b..00000000 diff --git a/Tupfile.lua b/Tupfile.lua deleted file mode 100644 index 1e166f81..00000000 --- a/Tupfile.lua +++ /dev/null @@ -1,257 +0,0 @@ - --- Generate uniform size minisprites - -foreach_rule( - "src/minisprites/pokemon/gen6/*.png", - { - display="pad g6 minisprite %f", - pad{w=40, h=30}, - compresspng{config="MINISPRITE"} - }, - "build/gen6-minisprites-padded/%b" -) - -foreach_rule( - "src/minisprites/items/*.png", - { - display="pad item minisprite %f", - pad{w=24, h=24}, - compresspng{config="MINISPRITE"} - }, - "build/item-minisprites-padded/%b" -) - -foreach_rule( - "src/minisprites/pokemon/gen6/*.png", - { - display="trim g6 minisprite %f", - trimimg{}, - compresspng{config="MINISPRITE"} - }, - "build/gen6-minisprites-trimmed/%b" -) - -foreach_rule( - "src/minisprites/items/*.png", - { - display="trim item minisprite %f", - trimimg{}, - compresspng{config="MINISPRITE"} - }, - "build/item-minisprites-trimmed/%b" -) - --- Gen 9 - -foreach_rule( - "src/gen9species/*.png", - { - display="96x96 %f", - -- TODO, add customizable compression for gif - -- ... or investigate using webp instead of both png/gif here - "magick convert %f -trim +repage -resize 90x90 %o", - "gifsicle -O3 -b %o" - }, - "build/gen9-modelslike/%B.gif" -) - --- Gen 10 - -foreach_rule( - "src/champions/*.png", - { - display="96x96 %f", - -- TODO, add customizable compression for gif - -- ... or investigate using webp instead of both png/gif here - "magick convert %f -trim +repage -resize 90x90 %o", - "gifsicle -O3 -b %o" - }, - "build/gen10-modelslike/%B.gif" -) - --- Gen 5 CAPs... - -foreach_rule( - "src/sprites/gen5/*.png", - { - -- TODO, add customizable compression for gif - -- ... or investigate using webp instead of both png/gif here - "magick convert %f %o", - "gifsicle -O3 -b %o" - }, - "build/gen5-gif/%B.gif" -) - --- PS spritesheet - -rule( - "ps-pokemon.sheet.mjs", - { - display="ps pokemon sheet", - "node tools/sheet/index.ts %f %o", - compresspng{config="SPRITESHEET"} - }, - "build/ps/pokemonicons-sheet.png" -) - --- TODO: reenable when trainers are moved --- rule{ --- display="ps trainers sheet", --- {"ps-trainers.sheet.mjs"}, --- { --- "node tools/sheet/index.ts %f %o", --- compresspng{config="SPRITESHEET"} --- }, --- {"build/ps/trainers-sheet.png"} --- } - -rule( - "ps-items.sheet.mjs", - { - display="ps items sheet", - "node tools/sheet/index.ts %f %o", - compresspng{config="SPRITESHEET"} - }, - "build/ps/itemicons-sheet.png" -) - --- PS pokeball icons - -local balls = { - "src/_uncategorized/noncanonical/ui/battle/Ball-Normal.png", - "src/_uncategorized/noncanonical/ui/battle/Ball-Sick.png", - "src/_uncategorized/noncanonical/ui/battle/Ball-Null.png", -} - -rule( - balls, - { - display="pokemonicons-pokeball-sheet", - "magick convert -background transparent -gravity center -extent 40x30 %f +append %o", - compresspng{config="SPRITESHEET"} - }, - "build/ps/pokemonicons-pokeball-sheet.png" -) - --- Smogdex minisprites (webp) - -foreach_rule( - spriteglob({"src/minisprites/pokemon/gen6/*", "src/minisprites/items/*"}, {a = false}), - { - display="webp minisprite %f", - "cwebp -z 9 %f -o %o" - }, - "build/smogon/minisprites/%B.webp" -) - --- Smogdex spritesheet - - -rule( - spriteglob({"src/minisprites/pokemon/gen6/*", "src/minisprites/items/*"}, {a = false}), - { - display="smogdex sheet", - "node tools/smogdexspritesheet/index.ts --image build/smogon/spritesheet.png --stylesheet build/smogon/spritesheet.css -- %f", - "cwebp -z 9 build/smogon/spritesheet.png -o build/smogon/spritesheet.webp", - "rm build/smogon/spritesheet.png" - }, - {"build/smogon/spritesheet.webp", "build/smogon/spritesheet.css"} -) - --- Smogdex social images - -local input = spriteglob({"src/models/*"}, {b = false, s = false}) - --- Copied from the CAP bit, dedup, should probably make a routine for it -local dexSet = {} -for file in iter(input) do - dexSet[tup.base(file)] = true -end - -for file in iter(spriteglob({"src/gen9species/*"}, {b = false, s = false})) do - local base = tup.base(file) - if dexSet[base] then - goto continue - end - input += file - dexSet[base] = true - ::continue:: -end - -foreach_rule( - input, - { - display="fbsprite %f", - "magick convert \"%f[0]\" -trim -resize 150x150 -background white -gravity center -extent 198x198 -bordercolor black -border 1 %o", - compresspng{config="MODELS"} - }, - "build/smogon/fbsprites/xy/%B.png" -) - -foreach_rule( - input, - { - display="twittersprite %f", - "magick convert \"%f[0]\" -trim -resize 115x115 -background white -gravity center -extent 120x120 %o", - compresspng{config="MODELS"} - }, - "build/smogon/twittersprites/xy/%B.png" -) - - --- Trainers - --- TODO: reenable when trainers are moved --- foreach_rule{ --- display="pad trainer %f", --- {"src/canonical/trainers/*"}, --- { --- pad{w=80, h=80}, --- compresspng{config="TRAINERS"} --- }, --- {"build/padded-trainers/canonical/%b"} --- } - --- Padded Dex - -local dexOutput = foreach_rule( - "src/dex/*", - { - display="pad dex %f", - pad{w=120, h=120}, - compresspng{config="DEX"} - }, - "build/padded-dex/%b" -) - - --- Build missing CAP dex - -local dexSet = {} -for file in iter(dexOutput) do - dexSet[tup.base(file)] = true -end - -local dexMissing = {} -for file in iter(spriteglob( - {"src/sprites/gen5/*.gif", "src/models/*.gif"}, - {b = false, s = false})) do - local base = tup.base(file) - if dexSet[base] then - goto continue - end - dexMissing += file - dexSet[base] = true - ::continue:: -end - -foreach_rule( - dexMissing, - { - display="missing dex %B", - "magick convert \"%f[0]\" -trim %o", - "magick mogrify -background transparent -gravity center -resize \"120x120>\" -extent 120x120 %o", - compresspng{config="DEX"} - }, - "build/padded-dex/%B.png" -) diff --git a/Tuprules.lua b/Tuprules.lua deleted file mode 100644 index 56034614..00000000 --- a/Tuprules.lua +++ /dev/null @@ -1,53 +0,0 @@ - ---tup.include("util/strict.lua") -tup.include("util/lua-ext.lua") -tup.include("util/tup-ext.lua") -tup.include("util/sprites.lua") - -ROOTDIR = tup.getcwd() - -function pad(opts) - return rep{ - "magick convert ${input} -background transparent -gravity center -extent ${w}x${h} ${output}", - input = opts.input or "%f", - output = opts.output or "%o", - w = opts.w, - h = opts.h - } -end - -function trimimg(opts) -- Can't just be trim because of the string function... - return rep{ - "magick convert ${input} -trim ${output}", - input = opts.input or "%f", - output = opts.output or "%o", - } -end - -local function compressopts(program, copts) - copts.pngquant = getconfig(program .. "_PNGQUANT") or copts.pngquant - copts.optipng = getconfig(program .. "_OPTIPNG") or copts.optipng - copts.advpng = getconfig(program .. "_ADVPNG") or copts.advpng -end - -function compresspng(opts) - local cmds = {} - local output = opts.output or "%o" - local copts = {} - compressopts("DEFAULT", copts) - if opts.config then - compressopts(opts.config, copts) - end - if copts.pngquant then - -- -f -o necessary to overwrite existing file - cmds += rep{"pngquant -f -o ${output} ${opts} ${output}", opts=copts.pngquant, output=output} - end - if copts.optipng then - cmds += rep{"optipng -q ${opts} ${output}", opts=copts.optipng, output=output} - end - if copts.advpng then - cmds += rep{"advpng -q ${opts} ${output}", opts=copts.advpng, output=output} - end - - return cmds -end diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d70c1e2e..8f96ce04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,24 +118,6 @@ importers: specifier: ^5.1.0 version: 5.1.0 - tools/tupctime: - dependencies: - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 - '@types/debug': - specifier: ^4.1.5 - version: 4.1.13 - '@types/node': - specifier: ^24.10.7 - version: 24.13.3 - better-sqlite3: - specifier: ^12.4.1 - version: 12.11.1 - debug: - specifier: ^4.1.1 - version: 4.4.3 - packages: '@types/better-sqlite3@7.6.13': diff --git a/tools/tupctime/index.ts b/tools/tupctime/index.ts deleted file mode 100644 index 097bdcdb..00000000 --- a/tools/tupctime/index.ts +++ /dev/null @@ -1,75 +0,0 @@ - -import fs from 'fs'; -import pathlib from 'path'; -import Database from 'better-sqlite3'; -import debugfn from 'debug'; - -const debug = debugfn('tupctime'); - -const start = process.argv[2]; -if (start !== undefined) { - process.chdir(start); -} - -while (true) { - if (fs.existsSync('.tup')) { - break; - } - - if (process.cwd() === '/') { - throw new Error("Can't find tup root."); - } - - process.chdir('..'); -} - -const db = new Database('.tup/db'); - -const TUP_DB_FILE = 0; -const TUP_DB_DIR = 2; -const TUP_DB_GENERATED = 4; -const TUP_DB_GENERATED_DIR = 7; - -// ^ and $ are virtual directories for environment variables -const traversalQuery = db.prepare<[number], {id: number, type: number, ctime: number, name: string}>(` -SELECT id, type, mtime as ctime, name -FROM node -WHERE dir = ? -AND type IN (${TUP_DB_FILE}, ${TUP_DB_DIR}, ${TUP_DB_GENERATED}, ${TUP_DB_GENERATED_DIR}) -AND name NOT IN ('^', '$')`); -const updateQuery = db.prepare(`UPDATE node SET mtime = ? WHERE id = ?`); - -function update() { - const stack : {isDir: boolean, id : number, path: string, ctime?: bigint, name?: string}[] = [{isDir: true, id: 0, path: "."}]; - - let item; - while (item = stack.pop()) { - if (!fs.existsSync(item.path)) { - debug(`Ignoring ${item.path}`); - continue; - } - - if (item.isDir) { - for (const {id, type, ctime, name} of traversalQuery.iterate(item.id)) { - const isDir = [TUP_DB_DIR, TUP_DB_GENERATED_DIR].includes(type); - const path = pathlib.join(item.path, name); - stack.push({isDir, id, ctime: BigInt(ctime), path}); - } - } else { - // bigint calculation is necessary to avoid rounding errors/spurious update - // - // typescript doesn't understand second argument to lstatSync, or - // that division can work between a number and bigint, so ignore - // - // @ts-ignore - const ctime = fs.lstatSync(item.path, {bigint: true}).ctimeMs / 1000n; - if (ctime !== item.ctime) { - debug(`Updating ${item.path}: ${ctime}, ${item.ctime}`); - if (updateQuery.run(ctime, item.id).changes !== 1) - throw new Error(`Couldn't update ${item.path}`); - } - } - } -} - -db.transaction(update)(); diff --git a/tools/tupctime/package.json b/tools/tupctime/package.json deleted file mode 100644 index a9e69c16..00000000 --- a/tools/tupctime/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "dependencies": { - "@types/better-sqlite3": "^7.6.13", - "@types/debug": "^4.1.5", - "@types/node": "^24.10.7", - "better-sqlite3": "^12.4.1", - "debug": "^4.1.1" - }, - "type": "module" -} diff --git a/tools/tupctime/tsconfig.json b/tools/tupctime/tsconfig.json deleted file mode 100644 index 3e88c180..00000000 --- a/tools/tupctime/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../../tsconfig-base" -} diff --git a/tsconfig-workspace.json b/tsconfig-workspace.json index d6f94958..5b7e972a 100644 --- a/tsconfig-workspace.json +++ b/tsconfig-workspace.json @@ -3,7 +3,6 @@ "references": [ {"path": "./tsconfig.json"}, {"path": "tools/build"}, - {"path": "tools/tupctime"}, {"path": "tools/deploy"}, {"path": "tools/sheet"}, {"path": "tools/smogdexspritesheet"}, diff --git a/util/lua-ext.lua b/util/lua-ext.lua deleted file mode 100644 index 808a06ac..00000000 --- a/util/lua-ext.lua +++ /dev/null @@ -1,86 +0,0 @@ - --- --- Strings --- - -function trim(s) - return s:gsub("^%s*(.-)%s*$", "%1") -end - -function rep(args) - local str = args[1] - local vars = args - local function fn(var) - local v = vars[var] - if v == nil then - error("unknown substitution: " .. var) - end - return v - end - -- Note: not equivalent to return str:gsub(...) due to multiple return values! - str = str:gsub("${(%a+)}", fn) - return str -end - --- --- Tables --- - --- Allow `for v in iter(table)` instead of `for _, v in ipairs(table)` -function iter(table) - local i = 1 - return function () - local v = table[i] - i = i + 1 - return v - end -end - -function astable(table) - local t = type(table) - if t == 'table' then - return table - elseif t == 'string' then - return {table} - elseif t == 'nil' then - return {} - end -end - --- Adapted from premake -function flatten(arr) - local result = {} - - local function flatten(arr) - for v in iter(arr) do - if type(v) == "table" then - flatten(v) - else - table.insert(result, v) - end - end - end - - flatten(arr) - return result -end - --- Adapted from https://stackoverflow.com/a/53038524 -function filter(t, fn) - local j, n = 1, #t - - for i=1,n do - if fn(t[i]) then - -- Move i's kept value to j's position, if it's not already there. - if (i ~= j) then - t[j] = t[i] - t[i] = nil - end - j = j + 1 -- Increment position of where we'll place the next kept value. - else - t[i] = nil - end - end - - return t -end diff --git a/util/sprites.lua b/util/sprites.lua deleted file mode 100644 index 5a0b554c..00000000 --- a/util/sprites.lua +++ /dev/null @@ -1,32 +0,0 @@ - --- Similar to `parseFilename` in data/lib -function spritedata(basename) - local iter = basename:gmatch("[^-]+") - local result = {id = iter(), data = {}} - for flagtext in iter do - if flagtext:len() == 1 then - result.data[flagtext] = true - else - local flag = flagtext:sub(1, 1) - local text = flagtext:sub(2) - result.data[flag] = text - end - end - return result -end - -function spriteglob(pat, flagspec) - local results = glob(pat) - local function fn(filename) - local sd = spritedata(tup.base(filename)) - for k, v in pairs(flagspec or {}) do - -- Make sure both are booleans - if not not v ~= not not sd.data[k] then - return false - end - end - return true - end - filter(results, fn) - return results -end diff --git a/util/strict.lua b/util/strict.lua deleted file mode 100644 index 1bcdc65b..00000000 --- a/util/strict.lua +++ /dev/null @@ -1,32 +0,0 @@ --- --- strict.lua --- checks uses of undeclared global variables --- All global variables must be 'declared' through a regular assignment --- (even assigning nil will do) in a main chunk before being used --- anywhere or assigned to inside a function. --- - --- NOTE: this file has been edited to remove mt.__newindex - -local getinfo, error, rawset, rawget = debug.getinfo, error, rawset, rawget - -local mt = getmetatable(_G) -if mt == nil then - mt = {} - setmetatable(_G, mt) -end - -mt.__declared = {} - -local function what () - local d = getinfo(3, "S") - return d and d.what or "C" -end - -mt.__index = function (t, n) - if not mt.__declared[n] and what() ~= "C" then - error("variable '"..n.."' is not declared", 2) - end - return rawget(t, n) -end - diff --git a/util/tup-ext.lua b/util/tup-ext.lua deleted file mode 100644 index 4511fc06..00000000 --- a/util/tup-ext.lua +++ /dev/null @@ -1,104 +0,0 @@ - --- --- Configuration --- - --- getconfig returning an empty string on absence is inconvenient -function getconfig(str) - local v = tup.getconfig(str) - if v == "" then - return nil - else - return v - end -end - -function booleanconfig(str) - local v = getconfig(str) - if v == nil or v == "false" then - return false - elseif v == "true" then - return true - else - error("boolean config must be true, false, or empty") - end -end - --- --- Globs --- - --- Workaround weird bug pre reported, where final path segment has two // --- EDIT: AND workaround a bug Marty reported on Windows, where final path segment has a /\ -local old_tup_glob = tup.glob -function tup.glob(pat) - local results = old_tup_glob(pat) - for i = 1, #results do - results[i] = results[i]:gsub("/\\", "/"):gsub("//", "/") - end - return results -end - --- A glob pattern is either an interpolated string, or a table of glob patterns --- globpat_normalize("foo/*") --> {"foo/*"} --- globpat_normalize({"foo/*"}) --> {"foo/*"} -local function globpat_normalize(pat) - if type(pat) == "string" then - return {pat} - elseif type(pat) == 'table' then - local result = {} - for x in iter(pat) do - result += globpat_normalize(x) - end - return result - else - error("bad globpat") - end -end - -function glob(pat, opts) - local results = {} - for pat in iter(globpat_normalize(pat)) do - for file in iter(tup.glob(pat)) do - table.insert(results, file) - end - end - return results -end - - --- Commands - -local function cspec2cmd(cmdSpec) - local cmd = "" - for newcmd in iter(flatten(astable(cmdSpec))) do - newcmd = trim(newcmd) - if cmd ~= "" then - cmd = cmd .. " &&\n" - end - cmd = cmd .. newcmd - end - -- Abstract over tup's arcane display override - if cmdSpec.display then - cmd = rep{"^ ${display}^ ${cmd}", display=cmdSpec.display, cmd=cmd} - end - return cmd -end - -local function do_rule(input, cspec, output, foreach) - local cmd = cspec2cmd(cspec) - if foreach then - return tup.foreach_rule(input, cmd, output) - else - return tup.rule(input, cmd, output) - end -end - -function rule(input, cmd, output) - return do_rule(input, cmd, output, false) -end - -function foreach_rule(input, cmd, output) - return do_rule(input, cmd, output, true) -end -