Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/workflows/lgb-diff.yml
Original file line number Diff line number Diff line change
@@ -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='<!-- lgb-diff -->'
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 "<details><summary>unified diff ($LINES lines)</summary>"
echo ""
echo '```diff'
head -400 /tmp/lgb.diff
echo '```'
echo "</details>"
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
14 changes: 14 additions & 0 deletions pkg/compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand Down
69 changes: 69 additions & 0 deletions pkg/compiler/void_toplevel_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Binary file modified pkg/rt/core_compiled.lgb
Binary file not shown.
75 changes: 75 additions & 0 deletions scripts/lgbdump.lg
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
;; scripts/lgbdump.lg — canonical text dump of a .lgb bundle, for diffing.
;;
;; ./lg scripts/lgbdump.lg <bundle.lgb>
;;
;; 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 <bundle.lgb>")
(os/exit 2))
(dump-bundle path)))
Loading