Fixed some bugs discovered by Claude Code

This commit is contained in:
Leon Mika 2026-02-15 16:03:45 +11:00
parent 852ea7c0f7
commit 33cf23b221
10 changed files with 84 additions and 23 deletions

57
CLAUDE.md Normal file
View file

@ -0,0 +1,57 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
UCL is an embeddable command-based scripting language implemented in Go, with Tcl/shell-like syntax. It is a **library** — there is no root `main.go`. All executables live under `cmd/`.
## Build & Test Commands
- **Run all tests:** `make test` (or `go test ./ucl/...`)
- **Run a single test:** `go test ./ucl/... -run TestName`
- **Run builtin module tests:** `go test ./ucl/builtins/...`
- **Build site + WASM playground:** `make site`
- **Clean build artifacts:** `make clean`
## Architecture
### Core Package (`ucl/`)
The language engine follows a **parse → tree-walk evaluate** pipeline:
- **`ast.go`** — AST node types and parser (using `participle` library with struct-tag grammar)
- **`eval.go`** — Tree-walk evaluator: scripts, statements, commands, pipelines, arguments, dot-access, string interpolation
- **`objs.go`** — Object type system: `StringObject`, `IntObject`, `BoolObject`, `ListObject`, `HashObject`, `TimeObject`, plus proxy types for Go interop via reflection
- **`inst.go`** — `Inst` (interpreter instance): the public API for creating and configuring the evaluator
- **`builtins.go`** — Built-in functions and macros (control flow, math, collections)
- **`env.go`** — `evalCtx`: scoped evaluation context (linked-list chain for variable/command lookup)
- **`userbuiltin.go`** — Public embedding API (`BuiltinHandler`, `CallArgs` with reflection-based argument binding)
### Two Command Dispatch Kinds
- **Invokables** (`invokable` interface): receive fully-evaluated arguments. Used by all regular functions, Go builtins, user `proc`s, and blocks.
- **Macros** (`macroable` interface): receive unevaluated AST + evaluator. Used by `if`, `for`, `while`, `proc`, `try` for lazy evaluation control.
### Optional Builtin Modules (`ucl/builtins/`)
Modules (`strs`, `lists`, `itrs`, `fns`, `os`, `fs`, `log`, `csv`, `time`, `urls`) are opt-in via `ucl.WithModule(builtins.Strs())` etc. Each is a self-contained file.
### Other Packages
- **`repl/`** — REPL wrapper with `EvalAndDisplay()`, help system, and `AddTypePrinter[T]` generic
- **`cmd/cmsh/`** — Interactive shell using `readline`
- **`cmd/playwasm/`** — WASM playground (builds with `GOOS=js GOARCH=wasm`)
- **`cmd/gendocs/`** — Markdown-to-HTML doc site generator (goldmark + frontmatter)
## Testing Conventions
- Tests use `testify/assert` with **table-driven** patterns (`[]struct{ desc, expr string; want any }`)
- Test packages use the `_test` suffix for black-box testing
- Shared test helpers (e.g., `WithTestBuiltin()`) are in `ucl/builtins_test.go` and provide test-only commands (`firstarg`, `toUpper`, `sjoin`, etc.)
## CI
Forgejo workflows in `.forgejo/workflows/`:
- `test.yaml` — runs `make test` on `feature/*` branches
- `build.yaml` — runs tests + `make site-deploy` on `main`

View file

