281 lines
14 KiB
Python
281 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
# Bundles the root *.lua (library + imgui_demo.lua), backends/ and fonts/ into ONE ModuleScript: build/ImGui.luau
|
|
# --no-demo: library only (imgui_demo.lua left out, like not compiling imgui_demo.cpp)
|
|
# --target love: build/imgui_love.lua for LÖVE/LuaJIT instead (LÖVE backend, shims from the LÖVE backend's COMPAT section, @native stripped).
|
|
# The default (roblox) build is unaffected by it.
|
|
# Why: ModuleScripts don't share globals, and the setfenv() alternative turns off Luau's fast paths / native codegen.
|
|
# Each source file becomes a function in __FILES; IM_INCLUDE(name) runs it once and caches the result.
|
|
# The module returns a table with every global the library defines (ImGui, ImVec2, ImGuiConfigFlags, ImGui_ImplRoblox, ...).
|
|
# Roblox target also writes ImGuiTyped.luau (types + require(script.Impl), see tools/gen_types.py).
|
|
#
|
|
# Build-time optimizations (token rewrites with minify.py's lexer: strings/comments are never touched, every source line
|
|
# stays on its line so linemap.json still maps errors). IMGUI_BUNDLE_NOOPT=1 turns all off, IMGUI_BUNDLE_NO_<X>=1 one:
|
|
# A hot library globals (most referenced names assigned at a file's top level) become bundle-level locals declared
|
|
# before __FILES: every file's assignment/reference then hits an upvalue instead of GETGLOBAL. Fewer names for
|
|
# LuaJIT (60 upvalues per function). Library globals can't be patched from outside (use the returned table).
|
|
# B enum folding: the bundle is loaded once headless (Luau CLI) to dump enum-like tables (all values numbers, never
|
|
# written inside a function) and numeric constants assigned once at top level; reads `ImGuiCol.Text` / `FLT_MAX`
|
|
# inside function bodies become `0 --[[ImGuiCol.Text]]`. Load-time (top-level) code is left alone.
|
|
# C `--@hoist` functions in the sources (pure numeric helpers: ImMin, ImMax, ImClamp, ...) are moved to the bundle top
|
|
# as `local function`s (their source lines become blank) so Luau -O2 can inline them everywhere.
|
|
# E statement-form `IM_ASSERT(...)` becomes `assert(...)` (fast builtin); expression uses are left alone.
|
|
import os, re, sys, json, shutil, subprocess, tempfile
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT = os.path.dirname(HERE)
|
|
sys.path.insert(0, HERE)
|
|
from minify import TOKEN
|
|
|
|
NO_DEMO = "--no-demo" in sys.argv
|
|
LOVE = False
|
|
if "--target" in sys.argv:
|
|
i = sys.argv.index("--target"); LOVE = sys.argv[i + 1] == "love"; del sys.argv[i:i + 2]
|
|
sys.argv = [a for a in sys.argv if a != "--no-demo"]
|
|
OPT = {x: os.environ.get("IMGUI_BUNDLE_NOOPT") != "1" and os.environ.get("IMGUI_BUNDLE_NO_" + x) != "1" for x in "ABCE"}
|
|
# A limits: Luau 200 locals in the main chunk (+ helpers, __FILES, ...), 200 upvalues per function; LuaJIT 60 upvalues
|
|
# per function (margin left for nested functions that also capture file locals)
|
|
MAX_LOCALS, MAX_UPVALUES = 160, (45 if LOVE else 150)
|
|
BACKEND = "imgui_impl_love.lua" if LOVE else "imgui_impl_roblox.lua"
|
|
SKIP = {"imgui_impl_roblox.lua", "imgui_impl_roblox_fonts.lua"} if LOVE else {"imgui_impl_love.lua"}
|
|
FILES = {}
|
|
for d in (ROOT, os.path.join(ROOT, "backends"), os.path.join(ROOT, "fonts")):
|
|
for f in sorted(os.listdir(d)):
|
|
if f.endswith(".lua") and not (NO_DEMO and f == "imgui_demo.lua") and f not in SKIP:
|
|
src = open(os.path.join(d, f)).read()
|
|
if LOVE: src = re.sub(r"^@native$", "", src, flags=re.M) # keep line numbers
|
|
FILES[f] = src
|
|
|
|
ORIG = dict(FILES) # for gen_types
|
|
ENTRY = ["imgui.lua", BACKEND] + ([] if NO_DEMO else ["imgui_demo.lua"]) # run order
|
|
|
|
names = set()
|
|
for src in FILES.values():
|
|
names.update(re.findall(r"^function ([A-Za-z_]\w*)\s*\(", src, re.M))
|
|
names.update(re.findall(r"^([A-Za-z_]\w*)\s*=[^=]", src, re.M))
|
|
|
|
# ---- lexer helpers ----
|
|
KW = set("and break do else elseif end false for function goto if in local nil not or repeat return then true until while".split())
|
|
|
|
def lex(src):
|
|
"""[(kind, text, start)] without whitespace/comments; kinds: name num str op."""
|
|
toks, pos = [], 0
|
|
while pos < len(src):
|
|
m = TOKEN.match(src, pos)
|
|
k = m.lastgroup
|
|
if k in ("ceq", "seq"): k = "lcom" if m.group(0).startswith("--") else "lstr"
|
|
if k not in ("ws", "lcom", "com"): toks.append(("str" if k == "lstr" else k, m.group(0), pos))
|
|
pos = m.end()
|
|
return toks
|
|
|
|
def fdepths(toks):
|
|
"""function nesting depth of every token (0 = runs at load time)"""
|
|
st, fd, out = [], 0, []
|
|
for k, t, _ in toks:
|
|
out.append(fd)
|
|
if k == "name":
|
|
if t == "function": st.append(1); fd += 1
|
|
elif t in ("if", "do", "repeat"): st.append(0)
|
|
elif t in ("end", "until"): fd -= st.pop()
|
|
assert not st, "unbalanced blocks"
|
|
return out
|
|
|
|
def declared(toks):
|
|
"""names declared as locals / parameters / loop variables anywhere"""
|
|
out, n = set(), len(toks)
|
|
for i, (k, t, _) in enumerate(toks):
|
|
j = None
|
|
if t == "local": j = i + 2 if toks[i + 1][1] == "function" else i + 1
|
|
elif t == "for": j = i + 1
|
|
elif t == "function":
|
|
j = i + 1
|
|
while toks[j][1] != "(": j += 1
|
|
j += 1
|
|
while j is not None and j < n and toks[j][0] == "name" and toks[j][1] not in KW:
|
|
out.add(toks[j][1])
|
|
j += 1
|
|
if j < n and toks[j][1] == ",": j += 1
|
|
else: break
|
|
return out
|
|
|
|
def apply(src, edits):
|
|
for s, e, r in sorted(edits, reverse=True): src = src[:s] + r + src[e:]
|
|
return src
|
|
|
|
def isref(toks, i):
|
|
"""toks[i] is a variable read/write (not a field name)"""
|
|
return toks[i][0] == "name" and (i == 0 or toks[i - 1][1] not in (".", ":"))
|
|
|
|
def nexttext(toks, i): return toks[i + 1][1] if i + 1 < len(toks) else ""
|
|
|
|
# ---- C: --@hoist helpers ----
|
|
hoisted, hoisted_names = [], []
|
|
if OPT["C"]:
|
|
for f, src in FILES.items():
|
|
while "--@hoist\n" in src:
|
|
s = src.index("--@hoist\n") + len("--@hoist\n")
|
|
toks = lex(src[s:])
|
|
depth, i = 0, 0
|
|
while True: # first `function` ... matching end
|
|
t = toks[i][1]
|
|
if t == "function" or t in ("if", "do", "repeat"): depth += 1
|
|
elif t in ("end", "until"):
|
|
depth -= 1
|
|
if depth == 0: break
|
|
i += 1
|
|
e = s + toks[i][2] + 3
|
|
text = src[s:e]
|
|
m = re.search(r"^(local )?function ([A-Za-z_]\w*)", text, re.M)
|
|
hoisted_names.append(m.group(2))
|
|
hoisted.append(text[:m.start()] + "local function " + text[m.start() + len(m.group(0)) - len(m.group(2)):])
|
|
src = src[:s - len("--@hoist\n")] + "\n" + re.sub(r"[^\n]", "", text) + src[e:]
|
|
FILES[f] = src
|
|
HOIST_DEPS = "local type, mathMin, mathMax, mathFloor, mathCeil, bitOr, bitLShift = type, math.min, math.max, math.floor, math.ceil, bit32.bor, bit32.lshift"
|
|
|
|
# ---- E: statement IM_ASSERT -> assert ----
|
|
STMT_PREV_KW = {"end", "true", "false", "nil", "break", "then", "do", "else", "repeat"}
|
|
def rewrite_assert(src):
|
|
toks, edits = lex(src), []
|
|
for i, (k, t, p) in enumerate(toks):
|
|
if t == "IM_ASSERT" and isref(toks, i) and nexttext(toks, i) == "(":
|
|
pk, pt = (toks[i - 1][0], toks[i - 1][1]) if i else ("op", ";")
|
|
if (pk == "name" and (pt not in KW or pt in STMT_PREV_KW)) or pk in ("num", "str") or pt in (")", "]", "}", ";", "..."):
|
|
edits.append((p, p + len(t), "assert"))
|
|
return apply(src, edits)
|
|
|
|
if OPT["E"]:
|
|
FILES = {f: rewrite_assert(s) for f, s in FILES.items()}
|
|
hoisted = [rewrite_assert(s) for s in hoisted]
|
|
|
|
# ---- B: enum / constant folding ----
|
|
LUAU = shutil.which("luau") or "/nix/store/2m2ja613nndihc8qm10f2rhq6znp1nh4-luau-0.734/bin/luau"
|
|
|
|
def header():
|
|
return ["local __FILES, __LOADED = {}, {}",
|
|
"function IM_INCLUDE(name)",
|
|
" local r = __LOADED[name]",
|
|
" if r == nil then r = __FILES[name](); if r == nil then r = true end; __LOADED[name] = r end",
|
|
" return r",
|
|
"end", ""]
|
|
|
|
def numtext(s):
|
|
v = float(s)
|
|
if v != v or v in (float("inf"), float("-inf")) or (v == 0 and s.startswith("-")): return None
|
|
r = str(int(v)) if v.is_integer() and abs(v) < 2 ** 53 else repr(v)
|
|
return "(%s)" % r if v < 0 else r
|
|
|
|
def dump_values():
|
|
"""{name: number} and {name: {key: number}} of the loaded library (imgui.lua and what it includes)"""
|
|
src = ["--!optimize 1"] + ([HOIST_DEPS] + hoisted if hoisted else []) + header()
|
|
for name, s in sorted(FILES.items()): src += ["__FILES[%r] = function()" % name, s, "end"]
|
|
src += ['IM_INCLUDE("imgui.lua")',
|
|
'local function D(n, v) if type(v) == "number" then print("C", n, string.format("%.17g", v)) elseif type(v) == "table" then',
|
|
' for k, x in pairs(v) do print("T", n, type(k) == "string" and k or "?", type(x) == "number" and string.format("%.17g", x) or "?") end end end']
|
|
src += ["D(%r, %s)" % (n, n) for n in sorted(names)]
|
|
with tempfile.NamedTemporaryFile("w", suffix=".luau", delete=False) as fp: fp.write("\n".join(src))
|
|
try:
|
|
p = subprocess.run([LUAU, fp.name], capture_output=True, text=True, timeout=60)
|
|
finally:
|
|
os.unlink(fp.name)
|
|
if p.returncode: raise RuntimeError("enum dump failed:\n" + p.stdout[-2000:] + p.stderr[-2000:])
|
|
consts, tables = {}, {}
|
|
for line in p.stdout.splitlines():
|
|
a = line.split("\t")
|
|
if a[0] == "C": consts[a[1]] = a[2]
|
|
elif a[0] == "T": tables.setdefault(a[1], {})[a[2]] = a[3]
|
|
tables = {n: t for n, t in tables.items() if "?" not in t and "?" not in t.values()}
|
|
return consts, tables
|
|
|
|
def fold(files):
|
|
consts, tables = dump_values()
|
|
lexed = {f: lex(s) for f, s in files.items()}
|
|
depth = {f: fdepths(t) for f, t in lexed.items()}
|
|
# tables written inside a function, constants assigned more than once or not at top level: not foldable
|
|
assigns = {}
|
|
for f, toks in lexed.items():
|
|
for i, (k, t, _) in enumerate(toks):
|
|
if not isref(toks, i): continue
|
|
if t in tables and depth[f][i] > 0:
|
|
j = i + 1
|
|
if nexttext(toks, i) == ".": j = i + 3
|
|
elif nexttext(toks, i) == "[":
|
|
b = 0
|
|
while True:
|
|
b += {"[": 1, "]": -1}.get(toks[j][1], 0); j += 1
|
|
if b == 0: break
|
|
if j < len(toks) and toks[j][1] == "=": tables.pop(t, None)
|
|
if t in consts and nexttext(toks, i) == "=":
|
|
assigns[t] = assigns.get(t, 0) + (1 if depth[f][i] == 0 else 2)
|
|
consts = {n: v for n, v in consts.items() if assigns.get(n) == 1 and numtext(v)}
|
|
out = {}
|
|
for f, toks in lexed.items():
|
|
shadow = declared(toks)
|
|
edits = []
|
|
for i, (k, t, p) in enumerate(toks):
|
|
if depth[f][i] == 0 or not isref(toks, i) or t in shadow: continue
|
|
if t in tables and nexttext(toks, i) == "." and i + 2 < len(toks):
|
|
key = toks[i + 2][1]
|
|
if key in tables[t] and numtext(tables[t][key]) and nexttext(toks, i + 2) not in ("=", ".", ":", "[", "(", "{") and toks[i + 3][0] != "str":
|
|
edits.append((p, toks[i + 2][2] + len(key), "%s --[[%s.%s]]" % (numtext(tables[t][key]), t, key)))
|
|
elif t in consts and nexttext(toks, i) not in ("=", ".", ":", "[", "(", "{") and toks[i + 1][0] != "str":
|
|
edits.append((p, p + len(t), "%s --[[%s]]" % (numtext(consts[t]), t)))
|
|
out[f] = apply(files[f], edits)
|
|
return out
|
|
|
|
if OPT["B"]:
|
|
try:
|
|
allf = dict(FILES); allf.update({"@hoist%d" % i: "local function __h()\n" + s + "\nend" for i, s in enumerate(hoisted)})
|
|
allf = fold(allf)
|
|
hoisted = [allf.pop("@hoist%d" % i)[len("local function __h()\n"):-len("\nend")] for i in range(len(hoisted))]
|
|
FILES = allf
|
|
except (OSError, RuntimeError) as e:
|
|
if os.environ.get("IMGUI_BUNDLE_STRICT"): raise # CI: a release must not silently lose the folding
|
|
print("bundle.py: enum folding skipped (%s)" % str(e).strip()[:300], file=sys.stderr)
|
|
|
|
# ---- A: hot globals -> bundle locals ----
|
|
hot = []
|
|
if OPT["A"]:
|
|
count, used = {}, []
|
|
for src in FILES.values():
|
|
toks = lex(src)
|
|
used.append({t for i, (k, t, _) in enumerate(toks) if isref(toks, i)})
|
|
for i, (k, t, _) in enumerate(toks):
|
|
if t in names and isref(toks, i): count[t] = count.get(t, 0) + 1
|
|
# greedy by reference count; each file function's upvalues (hoisted names it uses) stay <= MAX_UPVALUES
|
|
taken = [len(u & set(hoisted_names + re.findall(r"\w+", HOIST_DEPS.split("=")[0]))) for u in used]
|
|
for n in sorted((n for n in count if n not in hoisted_names), key=lambda n: (-count[n], n)):
|
|
if len(hot) == MAX_LOCALS: break
|
|
if all(taken[i] < MAX_UPVALUES for i, u in enumerate(used) if n in u):
|
|
hot.append(n)
|
|
taken = [c + (n in u) for c, u in zip(taken, used)]
|
|
|
|
out = ["--!optimize 2", "-- GENERATED by tools/bundle.py from *.lua, backends/, fonts/ - do not edit", ""]
|
|
if LOVE:
|
|
out = ["-- GENERATED by tools/bundle.py --target love - do not edit", re.search(r"--@compat-begin\n(.*?)--@compat-end", open(os.path.join(ROOT, "backends", "imgui_impl_love.lua")).read(), re.S).group(1), ""]
|
|
if hot: out.append("local " + ", ".join(hot))
|
|
if hoisted: out += [HOIST_DEPS] + hoisted
|
|
out += header()
|
|
linemap = [] # (first bundle line, file) for error -> source mapping (tools/test/run.py)
|
|
for name, src in sorted(FILES.items()):
|
|
out.append("__FILES[%r] = function()" % name)
|
|
linemap.append((sum(x.count("\n") + 1 for x in out) + 1, name))
|
|
out.append(src.rstrip("\n"))
|
|
out.append("end")
|
|
out.append("")
|
|
for name in ENTRY:
|
|
out.append("IM_INCLUDE(%r)" % name)
|
|
out.append("")
|
|
out.append("return {")
|
|
for n in sorted(names):
|
|
out.append(" %s = %s," % (n, n))
|
|
out.append("}")
|
|
|
|
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "build")
|
|
os.makedirs(OUT, exist_ok=True)
|
|
NAME = "imgui_love.lua" if LOVE else "ImGui.luau"
|
|
open(os.path.join(OUT, NAME), "w").write("\n".join(out) + "\n")
|
|
json.dump(linemap, open(os.path.join(OUT, NAME.rsplit(".", 1)[0] + ".linemap.json"), "w"))
|
|
if not LOVE:
|
|
from gen_types import wrapper
|
|
open(os.path.join(OUT, "ImGuiTyped.luau"), "w").write(wrapper(ORIG))
|
|
print("wrote build/%s (%d files, %d exports)" % (NAME, len(FILES), len(names)))
|