diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..92950bf --- /dev/null +++ b/CLAUDE.md @@ -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` diff --git a/repl/evaldisplay.go b/repl/evaldisplay.go index 083e4e3..4fc841d 100644 --- a/repl/evaldisplay.go +++ b/repl/evaldisplay.go @@ -8,6 +8,7 @@ import ( "os" "reflect" "strings" + "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) - return nil + return err } 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) { case nil: - if _, err = fmt.Fprintln(os.Stdout, "(nil)"); err != nil { + if _, err = fmt.Fprintln(w, "(nil)"); err != nil { return err } case ucl.Listable: @@ -92,6 +93,10 @@ func (r *REPL) displayResult(ctx context.Context, w io.Writer, res any, concise } fmt.Fprintf(w, "]") } else { + if v == nil { + fmt.Fprintf(w, "(nil)") + return nil + } // In the off-chance that this is actually a slice of printables vt := reflect.SliceOf(reflect.TypeOf(v[0])) if tp, ok := r.typePrinters[vt]; ok { diff --git a/ucl/ast.go b/ucl/ast.go index 719f381..e27ec01 100644 --- a/ucl/ast.go +++ b/ucl/ast.go @@ -174,5 +174,5 @@ var parser = participle.MustBuild[astScript](participle.Lexer(scanner), participle.Elide("Whitespace", "Comment")) func parse(fname string, r io.Reader) (*astScript, error) { - return parser.Parse("test", r) + return parser.Parse(fname, r) } diff --git a/ucl/builtins.go b/ucl/builtins.go index 59edecd..c984394 100644 --- a/ucl/builtins.go +++ b/ucl/builtins.go @@ -455,7 +455,6 @@ func callBuiltin(ctx context.Context, args invocationArgs) (Object, error) { for i := 0; i < argList.Len(); i++ { calledArgs[i] = argList.Index(i) } - args.shift(1) } invArgs := args.fork(calledArgs) @@ -932,7 +931,7 @@ func (s seqObject) Len() int { func (s seqObject) Index(i int) Object { l := s.Len() - if i < 0 || i > l { + if i < 0 || i >= l { return nil } 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) } } + if len(finallyBlocks) > 1 { + return nil, tooManyFinallyBlocksError(args.ast.Pos) + } defer func() { if isBreakErr(err) { @@ -1122,7 +1124,13 @@ func foreachBuiltin(ctx context.Context, args macroArgs) (Object, error) { case Hashable: err := t.Each(func(k string, v Object) error { 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 !breakErr.isCont { diff --git a/ucl/builtins/lists.go b/ucl/builtins/lists.go index e62ed77..0aa8264 100644 --- a/ucl/builtins/lists.go +++ b/ucl/builtins/lists.go @@ -116,6 +116,7 @@ func eachListOrIterItem(ctx context.Context, o ucl.Object, f func(int, ucl.Objec } idx++ } + return nil } return errors.New("expected listable") } diff --git a/ucl/builtins/log.go b/ucl/builtins/log.go index c336833..0f2458f 100644 --- a/ucl/builtins/log.go +++ b/ucl/builtins/log.go @@ -4,6 +4,7 @@ import ( "context" "log" "strings" + "ucl.lmika.dev/ucl" ) @@ -36,9 +37,10 @@ func (lh logHandler) logMessage(ctx context.Context, args ucl.CallArgs) (any, er var s ucl.Object for args.NArgs() > 0 { - if err := args.Bind(&s); err == nil { - sb.WriteString(s.String()) + if err := args.Bind(&s); err != nil { + return nil, err } + sb.WriteString(s.String()) } return nil, lh.handler(sb.String()) } diff --git a/ucl/env.go b/ucl/env.go index 7a58cc1..307a372 100644 --- a/ucl/env.go +++ b/ucl/env.go @@ -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 { @@ -127,5 +127,5 @@ func (ec *evalCtx) lookupMacro(name string) macroable { } } - return ec.parent.lookupMacro(name) + return nil } diff --git a/ucl/eval.go b/ucl/eval.go index c6469ae..3d8c14e 100644 --- a/ucl/eval.go +++ b/ucl/eval.go @@ -326,7 +326,7 @@ func (e evaluator) assignArg(ctx context.Context, ec *evalCtx, n astCmdArg, toVa } 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: return nil, errors.New("cannot assign to a subexpression") case n.ListOrHash != nil: diff --git a/ucl/objs.go b/ucl/objs.go index fedb25f..70f7cd8 100644 --- a/ucl/objs.go +++ b/ucl/objs.go @@ -444,17 +444,6 @@ func (ia invocationArgs) expectArgn(x int) error { 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) { if len(ia.args) < i { return 0, errors.New("expected at least " + strconv.Itoa(i) + " args") diff --git a/ucl/userbuiltin.go b/ucl/userbuiltin.go index 87046f3..1222d17 100644 --- a/ucl/userbuiltin.go +++ b/ucl/userbuiltin.go @@ -239,7 +239,6 @@ func canBindProxyObject(v interface{}, r reflect.Value) bool { for { if r.Type().AssignableTo(argValue.Elem().Type()) { - argValue.Elem().Set(r) return true } if r.Type().Kind() != reflect.Pointer {