diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66201ae..87db682 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -89,6 +89,62 @@ jobs: revive -config revive.toml -formatter friendly ./... >> $GITHUB_STEP_SUMMARY printf '```\n\n' >> $GITHUB_STEP_SUMMARY + wasm: + name: Cross-build ${{ matrix.goos }}/${{ matrix.goarch }} with ${{ matrix.go-version }} on ${{ matrix.vm-os }} + runs-on: ${{ matrix.vm-os }} + strategy: + max-parallel: 10 + fail-fast: false + matrix: + vm-os: [ + ubuntu-22.04, + macos-14, + windows-2022 + ] + go-version: [ + 1.19.x, + 1.25.x, + ] + goos: [ + js, + wasip1 + ] + goarch: [ + wasm + ] + exclude: + # WASI support starts after Go 1.19; keep the compatibility floor + # on browser js/wasm and verify WASI on the current toolchain. + - go-version: 1.19.x + goos: wasip1 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + cache: true + - name: Cross-build package + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + go version + go tool dist list | grep -Fx "${GOOS}/${GOARCH}" + go mod download + go build . + - name: Guard REPL terminal dependencies + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + if go list -deps . | grep -E '^(go\.starlark\.net/repl|github\.com/chzyer/readline)$'; then + echo "terminal-only REPL dependency leaked into ${GOOS}/${GOARCH}" + exit 1 + fi + # cmd/starlet is a separate Go module with its own (older) pins and extra # third-party dependencies; build it as a non-gating signal so a CLI-side # breakage is visible without blocking library PRs. diff --git a/run.go b/run.go index 55f6bfd..202b069 100644 --- a/run.go +++ b/run.go @@ -11,20 +11,16 @@ import ( "github.com/1set/starlet/lib/goidiomatic" "github.com/1set/starlight/convert" - "go.starlark.net/repl" "go.starlark.net/starlark" "go.starlark.net/syntax" ) -// REPL is a Read-Eval-Print-Loop for Starlark. -// It loads the predeclared symbols and modules into the global environment, -func (m *Machine) REPL() { - if err := m.prepareThread(nil); err != nil { - repl.PrintError(err) - return - } - repl.REPLOptions(m.getFileOptions(), m.thread, m.predeclared) -} +// REPL is defined in run_repl.go (terminal targets) / run_repl_stub.go +// (non-terminal targets): the +// interactive REPL pulls go.starlark.net/repl -> chzyer/readline, a terminal +// library that does not compile for browser js/wasm or WASI. Isolating it +// behind a build tag keeps the library core (and every consumer, e.g. a WASM +// playground) free of that terminal dependency. // RunScript initiates a Machine, executes a script with extra variables, and returns the Machine and the execution result. func RunScript(content []byte, extras StringAnyMap) (*Machine, StringAnyMap, error) { diff --git a/run_repl.go b/run_repl.go new file mode 100644 index 0000000..787fc48 --- /dev/null +++ b/run_repl.go @@ -0,0 +1,19 @@ +//go:build !js && !wasip1 + +package starlet + +import "go.starlark.net/repl" + +// REPL is a Read-Eval-Print-Loop for Starlark. It loads the predeclared +// symbols and modules into the global environment and reads from the terminal. +// +// This lives in a terminal-target file because go.starlark.net/repl pulls in +// chzyer/readline, a terminal library that does not compile for browser +// js/wasm or WASI; see run_repl_stub.go for the no-terminal stub. +func (m *Machine) REPL() { + if err := m.prepareThread(nil); err != nil { + repl.PrintError(err) + return + } + repl.REPLOptions(m.getFileOptions(), m.thread, m.predeclared) +} diff --git a/run_repl_stub.go b/run_repl_stub.go new file mode 100644 index 0000000..c9488a7 --- /dev/null +++ b/run_repl_stub.go @@ -0,0 +1,12 @@ +//go:build js || wasip1 + +package starlet + +// REPL is unavailable on no-terminal wasm targets: the upstream +// go.starlark.net/repl pulls in chzyer/readline, which depends on terminal +// primitives that are not available in browser js/wasm or WASI. Drive the +// Machine with Run/RunScript instead. This stub keeps the method present so +// consumer code referencing REPL still compiles for wasm. +func (m *Machine) REPL() { + // no-op on no-terminal wasm targets; use Run/RunScript +} diff --git a/run_test.go b/run_test.go index 8ead922..04a1125 100644 --- a/run_test.go +++ b/run_test.go @@ -6,7 +6,9 @@ import ( "fmt" "io/fs" "os" + "os/exec" "reflect" + "strings" "testing" "time" @@ -2140,6 +2142,53 @@ func TestMachine_REPL_Error(t *testing.T) { m.REPL() } +// TestBuildsForWASM guards the wasm portability of the library core. +// The interactive REPL pulls go.starlark.net/repl -> github.com/chzyer/readline, +// a terminal library that does not compile for no-terminal wasm targets; it is +// therefore isolated behind build tags (run_repl.go for terminal targets, +// run_repl_stub.go for js/wasm and WASI). Re-importing a terminal dependency +// into an always-compiled file would silently break wasm consumers (e.g. a +// browser playground). +func TestBuildsForWASM(t *testing.T) { + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("go toolchain not found; skipping wasm build check") + } + + targets := []struct { + goos string + goarch string + }{ + {goos: "js", goarch: "wasm"}, + {goos: "wasip1", goarch: "wasm"}, + } + for _, target := range targets { + target := target + t.Run(target.goos+"/"+target.goarch, func(t *testing.T) { + if !goTargetSupported(t, goBin, target.goos, target.goarch) { + t.Skipf("go toolchain does not support %s/%s", target.goos, target.goarch) + } + cmd := exec.Command(goBin, "build", ".") + cmd.Env = append(os.Environ(), "GOOS="+target.goos, "GOARCH="+target.goarch) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("starlet must compile for GOOS=%s GOARCH=%s — did a terminal "+ + "dependency (e.g. chzyer/readline via go.starlark.net/repl) leak into "+ + "an always-compiled file?\n%s\n%v", target.goos, target.goarch, out, err) + } + }) + } +} + +func goTargetSupported(t *testing.T, goBin, goos, goarch string) bool { + t.Helper() + out, err := exec.Command(goBin, "tool", "dist", "list").Output() + if err != nil { + t.Logf("warning: failed to list go targets: %v", err) + return false + } + return strings.Contains(string(out), goos+"/"+goarch+"\n") +} + func Test_Machine_Run_LoadTypedErrors(t *testing.T) { // an unknown name surfaces as ModuleNotFoundError through the chain m := starlet.NewDefault()