@ -8,6 +8,7 @@ import (
"os" "os"
"reflect" "reflect"
"strings" "strings"
"ucl.lmika.dev/ucl" "ucl.lmika.dev/ucl"
) )
@ -44,7 +45,7 @@ func (r *REPL) echoPrinter(ctx context.Context, w io.Writer, args []any) (err er
} }
_, err = fmt.Fprintln(w, res) _, err = fmt.Fprintln(w, res)
return nil return err
} }
func (r *REPL) displayResult(ctx context.Context, w io.Writer, res any, concise bool) (err error) { func (r *REPL) displayResult(ctx context.Context, w io.Writer, res any, concise bool) (err error) {
@ -56,7 +57,7 @@ func (r *REPL) displayResult(ctx context.Context, w io.Writer, res any, concise
switch v := res.(type) { switch v := res.(type) {
case nil: case nil:
if _, err = fmt.Fprintln(os.Stdout, "(nil)"); err != nil { if _, err = fmt.Fprintln(w, "(nil)"); err != nil {
return err return err
} }
case ucl.Listable: case ucl.Listable:
@ -92,6 +93,10 @@ func (r *REPL) displayResult(ctx context.Context, w io.Writer, res any, concise
} }
fmt.Fprintf(w, "]") fmt.Fprintf(w, "]")
} else { } else {
if v == nil {
fmt.Fprintf(w, "(nil)")
return nil
}
// In the off-chance that this is actually a slice of printables // In the off-chance that this is actually a slice of printables
vt := reflect.SliceOf(reflect.TypeOf(v[0])) vt := reflect.SliceOf(reflect.TypeOf(v[0]))
if tp, ok := r.typePrinters[vt]; ok { if tp, ok := r.typePrinters[vt]; ok {

View file

@ -174,5 +174,5 @@ var parser = participle.MustBuild[astScript](participle.Lexer(scanner),
participle.Elide("Whitespace", "Comment")) participle.Elide("Whitespace", "Comment"))
func parse(fname string, r io.Reader) (*astScript, error) { func parse(fname string, r io.Reader) (*astScript, error) {
return parser.Parse("test", r) return parser.Parse(fname, r)
} }

View file

@ -455,7 +455,6 @@ func callBuiltin(ctx context.Context, args invocationArgs) (Object, error) {
for i := 0; i < argList.Len(); i++ { for i := 0; i < argList.Len(); i++ {
calledArgs[i] = argList.Index(i) calledArgs[i] = argList.Index(i)
} }
args.shift(1)
} }
invArgs := args.fork(calledArgs) invArgs := args.fork(calledArgs)
@ -932,7 +931,7 @@ func (s seqObject) Len() int {
func (s seqObject) Index(i int) Object { func (s seqObject) Index(i int) Object {
l := s.Len() l := s.Len()
if i < 0 || i > l { if i < 0 || i >= l {
return nil return nil
} }
if s.from > s.to { if s.from > s.to {
@ -1028,6 +1027,9 @@ func tryBuiltin(ctx context.Context, args macroArgs) (res Object, err error) {
finallyBlocks = append(finallyBlocks, i+1) finallyBlocks = append(finallyBlocks, i+1)
} }
} }
if len(finallyBlocks) > 1 {
return nil, tooManyFinallyBlocksError(args.ast.Pos)
}
defer func() { defer func() {
if isBreakErr(err) { if isBreakErr(err) {
@ -1122,7 +1124,13 @@ func foreachBuiltin(ctx context.Context, args macroArgs) (Object, error) {
case Hashable: case Hashable:
err := t.Each(func(k string, v Object) error { err := t.Each(func(k string, v Object) error {
last, err = args.evalBlock(ctx, blockIdx, []Object{StringObject(k), v}, false) last, err = args.evalBlock(ctx, blockIdx, []Object{StringObject(k), v}, false)
return err if err != nil {
if errors.As(err, &breakErr) && breakErr.isCont {
return nil
}
return err
}
return nil
}) })
if errors.As(err, &breakErr) { if errors.As(err, &breakErr) {
if !breakErr.isCont { if !breakErr.isCont {

View file

@ -116,6 +116,7 @@ func eachListOrIterItem(ctx context.Context, o ucl.Object, f func(int, ucl.Objec
} }
idx++ idx++
} }
return nil
} }
return errors.New("expected listable") return errors.New("expected listable")
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"strings" "strings"
"ucl.lmika.dev/ucl" "ucl.lmika.dev/ucl"
) )
@ -36,9 +37,10 @@ func (lh logHandler) logMessage(ctx context.Context, args ucl.CallArgs) (any, er
var s ucl.Object var s ucl.Object
for args.NArgs() > 0 { for args.NArgs() > 0 {
if err := args.Bind(&s); err == nil { if err := args.Bind(&s); err != nil {
sb.WriteString(s.String()) return nil, err
} }
sb.WriteString(s.String())
} }
return nil, lh.handler(sb.String()) return nil, lh.handler(sb.String())
} }

View file

@ -113,7 +113,7 @@ func (ec *evalCtx) lookupInvokable(name string) invokable {
} }
} }
return ec.parent.lookupInvokable(name) return nil
} }
func (ec *evalCtx) lookupMacro(name string) macroable { func (ec *evalCtx) lookupMacro(name string) macroable {
@ -127,5 +127,5 @@ func (ec *evalCtx) lookupMacro(name string) macroable {
} }
} }
return ec.parent.lookupMacro(name) return nil
} }

View file

@ -326,7 +326,7 @@ func (e evaluator) assignArg(ctx context.Context, ec *evalCtx, n astCmdArg, toVa
} }
return toVal, nil return toVal, nil
} }
return nil, errors.New("unknown pseudo-variable: " + *n.Var) return nil, fmt.Errorf("unknown pseudo-variable: %v", n.PseudoVar)
case n.MaybeSub != nil: case n.MaybeSub != nil:
return nil, errors.New("cannot assign to a subexpression") return nil, errors.New("cannot assign to a subexpression")
case n.ListOrHash != nil: case n.ListOrHash != nil:

View file

@ -444,17 +444,6 @@ func (ia invocationArgs) expectArgn(x int) error {
return nil return nil
} }
func (ia invocationArgs) stringArg(i int) (string, error) {
if len(ia.args) < i {
return "", errors.New("expected at least " + strconv.Itoa(i) + " args")
}
s, ok := ia.args[i].(fmt.Stringer)
if !ok {
return "", errors.New("expected a string arg")
}
return s.String(), nil
}
func (ia invocationArgs) intArg(i int) (int, error) { func (ia invocationArgs) intArg(i int) (int, error) {
if len(ia.args) < i { if len(ia.args) < i {
return 0, errors.New("expected at least " + strconv.Itoa(i) + " args") return 0, errors.New("expected at least " + strconv.Itoa(i) + " args")

View file

@ -239,7 +239,6 @@ func canBindProxyObject(v interface{}, r reflect.Value) bool {
for { for {
if r.Type().AssignableTo(argValue.Elem().Type()) { if r.Type().AssignableTo(argValue.Elem().Type()) {
argValue.Elem().Set(r)
return true return true
} }
if r.Type().Kind() != reflect.Pointer { if r.Type().Kind() != reflect.Pointer {