diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 138f718..6cb9a1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,11 @@ jobs: python3 tools/bundle.py --no-demo dist/roblox-nodemo # ImGui.luau (library only) python3 tools/bundle.py --target love dist/love # imgui_love.lua (library + demo) python3 tools/bundle.py --target love --no-demo dist/love-nodemo + # minified variants (comments/whitespace stripped, same code): tools/minify.py + python3 tools/minify.py dist/roblox/ImGui.luau dist/ImGui.min.luau + python3 tools/minify.py dist/roblox-nodemo/ImGui.luau dist/ImGui-nodemo.min.luau + python3 tools/minify.py dist/love/imgui_love.lua dist/imgui_love.min.lua + python3 tools/minify.py dist/love-nodemo/imgui_love.lua dist/imgui_love-nodemo.min.lua - name: Check run: | @@ -45,12 +50,23 @@ jobs: sed 's|require(script:WaitForChild("Impl"))|nil|' dist/roblox/ImGuiTyped.luau > /tmp/ImGuiTyped.luau && luau-analyze /tmp/ImGuiTyped.luau luajit -bl dist/love/imgui_love.lua > /dev/null luajit -bl dist/love-nodemo/imgui_love.lua > /dev/null + python3 tools/minify.py --test + luau-compile --null dist/ImGui.min.luau + luau-compile --null dist/ImGui-nodemo.min.luau + luajit -bl dist/imgui_love.min.lua > /dev/null + luajit -bl dist/imgui_love-nodemo.min.lua > /dev/null python3 tools/test/run.py --main tools/test/scripts/t_readme.luau --frames 30 --quiet --roblox --timeout 300 | tee /tmp/r.txt grep -q "README OK" /tmp/r.txt python3 tools/test/run.py --main tools/test/scripts/t_cached_window.luau --frames 130 --quiet --roblox --timeout 300 | tee /tmp/c.txt grep -q "CACHED OK" /tmp/c.txt + MINIFY=1 python3 tools/test/run.py --main tools/test/scripts/t_readme.luau --frames 30 --quiet --roblox --timeout 300 | tee /tmp/rm.txt + grep -q "README OK" /tmp/rm.txt python3 tools/bundle.py --target love >/dev/null - luajit tools/test/love/t_drawhash.lua 60 Widgets,Basic + luajit tools/test/love/t_drawhash.lua 60 Widgets,Basic | tee /tmp/h.txt + cp build/imgui_love.lua /tmp/imgui_love.lua && cp dist/imgui_love.min.lua build/imgui_love.lua + luajit tools/test/love/t_drawhash.lua 60 Widgets,Basic | tee /tmp/hm.txt + cp /tmp/imgui_love.lua build/imgui_love.lua + diff /tmp/h.txt /tmp/hm.txt # minified LÖVE bundle draws exactly the same luajit tools/test/love/t_examples.lua - name: Package @@ -80,6 +96,8 @@ jobs: dist/ImGui-nodemo.luau dist/imgui_love.lua dist/imgui_love-nodemo.lua + dist/*.min.luau + dist/*.min.lua dist/*.zip generate_release_notes: true @@ -105,4 +123,4 @@ jobs: gh release delete latest --yes --cleanup-tag || true gh release create latest --latest --title "Latest build (${GITHUB_SHA::7})" \ --notes "Automatic build of ${GITHUB_SHA} on ${GITHUB_REF_NAME}. Tag a commit v* for a versioned release." \ - dist/ImGui.luau dist/ImGui-nodemo.luau dist/imgui_love.lua dist/imgui_love-nodemo.lua dist/*.zip + dist/ImGui.luau dist/ImGui-nodemo.luau dist/imgui_love.lua dist/imgui_love-nodemo.lua dist/*.min.luau dist/*.min.lua dist/*.zip diff --git a/tools/minify.py b/tools/minify.py new file mode 100644 index 0000000..b4b49ae --- /dev/null +++ b/tools/minify.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# Minifies a Lua/Luau file: drops comments, indentation, blank lines and spaces that don't separate tokens. +# Kept: strings (verbatim), "--!" directive lines (--!optimize, --!native, ...), @native attributes, and one newline +# wherever the source had one (so no ambiguous-call issues, and error line numbers stay roughly meaningful). +# python3 tools/minify.py IN OUT # write minified IN to OUT +# python3 tools/minify.py --same-code F.. # check F.. have the same code tokens as `git show HEAD:F` (comment-only edits) +# python3 tools/minify.py --same-code-as OLD NEW # same check between two files +# python3 tools/minify.py --test # self-check +import re, subprocess, sys +from functools import lru_cache + +TOKEN = re.compile(r""" + (?P[ \t\r\n\f\v]+) +| (?P--\[(?P=*)\[.*?\](?P=ceq)\]) +| (?P--[^\n]*) +| (?P\[(?P=*)\[.*?\](?P=seq)\]) +| (?P"(?:\\.|\\\n|[^"\\\n])*"|'(?:\\.|\\\n|[^'\\\n])*'|`(?:\\.|\\\n|[^`\\])*`) +| (?P0[xX][0-9a-fA-F_]*(?:\.[0-9a-fA-F_]*)?(?:[pP][+-]?\d+)?[uUlLi]*|0[bB][01_]+|(?:\d[\d_]*(?:\.[\d_]*)?|\.\d[\d_]*)(?:[eE][+-]?\d+)?[uUlLi]*) +| (?P@?[A-Za-z_]\w*) +| (?P\.\.\.|\.\.=|//=|\.\.|==|~=|<=|>=|//|::|->|[-+*/%^]=|.) +""", re.S | re.X) + +def tokenize(src): + """[(kind, text, newline_before)]; comments/whitespace folded into newline_before ("--!" lines kept as 'dir').""" + out, nl, pos = [], False, 0 + while pos < len(src): + m = TOKEN.match(src, pos) + kind, text = m.lastgroup, m.group(0) + if kind in ("ceq", "seq"): kind = "lcom" if text.startswith("--") else "lstr" + pos = m.end() + if kind == "ws" or kind == "lcom" or (kind == "com" and not text.startswith("--!")): + nl = nl or "\n" in text + continue + if kind == "com": kind = "dir" + if kind == "lstr" or kind == "str": kind = "str" + out.append((kind, text, nl)) + nl = False + return out + +@lru_cache(maxsize=None) +def glue_ok(a, b): + """True if `a..b` with no space still tokenizes as exactly a, b.""" + if re.match(r"\w", a[-1]) and re.match(r"\w", b[0]): return False # real lexers read `0then`/`x1` as one token + return [t for _, t, _ in tokenize(a + b)] == [a, b] + +def minify(src): + toks, out = tokenize(src), [] + prev = None + for kind, text, nl in toks: + if prev is not None: + if nl or prev[0] == "dir" or kind == "dir": out.append("\n") + elif kind == "str" or prev[0] == "str": # strings are self-delimiting; only `[` + `[[` would fuse + if kind == "str" and text[0] == "[" and prev[1].endswith("["): out.append(" ") + elif not glue_ok(prev[1], text): + out.append(" ") + out.append(text) + prev = (kind, text) + return "".join(out) + "\n" + +def code(src): + return [(k, t) for k, t, _ in tokenize(src)] + +def test(): + cases = { + "local a = 1 -- c\nlocal b = a - -1\n": "local a=1\nlocal b=a- -1\n", + "x = 1 .. 2": "x=1 ..2\n", + "x = t[ [[s]] ]": "x=t[ [[s]]]\n", + "x = t[ [==[s]==] ]": "x=t[ [==[s]==]]\n", + "--!optimize 2\n--[[ long\ncomment ]] local s = 'a -- b' .. \"c\\\"d\"": "--!optimize 2\nlocal s='a -- b'..\"c\\\"d\"\n", + "@native\nfunction f(...) return ... end": "@native\nfunction f(...)return...end\n", + "a = b == c and d ~= e or f <= g": "a=b==c and d~=e or f<=g\n", + "x = 0x10 + 1e-5 + .5": "x=0x10+1e-5+.5\n", + "f(a)\n\n\n(g)(b)": "f(a)\n(g)(b)\n", + "s = [[a\n-- not a comment\n]]": "s=[[a\n-- not a comment\n]]\n", + "x = a / / b": "x=a/ /b\n", + "x = a . . b": "x=a. .b\n", + "if a == 0 then return 1 end": "if a==0 then return 1 end\n", + "x = 1 .. y": "x=1 ..y\n", + } + for src, want in cases.items(): + got = minify(src) + assert got == want, (src, got, want) + assert code(minify(src)) == code(src), src + print("minify OK") + +if __name__ == "__main__": + if sys.argv[1] == "--test": + test() + elif sys.argv[1] in ("--same-code", "--same-code-as"): + bad = 0 + pairs = [(sys.argv[2], sys.argv[3])] if sys.argv[1] == "--same-code-as" else [(None, f) for f in sys.argv[2:]] + for ref, f in pairs: + old = open(ref).read() if ref else subprocess.run(["git", "show", "HEAD:" + f], capture_output=True, text=True, check=True).stdout + a, b = code(old), code(open(f).read()) + if a != b: + bad += 1 + i = next((i for i, (x, y) in enumerate(zip(a, b)) if x != y), min(len(a), len(b))) + print("%s: CODE CHANGED at token %d: %r -> %r" % (f, i, a[i:i + 6], b[i:i + 6])) + else: + print("%s: same code" % f) + sys.exit(1 if bad else 0) + else: + src = open(sys.argv[1], encoding="utf-8").read() + dst = minify(src) + assert code(dst) == code(src), "minify changed code tokens" + open(sys.argv[2], "w", encoding="utf-8").write(dst) + print("wrote %s (%d -> %d bytes, %.0f%%)" % (sys.argv[2], len(src.encode()), len(dst.encode()), 100.0 * len(dst) / len(src))) diff --git a/tools/test/run.py b/tools/test/run.py index 3fae0e4..3b76794 100644 --- a/tools/test/run.py +++ b/tools/test/run.py @@ -16,6 +16,8 @@ G = "game, workspace, Instance, Enum, Vector2, Vector3, UDim2, Color3, Content, def build_run(args, flags): subprocess.run([sys.executable, os.path.join(ROOT, "tools", "bundle.py"), TMP] + (["--no-demo"] if os.environ.get("NO_DEMO") else []), check=True, stdout=subprocess.DEVNULL) + if os.environ.get("MINIFY"): # test the minified bundle instead (error line numbers won't map back to sources) + subprocess.run([sys.executable, os.path.join(ROOT, "tools", "minify.py"), os.path.join(TMP, "ImGui.luau"), os.path.join(TMP, "ImGui.luau")], check=True, stdout=subprocess.DEVNULL) out = ["--" if args.roblox else "--!native", "--!optimize 2", flags, "local SOURCE_FNS = {}"] def add(key, path): s = "\n".join(l for l in open(path).read().split("\n") if not l.startswith("--!"))