diff --git a/.github/workflows/lgb-diff.yml b/.github/workflows/lgb-diff.yml new file mode 100644 index 000000000..f126cf3e7 --- /dev/null +++ b/.github/workflows/lgb-diff.yml @@ -0,0 +1,95 @@ +name: lgb-diff + +# Opt-in bundle diff: label a PR `lgb-diff` and this posts (and keeps updated) +# a sticky comment with the semantic bytecode diff of pkg/rt/core_compiled.lgb +# against the merge-base, rendered by scripts/lgbdump.lg. Same-repo PRs only: +# fork PRs get a read-only token, so the comment step would fail there. + +on: + pull_request: + types: [labeled, synchronize] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: lgb-diff-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + diff: + if: contains(github.event.pull_request.labels.*.name, 'lgb-diff') + runs-on: ubuntu-latest + steps: + # Head SHA, not the merge ref: the lg built here must share an opcode + # enum with the bundle it dumps, and the head tree is that producer. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Locate bundle change + id: bundle + run: | + MB=$(git merge-base HEAD "origin/${{ github.event.pull_request.base.ref }}") + echo "mb=$MB" >> "$GITHUB_OUTPUT" + if git diff --quiet "$MB" HEAD -- pkg/rt/core_compiled.lgb; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/setup-go@v5 + if: steps.bundle.outputs.changed == 'true' + with: + go-version-file: go.mod + cache: true + + - name: Build lg, dump both bundles, diff + if: steps.bundle.outputs.changed == 'true' + run: | + make lg + MB=${{ steps.bundle.outputs.mb }} + git show "$MB:pkg/rt/core_compiled.lgb" > /tmp/base.lgb + ./lg scripts/lgbdump.lg /tmp/base.lgb > /tmp/base.txt + ./lg scripts/lgbdump.lg pkg/rt/core_compiled.lgb > /tmp/head.txt + # diff(1) semantics: exit 1 just means the bundles differ. + diff -u --label "base ($(git rev-parse --short "$MB"))" --label head \ + /tmp/base.txt /tmp/head.txt > /tmp/lgb.diff || true + + - name: Post sticky comment + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + MARKER='' + MB_SHORT=$(git rev-parse --short "${{ steps.bundle.outputs.mb }}") + { + echo "$MARKER" + echo "### lgb-diff: \`pkg/rt/core_compiled.lgb\` vs merge-base \`$MB_SHORT\`" + echo "" + if [ "${{ steps.bundle.outputs.changed }}" = "false" ]; then + echo "Bundle unchanged." + else + LINES=$(wc -l < /tmp/lgb.diff) + echo "
unified diff ($LINES lines)" + echo "" + echo '```diff' + head -400 /tmp/lgb.diff + echo '```' + echo "
" + if [ "$LINES" -gt 400 ]; then + echo "" + echo "_Truncated at 400 lines; dump both bundles with \`scripts/lgbdump.lg\` locally for the rest._" + fi + fi + } > /tmp/comment.md + CID=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1) + if [ -n "$CID" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$CID" -F body=@/tmp/comment.md + else + gh pr comment "$PR" --repo "$REPO" --body-file /tmp/comment.md + fi diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go index d692cfd1f..e806b463f 100644 --- a/pkg/compiler/compiler.go +++ b/pkg/compiler/compiler.go @@ -195,6 +195,7 @@ func (c *Context) CompileMultiple(reader io.Reader) (compiled *vm.CodeChunk, res compiledForms++ return nil } + sawVoid := false for { o, err := r.Read() if err != nil { @@ -203,10 +204,23 @@ func (c *Context) CompileMultiple(reader io.Reader) (compiled *vm.CodeChunk, res } return nil, result, err } + // Comments, #_ discards, and empty reader conditionals read as the + // VOID sentinel. Compiling them would emit a dead LOAD_CONST/POP pair + // per occurrence (and let a trailing comment clobber the last value), + // so skip them here like every other read loop does. + if o.Type() == vm.VoidType { + sawVoid = true + continue + } if err := evalTopForm(o); err != nil { return nil, result, err } } + // Input that held only no-value forms still evaluates to VOID (not nil), + // so the REPL keeps echoing nothing for a comment-only line. + if compiledForms == 0 && sawVoid { + result = vm.VOID + } c.chunk = chunk diff --git a/pkg/compiler/void_toplevel_test.go b/pkg/compiler/void_toplevel_test.go new file mode 100644 index 000000000..7899b713f --- /dev/null +++ b/pkg/compiler/void_toplevel_test.go @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 let-go contributors + * SPDX-License-Identifier: MIT + */ + +package compiler + +import ( + "strings" + "testing" + + "github.com/nooga/let-go/pkg/rt" + "github.com/nooga/let-go/pkg/vm" +) + +// A trailing comment (or #_ discard) must not clobber the last value: the +// reader surfaces those as the VOID sentinel and CompileMultiple used to +// compile-and-run it like a real form, so `42 ;; done` evaluated to VOID. +func TestTrailingCommentKeepsLastValue(t *testing.T) { + for _, src := range []string{ + "42 ;; done", + "42\n;; done\n", + "42 #_(discarded)", + "42 #?(:cljr 1)", + } { + v, err := Eval(src) + if err != nil { + t.Fatalf("Eval(%q): %v", src, err) + } + if n, ok := v.(vm.Int); !ok || int(n) != 42 { + t.Fatalf("Eval(%q) = %v (%s), want 42", src, v, v.Type().Name()) + } + } +} + +// Input holding only no-value forms still evaluates to VOID, not nil — the +// REPL relies on this to echo nothing for a comment-only line. +func TestCommentOnlyInputEvaluatesToVoid(t *testing.T) { + for _, src := range []string{ + ";; just a comment", + "#_(all discarded)", + } { + v, err := Eval(src) + if err != nil { + t.Fatalf("Eval(%q): %v", src, err) + } + if v != vm.VOID { + t.Fatalf("Eval(%q) = %v (%s), want VOID", src, v, v.Type().Name()) + } + } +} + +// Skipped forms must not leave dead LOAD_CONST/POP pairs in the chunk: a +// comment between two forms compiles identically to no comment at all. +func TestVoidFormsEmitNoCode(t *testing.T) { + compile := func(src string) []int32 { + c := NewTransientCompiler(consts, rt.NS(rt.NameCoreNS)) + chunk, _, err := c.CompileMultiple(strings.NewReader(src)) + if err != nil { + t.Fatalf("CompileMultiple(%q): %v", src, err) + } + return chunk.Code() + } + plain := compile("1 2") + commented := compile("1 ;; between\n#_(dead) 2") + if len(plain) != len(commented) { + t.Fatalf("comments changed emitted code size: %d words vs %d", len(plain), len(commented)) + } +} diff --git a/pkg/rt/core_compiled.lgb b/pkg/rt/core_compiled.lgb index 22e4f1525..8a893fb48 100644 Binary files a/pkg/rt/core_compiled.lgb and b/pkg/rt/core_compiled.lgb differ diff --git a/scripts/lgbdump.lg b/scripts/lgbdump.lg new file mode 100644 index 000000000..959c1086a --- /dev/null +++ b/scripts/lgbdump.lg @@ -0,0 +1,75 @@ +;; scripts/lgbdump.lg — canonical text dump of a .lgb bundle, for diffing. +;; +;; ./lg scripts/lgbdump.lg +;; +;; Diff two bundles by diffing two dumps: +;; +;; diff -u <(./lg scripts/lgbdump.lg old.lgb) <(./lg scripts/lgbdump.lg new.lgb) +;; +;; Builds on disasm/decode-bundle (decodes without executing) and +;; disassemble-resolved (LOAD_CONST/LOAD_VAR rows carry the referenced +;; identifier, so const-pool index shifts don't drown a diff). Output is +;; deterministic: namespaces sorted by name, no addresses or pool order. +;; +;; Named functions from the shared const pool are dumped under their name; +;; anonymous ones under a bare "fn anon" label (a pool index would shift on +;; any insertion, making identical fns diff as different). + +(ns lgbdump + (:require [disasm])) + +;; A resolved LOAD_CONST/LOAD_VAR row is [op pool-idx identifier]. The pool +;; index shifts whenever any earlier const is added or removed, which turns a +;; one-fn change into a whole-bundle diff — so for diffing, keep only the +;; identifier. Unresolved rows keep their index (it's all we have). +(defn scrub-row [row] + (if (and (>= (count row) 3) + (or (= :LOAD_CONST (first row)) (= :LOAD_VAR (first row)))) + (vector (first row) (nth row 2)) + row)) + +;; Top-level comments and #_ discards compile to a LOAD_CONST-VOID/POP pair +;; (dead code; being removed by #600). Rendering each pair would make "added +;; a comment" look like bytecode churn, so suppress them and print one count +;; line per chunk instead — the count still diffs when it changes. +(defn void-load? [row] + (and (= :LOAD_CONST (first row)) + (let [x (nth row (dec (count row)))] + (and (vector? x) (= :opaque (first x)) (= "VOID" (second x)))))) + +(defn dump-chunk [label chunk] + (println (str "== " label " ==")) + (loop [rows (seq (disasm/disassemble-resolved chunk)) skipped 0] + (if (nil? rows) + (when (> skipped 0) + (println (str ";; " skipped " no-value pairs suppressed"))) + (let [row (first rows) + nxt (next rows)] + (if (and (void-load? row) nxt (= :POP (first (first nxt)))) + (recur (next nxt) (inc skipped)) + (do (println (pr-str (scrub-row row))) + (recur nxt skipped))))))) + +(defn dump-bundle [path] + (let [b (disasm/decode-bundle path) + nss (sort-by (fn [m] (get m :ns)) (get b :namespaces))] + (println (str ";; lgbdump: " (count nss) " namespaces, " + (count (get b :consts)) " consts")) + (dump-chunk "main" (get b :main)) + (doseq [m nss] + (dump-chunk (str "ns " (get m :ns)) (get m :chunk))) + ;; Nested fns live in the bundle-wide shared const pool: raw values give + ;; the chunks, the projected view gives stable [:fn name] labels. + (let [raw (disasm/constants (get b :main)) + proj (get b :consts)] + (doseq [i (range (count raw))] + (let [p (nth proj i)] + (when (and (vector? p) (= :fn (first p))) + (dump-chunk (str "fn " (if (> (count p) 1) (second p) "anon")) + (nth raw i)))))))) + +(let [path (first *command-line-args*)] + (if (nil? path) + (do (println "usage: lg scripts/lgbdump.lg ") + (os/exit 2)) + (dump-bundle path)))