diff --git a/README.md b/README.md index a2a8ce7..99d404f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ go-polyscript democratizes different scripting engines by abstracting the loadin ## Engines Implemented -- **Risor**: A Python-like scripting language designed for embedding in Go applications +- **Risor**: A fast scripting language designed for embedding in Go applications - **Starlark**: Google's deterministic configuration language (used in Bazel, and others) - **Extism**: Pure Go runtime and plugin system for executing WASM @@ -51,15 +51,15 @@ func main() { script := ` // The ctx object from the Go inputData map - name := ctx.get("name") + let name = ctx.get("name") - p := "." - if ctx.get("excited") { + let p = "." + if (ctx.get("excited")) { p = "!" } - - message := "Hello, " + name + p - + + let message = "Hello, " + name + p + // Return a map with our result { "greeting": message, diff --git a/engines/README.md b/engines/README.md index a99f991..0450ff6 100644 --- a/engines/README.md +++ b/engines/README.md @@ -69,8 +69,8 @@ data := map[string]any{ } // Risor script access -name := ctx["name"] // "World" -debug := ctx["config"]["debug"] // true +let name = ctx["name"] // "World" +let debug = ctx["config"]["debug"] // true ``` ### Starlark Engine: `ctx` Context Wrapper diff --git a/engines/benchmarks/README.md b/engines/benchmarks/README.md index b9cd203..d9b6c33 100644 --- a/engines/benchmarks/README.md +++ b/engines/benchmarks/README.md @@ -4,20 +4,29 @@ This directory contains benchmarking tools and historical benchmark records for ## Performance Optimization Patterns +The numbers below are from `BenchmarkEvaluationPatterns`, `BenchmarkDataProviders`, and `BenchmarkEngineComparison` on an Apple M5 Max (darwin/arm64) using the trivial greeting script in [`benchmark_test.go`](./benchmark_test.go). Your absolute numbers will differ, but the relative deltas are the signal. See [`results/latest.txt`](./results/latest.txt) for the authoritative most-recent run. + ### 1. Compile Once, Run Many Times -The benchmarks show that pre-loading and parsing scripts is ~35% faster than recompiling for each execution: +Reusing a compiled evaluator is **~2.25x faster** than recompiling the script on every call, and allocates **~44% less memory** / **~65% fewer objects**: + +| Pattern | ns/op | B/op | allocs/op | +|----------------------|--------:|--------:|----------:| +| SingleExecution | 48,323 | 120,751 | 713 | +| CompileOnceRunMany | 21,435 | 67,682 | 252 | ```go // COMPILATION PHASE (expensive, do once) evaluator, err := polyscript.FromRisorString(script, options...) -// EXECUTION PHASE (inexpensive, do many times) +// EXECUTION PHASE (cheap, do many times) result1, _ := evaluator.Eval(ctx1) result2, _ := evaluator.Eval(ctx2) result3, _ := evaluator.Eval(ctx3) ``` +The single-execution path pays parser + compiler + globals-validation cost per call. If you're running the same script more than once, create the evaluator once and hold onto it. + ### 2. Data Preparation Separation For distributed architectures, separate data preparation from evaluation to improve system architecture design: @@ -32,19 +41,32 @@ result, _ := evaluator.Eval(enrichedCtx) ### 3. Provider Performance Comparison -The benchmarks show performance differences between `data.Provider` implementations: +On `BenchmarkDataProviders` (Risor engine), all three `data.Provider` implementations land within ~3.5% of each other — raw provider throughput is **not** a meaningful selection criterion. Choose based on data flow shape, not speed: -- **StaticProvider**: Fastest overall (~5-10% faster than other providers) - use when input data is static -- **ContextProvider**: Needed for request-specific data that varies per execution -- **CompositeProvider**: Small overhead but enables both static configuration and dynamic request data +| Provider | ns/op | B/op | allocs/op | +|--------------------|--------:|-------:|----------:| +| StaticProvider | 22,354 | 67,382 | 251 | +| ContextProvider | 21,604 | 66,283 | 243 | +| CompositeProvider | 21,966 | 67,382 | 253 | + +- **StaticProvider** — data is fixed at evaluator creation (config, constants, feature flags). +- **ContextProvider** — data varies per-call; carried via `context.Context`. +- **CompositeProvider** — the backing store for the 2-step pattern (static config + dynamic per-request data). ### 4. Script Engine Performance Characteristics -Performance characteristics vary significantly by implementation: +On the same greeting script, `BenchmarkEngineComparison` shows Starlark is **~5.4x faster** than Risor for raw per-call overhead on small scripts, with **~8.6x less memory** and **~3.5x fewer allocations**: + +| Engine | ns/op | B/op | allocs/op | +|-----------|--------:|-------:|----------:| +| Risor | 22,121 | 67,382 | 251 | +| Starlark | 4,119 | 7,806 | 71 | + +Caveats: the benchmark script is trivial (two variables + a map literal), so this measures per-call VM setup more than real work. Interpret by use case, not by the numbers alone: -- **Risor**: Generally fastest for general-purpose scripting with good Go interoperability -- **Starlark**: Optimized for configuration processing with Python-like syntax -- **Extism/WASM**: Best for security isolation with pre-compiled modules +- **Starlark** — lowest per-call overhead; deterministic, Python-like, designed for configuration. Limited language capabilities (no loops as iteration, no stdlib I/O). Best when you have many fast, simple evaluations. +- **Risor** — richer stdlib (`math`, `rand`, `regexp` in v2), TypeScript-aligned syntax (arrow functions, `try/catch`, optional chaining), friendlier for general scripting and data munging. Pays a higher fixed per-call cost. +- **Extism/WASM** — language-agnostic isolation via pre-compiled modules. Choose when you need to run untrusted code, support multiple languages, or get true sandbox isolation. ## Running Benchmarks diff --git a/engines/benchmarks/benchmark_test.go b/engines/benchmarks/benchmark_test.go index c0e8475..ca3541a 100644 --- a/engines/benchmarks/benchmark_test.go +++ b/engines/benchmarks/benchmark_test.go @@ -43,9 +43,9 @@ var quietHandler = slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: s func BenchmarkEvaluationPatterns(b *testing.B) { // Simple script for benchmarking scriptContent := ` - name := ctx["name"] - message := "Hello, " + name + "!" - + let name = ctx["name"] + let message = "Hello, " + name + "!" + { "greeting": message, "length": len(message) @@ -110,9 +110,9 @@ func BenchmarkEvaluationPatterns(b *testing.B) { func BenchmarkDataProviders(b *testing.B) { // Simple script for benchmarking scriptContent := ` - name := ctx["name"] - message := "Hello, " + name + "!" - + let name = ctx["name"] + let message = "Hello, " + name + "!" + { "greeting": message, "length": len(message) @@ -168,8 +168,8 @@ func BenchmarkDataProviders(b *testing.B) { // For CompositeProvider use case, we can prepare the context separately // We access the name directly from the context compositeScript := ` - name := ctx["name"] - message := "Hello, " + name + "!" + let name = ctx["name"] + let message = "Hello, " + name + "!" { "greeting": message, "length": len(message) @@ -214,9 +214,9 @@ func BenchmarkEngineComparison(b *testing.B) { // Risor script risorScript := ` - name := ctx["name"] - message := "Hello, " + name + "!" - + let name = ctx["name"] + let message = "Hello, " + name + "!" + { "greeting": message, "length": len(message) diff --git a/engines/benchmarks/results/benchmark_2026-04-11_15-27-36.json b/engines/benchmarks/results/benchmark_2026-04-11_15-27-36.json new file mode 100644 index 0000000..df24738 --- /dev/null +++ b/engines/benchmarks/results/benchmark_2026-04-11_15-27-36.json @@ -0,0 +1,45 @@ +{"Time":"2026-04-11T15:27:47.928167-04:00","Action":"start","Package":"github.com/robbyt/go-polyscript/engines/benchmarks"} +{"Time":"2026-04-11T15:27:47.93643-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Output":"goos: darwin\n"} +{"Time":"2026-04-11T15:27:47.93651-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Output":"goarch: arm64\n"} +{"Time":"2026-04-11T15:27:47.936515-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Output":"pkg: github.com/robbyt/go-polyscript/engines/benchmarks\n"} +{"Time":"2026-04-11T15:27:47.936522-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Output":"cpu: Apple M5 Max\n"} +{"Time":"2026-04-11T15:27:47.936529-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns"} +{"Time":"2026-04-11T15:27:47.936533-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns","Output":"=== RUN BenchmarkEvaluationPatterns\n"} +{"Time":"2026-04-11T15:27:47.936537-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns","Output":"BenchmarkEvaluationPatterns\n"} +{"Time":"2026-04-11T15:27:47.936661-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns/SingleExecution"} +{"Time":"2026-04-11T15:27:47.936678-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns/SingleExecution","Output":"=== RUN BenchmarkEvaluationPatterns/SingleExecution\n"} +{"Time":"2026-04-11T15:27:47.936684-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns/SingleExecution","Output":"BenchmarkEvaluationPatterns/SingleExecution\n"} +{"Time":"2026-04-11T15:27:49.62027-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns/SingleExecution","Output":"BenchmarkEvaluationPatterns/SingleExecution-18 \t 24763\t 47300 ns/op\t 120751 B/op\t 713 allocs/op\n"} +{"Time":"2026-04-11T15:27:49.620349-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns/CompileOnceRunMany"} +{"Time":"2026-04-11T15:27:49.620367-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns/CompileOnceRunMany","Output":"=== RUN BenchmarkEvaluationPatterns/CompileOnceRunMany\n"} +{"Time":"2026-04-11T15:27:49.620371-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns/CompileOnceRunMany","Output":"BenchmarkEvaluationPatterns/CompileOnceRunMany\n"} +{"Time":"2026-04-11T15:27:51.18472-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEvaluationPatterns/CompileOnceRunMany","Output":"BenchmarkEvaluationPatterns/CompileOnceRunMany-18 \t 59836\t 21618 ns/op\t 67682 B/op\t 252 allocs/op\n"} +{"Time":"2026-04-11T15:27:51.184786-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders"} +{"Time":"2026-04-11T15:27:51.1848-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders","Output":"=== RUN BenchmarkDataProviders\n"} +{"Time":"2026-04-11T15:27:51.184817-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders","Output":"BenchmarkDataProviders\n"} +{"Time":"2026-04-11T15:27:51.25881-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/StaticProvider"} +{"Time":"2026-04-11T15:27:51.258855-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/StaticProvider","Output":"=== RUN BenchmarkDataProviders/StaticProvider\n"} +{"Time":"2026-04-11T15:27:51.258882-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/StaticProvider","Output":"BenchmarkDataProviders/StaticProvider\n"} +{"Time":"2026-04-11T15:27:52.772376-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/StaticProvider","Output":"BenchmarkDataProviders/StaticProvider-18 \t 57315\t 21799 ns/op\t 67382 B/op\t 251 allocs/op\n"} +{"Time":"2026-04-11T15:27:52.772435-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/ContextProvider"} +{"Time":"2026-04-11T15:27:52.77244-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/ContextProvider","Output":"=== RUN BenchmarkDataProviders/ContextProvider\n"} +{"Time":"2026-04-11T15:27:52.772445-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/ContextProvider","Output":"BenchmarkDataProviders/ContextProvider\n"} +{"Time":"2026-04-11T15:27:54.349074-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/ContextProvider","Output":"BenchmarkDataProviders/ContextProvider-18 \t 59300\t 21286 ns/op\t 66278 B/op\t 243 allocs/op\n"} +{"Time":"2026-04-11T15:27:54.349117-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/CompositeProvider"} +{"Time":"2026-04-11T15:27:54.349122-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/CompositeProvider","Output":"=== RUN BenchmarkDataProviders/CompositeProvider\n"} +{"Time":"2026-04-11T15:27:54.349126-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/CompositeProvider","Output":"BenchmarkDataProviders/CompositeProvider\n"} +{"Time":"2026-04-11T15:27:55.948097-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkDataProviders/CompositeProvider","Output":"BenchmarkDataProviders/CompositeProvider-18 \t 58492\t 21914 ns/op\t 67382 B/op\t 253 allocs/op\n"} +{"Time":"2026-04-11T15:27:55.948161-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison"} +{"Time":"2026-04-11T15:27:55.948169-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison","Output":"=== RUN BenchmarkEngineComparison\n"} +{"Time":"2026-04-11T15:27:55.948198-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison","Output":"BenchmarkEngineComparison\n"} +{"Time":"2026-04-11T15:27:56.028991-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison/RisorEngine"} +{"Time":"2026-04-11T15:27:56.029037-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison/RisorEngine","Output":"=== RUN BenchmarkEngineComparison/RisorEngine\n"} +{"Time":"2026-04-11T15:27:56.029043-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison/RisorEngine","Output":"BenchmarkEngineComparison/RisorEngine\n"} +{"Time":"2026-04-11T15:27:57.521117-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison/RisorEngine","Output":"BenchmarkEngineComparison/RisorEngine-18 \t 55202\t 22296 ns/op\t 67382 B/op\t 251 allocs/op\n"} +{"Time":"2026-04-11T15:27:57.521171-04:00","Action":"run","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison/StarlarkEngine"} +{"Time":"2026-04-11T15:27:57.521237-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison/StarlarkEngine","Output":"=== RUN BenchmarkEngineComparison/StarlarkEngine\n"} +{"Time":"2026-04-11T15:27:57.521247-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison/StarlarkEngine","Output":"BenchmarkEngineComparison/StarlarkEngine\n"} +{"Time":"2026-04-11T15:27:59.194638-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Test":"BenchmarkEngineComparison/StarlarkEngine","Output":"BenchmarkEngineComparison/StarlarkEngine-18 \t 351516\t 4261 ns/op\t 7808 B/op\t 71 allocs/op\n"} +{"Time":"2026-04-11T15:27:59.194849-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Output":"PASS\n"} +{"Time":"2026-04-11T15:27:59.225755-04:00","Action":"output","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Output":"ok \tgithub.com/robbyt/go-polyscript/engines/benchmarks\t11.297s\n"} +{"Time":"2026-04-11T15:27:59.225792-04:00","Action":"pass","Package":"github.com/robbyt/go-polyscript/engines/benchmarks","Elapsed":11.298} diff --git a/engines/benchmarks/results/benchmark_2026-04-11_15-27-36.txt b/engines/benchmarks/results/benchmark_2026-04-11_15-27-36.txt new file mode 100644 index 0000000..8658967 --- /dev/null +++ b/engines/benchmarks/results/benchmark_2026-04-11_15-27-36.txt @@ -0,0 +1,13 @@ +goos: darwin +goarch: arm64 +pkg: github.com/robbyt/go-polyscript/engines/benchmarks +cpu: Apple M5 Max +BenchmarkEvaluationPatterns/SingleExecution-18 24620 48323 ns/op 120751 B/op 713 allocs/op +BenchmarkEvaluationPatterns/CompileOnceRunMany-18 59073 21435 ns/op 67682 B/op 252 allocs/op +BenchmarkDataProviders/StaticProvider-18 56025 22354 ns/op 67382 B/op 251 allocs/op +BenchmarkDataProviders/ContextProvider-18 59650 21604 ns/op 66283 B/op 243 allocs/op +BenchmarkDataProviders/CompositeProvider-18 58615 21966 ns/op 67382 B/op 253 allocs/op +BenchmarkEngineComparison/RisorEngine-18 55406 22121 ns/op 67382 B/op 251 allocs/op +BenchmarkEngineComparison/StarlarkEngine-18 338401 4119 ns/op 7806 B/op 71 allocs/op +PASS +ok github.com/robbyt/go-polyscript/engines/benchmarks 11.202s diff --git a/engines/benchmarks/results/latest.txt b/engines/benchmarks/results/latest.txt index 4eb2274..d6200e3 120000 --- a/engines/benchmarks/results/latest.txt +++ b/engines/benchmarks/results/latest.txt @@ -1 +1 @@ -benchmark_2025-11-08_16-24-33.txt \ No newline at end of file +benchmark_2026-04-11_15-27-36.txt \ No newline at end of file diff --git a/engines/integration_test.go b/engines/integration_test.go index ca437c2..11fd47a 100644 --- a/engines/integration_test.go +++ b/engines/integration_test.go @@ -51,11 +51,11 @@ func TestEngineDataHandlingIntegration(t *testing.T) { // Risor script that accesses data via ctx variable risorScript := ` // Access data through ctx variable -name := ctx["name"] -version := ctx["version"] -debug := ctx["config"]["debug"] -timeout := ctx["config"]["timeout"] -tags := ctx["tags"] +let name = ctx["name"] +let version = ctx["version"] +let debug = ctx["config"]["debug"] +let timeout = ctx["config"]["timeout"] +let tags = ctx["tags"] // Create result { @@ -209,9 +209,9 @@ func TestEngineDataHandlingWithDynamicData(t *testing.T) { t.Run("risor_dynamic_data", func(t *testing.T) { risorScript := ` // Access both static and dynamic data -app_name := ctx["app_name"] -user_id := ctx["user_id"] -action := ctx["action"] +let app_name = ctx["app_name"] +let user_id = ctx["user_id"] +let action = ctx["action"] { "result": "processed", @@ -349,8 +349,8 @@ func TestEngineDataStructureDocumentation(t *testing.T) { t.Run("risor_documentation_example", func(t *testing.T) { script := ` -name := ctx["name"] -debug := ctx["config"]["debug"] +let name = ctx["name"] +let debug = ctx["config"]["debug"] { "name": name, @@ -439,8 +439,8 @@ func TestDataProviderPatterns(t *testing.T) { "risor_context_provider", func(t *testing.T) { script := ` -config := ctx["config"] -user_data := ctx["user_data"] +let config = ctx["config"] +let user_data = ctx["user_data"] { "config": config, @@ -596,9 +596,9 @@ _ = result t.Run("risor_static_provider", func(t *testing.T) { script := ` -app_name := ctx["config"]["app_name"] -version := ctx["config"]["version"] -max_retries := ctx["constants"]["max_retries"] +let app_name = ctx["config"]["app_name"] +let version = ctx["config"]["version"] +let max_retries = ctx["constants"]["max_retries"] { "app_name": app_name, @@ -713,10 +713,10 @@ _ = result t.Run( //nolint:dupl // Each engine test demonstrates different syntax and behavior "risor_composite_provider", func(t *testing.T) { script := ` -app_name := ctx["config"]["app_name"] -version := ctx["config"]["version"] -user_id := ctx["user_id"] -request_id := ctx["request_id"] +let app_name = ctx["config"]["app_name"] +let version = ctx["config"]["version"] +let user_id = ctx["user_id"] +let request_id = ctx["request_id"] { "app_name": app_name, @@ -875,10 +875,10 @@ func TestHttpRequestDataAccess(t *testing.T) { "risor_http_request", func(t *testing.T) { script := ` // HTTP request data as documented in platform/data/README.md -request_method := ctx["request"]["Method"] -url_path := ctx["request"]["URL_Path"] -request_body := ctx["request"]["Body"] -content_type := ctx["request"]["Headers"]["Content-Type"][0] +let request_method = ctx["request"]["Method"] +let url_path = ctx["request"]["URL_Path"] +let request_body = ctx["request"]["Body"] +let content_type = ctx["request"]["Headers"]["Content-Type"][0] { "method": request_method, @@ -1005,9 +1005,9 @@ _ = result t.Run("risor_explicit_keys", func(t *testing.T) { script := ` -request_data := ctx["request"] -user_data := ctx["user"] -config_data := ctx["config"] +let request_data = ctx["request"] +let user_data = ctx["user"] +let config_data = ctx["config"] { "has_request": request_data != nil, diff --git a/engines/risor/adapters/interfaces.go b/engines/risor/adapters/interfaces.go index c93b087..be2649e 100644 --- a/engines/risor/adapters/interfaces.go +++ b/engines/risor/adapters/interfaces.go @@ -1,7 +1,7 @@ package adapters -import risorCompiler "github.com/risor-io/risor/compiler" +import "github.com/deepnoodle-ai/risor/v2/pkg/bytecode" type RisorExecutable struct { - GetRisorByteCode func() *risorCompiler.Code + GetRisorByteCode func() *bytecode.Code } diff --git a/engines/risor/compiler/compiler.go b/engines/risor/compiler/compiler.go index 6bbc9f0..fafead4 100644 --- a/engines/risor/compiler/compiler.go +++ b/engines/risor/compiler/compiler.go @@ -83,7 +83,7 @@ func (c *Compiler) compile(scriptBodyBytes []byte) (*executable, error) { isCommentOnly := true for line := range strings.SplitSeq(trimmedScript, "\n") { if trimmedLine := strings.TrimSpace(line); trimmedLine != "" && - !strings.HasPrefix(trimmedLine, "#") { + !strings.HasPrefix(trimmedLine, "//") { // Found a non-comment line, so we can stop checking lines because there's some real code here! isCommentOnly = false break diff --git a/engines/risor/compiler/compiler_test.go b/engines/risor/compiler/compiler_test.go index 5aa6fc0..c253e95 100644 --- a/engines/risor/compiler/compiler_test.go +++ b/engines/risor/compiler/compiler_test.go @@ -89,23 +89,23 @@ func TestCompiler_Compile(t *testing.T) { }{ { name: "valid script", - script: `print("Hello, World!")`, + script: `"Hello, World!"`, globals: []string{"request"}, }, { name: "with multiple globals", - script: `print(request, response)`, + script: `[request, response]`, globals: []string{"request", "response"}, }, { name: "complex valid script with global override", script: ` request = true -func main() { - if request { - print("Yes") +function main() { + if (request) { + "Yes" } else { - print("No") + "No" } } main() @@ -115,11 +115,11 @@ main() { name: "complex valid script with condition", script: ` -func main() { - if condition { - print("Yes") +function main() { + if (condition) { + "Yes" } else { - print("No") + "No" } } main() @@ -176,8 +176,8 @@ main() err error }{ { - name: "syntax error - missing closing parenthesis", - script: `print("Hello, World!"`, + name: "syntax error - unterminated string", + script: `"Hello, World!`, globals: []string{"request"}, err: ErrValidationFailed, }, @@ -189,13 +189,13 @@ main() }, { name: "undefined global", - script: `print(undefined_global)`, + script: `undefined_global`, globals: []string{"request"}, err: ErrValidationFailed, }, { name: "script using undefined global", - script: `print(undefined)`, + script: `undefined`, globals: []string{"request"}, err: ErrValidationFailed, }, @@ -270,7 +270,7 @@ main() require.NotNil(t, comp, "Expected compiler to be non-nil") // Create a reader that will return an error on close - reader := newMockScriptReaderCloser(`print("Hello, World!")`) + reader := newMockScriptReaderCloser(`"Hello, World!"`) reader.On("Close").Return(errors.New("test error")).Once() execContent, err := comp.Compile(reader) @@ -294,7 +294,7 @@ main() require.NotNil(t, comp, "Expected compiler to be non-nil") // Here we test that we can directly call the compile method with a byteslice - scriptBytes := []byte(`print("Hello, World!")`) + scriptBytes := []byte(`"Hello, World!"`) executable, err := comp.compile(scriptBytes) require.NoError(t, err, "Did not expect an error but got one") require.NotNil(t, executable, "Expected execContent to be non-nil") @@ -343,7 +343,7 @@ func TestCompilerOptions(t *testing.T) { require.NotNil(t, comp) // Test with a script using the globals - script := `print(request, response)` + script := `[request, response]` reader := io.ReadCloser(newMockScriptReaderCloser(script)) if mockReader, ok := reader.(*mockScriptReaderCloser); ok { mockReader.On("Close").Return(nil) @@ -361,7 +361,7 @@ func TestCompilerOptions(t *testing.T) { require.NotNil(t, comp) // Simple script that doesn't require globals - script := `print("Hello")` + script := `"Hello"` reader := io.ReadCloser(newMockScriptReaderCloser(script)) if mockReader, ok := reader.(*mockScriptReaderCloser); ok { mockReader.On("Close").Return(nil) @@ -407,7 +407,7 @@ func TestCompileWithBytecode(t *testing.T) { require.NotNil(t, comp, "Expected compiler to be non-nil") // Here we test that we can directly call the compile method with a byteslice - scriptBytes := []byte(`print("Hello, World!")`) + scriptBytes := []byte(`"Hello, World!"`) executable, err := comp.compile(scriptBytes) require.NoError(t, err, "Did not expect an error but got one") require.NotNil(t, executable, "Expected execContent to be non-nil") @@ -452,7 +452,7 @@ func TestCompileCloseError(t *testing.T) { require.NotNil(t, comp, "Expected compiler to be non-nil") // Create a reader that will return an error on close - reader := newMockScriptReaderCloser(`print("Hello, World!")`) + reader := newMockScriptReaderCloser(`"Hello, World!"`) reader.On("Close").Return(errors.New("test error")).Once() execContent, err := comp.Compile(reader) diff --git a/engines/risor/compiler/executable.go b/engines/risor/compiler/executable.go index b66824e..9b081ec 100644 --- a/engines/risor/compiler/executable.go +++ b/engines/risor/compiler/executable.go @@ -1,16 +1,16 @@ package compiler import ( - risorCompiler "github.com/risor-io/risor/compiler" + "github.com/deepnoodle-ai/risor/v2/pkg/bytecode" machineTypes "github.com/robbyt/go-polyscript/engines/types" ) type executable struct { scriptBodyBytes []byte - ByteCode *risorCompiler.Code + ByteCode *bytecode.Code } -func newExecutable(scriptBodyBytes []byte, byteCode *risorCompiler.Code) *executable { +func newExecutable(scriptBodyBytes []byte, byteCode *bytecode.Code) *executable { if len(scriptBodyBytes) == 0 || byteCode == nil { return nil } @@ -29,7 +29,7 @@ func (e *executable) GetByteCode() any { return e.ByteCode } -func (e *executable) GetRisorByteCode() *risorCompiler.Code { +func (e *executable) GetRisorByteCode() *bytecode.Code { return e.ByteCode } diff --git a/engines/risor/compiler/executable_test.go b/engines/risor/compiler/executable_test.go index ba8ad78..d520ba0 100644 --- a/engines/risor/compiler/executable_test.go +++ b/engines/risor/compiler/executable_test.go @@ -3,7 +3,7 @@ package compiler import ( "testing" - risorCompiler "github.com/risor-io/risor/compiler" + "github.com/deepnoodle-ai/risor/v2/pkg/bytecode" machineTypes "github.com/robbyt/go-polyscript/engines/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,25 +16,25 @@ func TestExecutable(t *testing.T) { // Test creation scenarios t.Run("Creation", func(t *testing.T) { t.Run("valid creation", func(t *testing.T) { - content := "print('Hello, World!')" - bytecode := &risorCompiler.Code{} + content := "'Hello, World!'" + bc := bytecode.NewCode(bytecode.CodeParams{}) - exe := newExecutable([]byte(content), bytecode) + exe := newExecutable([]byte(content), bc) require.NotNil(t, exe) assert.Equal(t, content, exe.GetSource()) - assert.Equal(t, bytecode, exe.GetByteCode()) - assert.Equal(t, bytecode, exe.GetRisorByteCode()) + assert.Equal(t, bc, exe.GetByteCode()) + assert.Equal(t, bc, exe.GetRisorByteCode()) assert.Equal(t, machineTypes.Risor, exe.GetMachineType()) }) t.Run("nil content", func(t *testing.T) { - bytecode := &risorCompiler.Code{} - exe := newExecutable(nil, bytecode) + bc := bytecode.NewCode(bytecode.CodeParams{}) + exe := newExecutable(nil, bc) assert.Nil(t, exe) }) t.Run("nil bytecode", func(t *testing.T) { - content := "print('test')" + content := "'test'" exe := newExecutable([]byte(content), nil) assert.Nil(t, exe) }) @@ -47,9 +47,9 @@ func TestExecutable(t *testing.T) { // Test getters t.Run("Getters", func(t *testing.T) { - content := "print('Hello, World!')" - bytecode := &risorCompiler.Code{} - executable := newExecutable([]byte(content), bytecode) + content := "'Hello, World!'" + bc := bytecode.NewCode(bytecode.CodeParams{}) + executable := newExecutable([]byte(content), bc) require.NotNil(t, executable) t.Run("GetSource", func(t *testing.T) { @@ -59,16 +59,16 @@ func TestExecutable(t *testing.T) { t.Run("GetByteCode", func(t *testing.T) { code := executable.GetByteCode() - assert.Equal(t, bytecode, code) + assert.Equal(t, bc, code) // Test type assertion - _, ok := code.(*risorCompiler.Code) + _, ok := code.(*bytecode.Code) assert.True(t, ok) }) t.Run("GetRisorByteCode", func(t *testing.T) { code := executable.GetRisorByteCode() - assert.Equal(t, bytecode, code) + assert.Equal(t, bc, code) }) t.Run("GetMachineType", func(t *testing.T) { diff --git a/engines/risor/compiler/internal/compile/compile.go b/engines/risor/compiler/internal/compile/compile.go index 2c60948..622b6d3 100644 --- a/engines/risor/compiler/internal/compile/compile.go +++ b/engines/risor/compiler/internal/compile/compile.go @@ -2,36 +2,30 @@ package compile import ( "context" - "errors" "fmt" + "maps" + "slices" - risorLib "github.com/risor-io/risor" - risorCompiler "github.com/risor-io/risor/compiler" - risorErrors "github.com/risor-io/risor/errz" - risorParser "github.com/risor-io/risor/parser" + risor "github.com/deepnoodle-ai/risor/v2" + "github.com/deepnoodle-ai/risor/v2/pkg/bytecode" + risorCompiler "github.com/deepnoodle-ai/risor/v2/pkg/compiler" + risorParser "github.com/deepnoodle-ai/risor/v2/pkg/parser" ) // Compile parses and compiles the script content into bytecode -func Compile(scriptContent *string, options ...risorCompiler.Option) (*risorCompiler.Code, error) { +func Compile(scriptContent *string, cfg *risorCompiler.Config) (*bytecode.Code, error) { if scriptContent == nil { return nil, ErrContentNil } - ast, err := risorParser.Parse(context.Background(), *scriptContent) + ast, err := risorParser.Parse(context.Background(), *scriptContent, nil) if err != nil { - // Create a better-looking error output when there's a syntax error - errMsg := err.Error() - var friendlyErr risorErrors.FriendlyError - if errors.As(err, &friendlyErr) { - errMsg = friendlyErr.FriendlyErrorMessage() - } - return nil, fmt.Errorf("%w: %s", ErrCompileFailed, errMsg) + return nil, fmt.Errorf("%w: %w", ErrCompileFailed, err) } - // Compile the AST to bytecode - bc, err := risorCompiler.Compile(ast, options...) + bc, err := risorCompiler.Compile(ast, cfg) if err != nil { - return nil, err + return nil, fmt.Errorf("%w: %w", ErrCompileFailed, err) } return bc, nil @@ -41,14 +35,20 @@ func Compile(scriptContent *string, options ...risorCompiler.Option) (*risorComp // which are needed when parsing a script that will eventually have globals injected at eval time. // For example, if a script uses a request or response object, it needs to be compiled with those // global names, even though they won't be available until eval time. -func CompileWithGlobals(scriptContent *string, globals []string) (*risorCompiler.Code, error) { - // Retrieve default global names, and append the custom globals - cfg := risorLib.NewConfig() - globalNames := append(cfg.GlobalNames(), globals...) +func CompileWithGlobals(scriptContent *string, globals []string) (*bytecode.Code, error) { + // Start with the standard builtins env and add custom globals + env := risor.Builtins() + for _, g := range globals { + if _, exists := env[g]; !exists { + env[g] = nil + } + } + + globalNames := slices.Sorted(maps.Keys(env)) - options := []risorCompiler.Option{ - risorCompiler.WithGlobalNames(globalNames), + cfg := &risorCompiler.Config{ + GlobalNames: globalNames, } - return Compile(scriptContent, options...) + return Compile(scriptContent, cfg) } diff --git a/engines/risor/compiler/internal/compile/compile_test.go b/engines/risor/compiler/internal/compile/compile_test.go index 352d7d3..c1debba 100644 --- a/engines/risor/compiler/internal/compile/compile_test.go +++ b/engines/risor/compiler/internal/compile/compile_test.go @@ -10,7 +10,7 @@ import ( func TestCompileSuccess(t *testing.T) { scriptContent := `true` - code, err := Compile(&scriptContent) + code, err := Compile(&scriptContent, nil) require.NoError(t, err) require.NotNil(t, code) } @@ -18,10 +18,10 @@ func TestCompileSuccess(t *testing.T) { // TestCompileSyntaxError tests the compilation failure due to syntax errors func TestCompileSyntaxError(t *testing.T) { scriptContent := ` - print("Hello, World! + "Hello, World! ` - code, err := Compile(&scriptContent) + code, err := Compile(&scriptContent, nil) require.Error(t, err) require.Nil(t, code) require.ErrorIs(t, err, ErrCompileFailed) @@ -30,7 +30,7 @@ func TestCompileSyntaxError(t *testing.T) { // TestCompileWithGlobals tests the compilation with custom global names func TestCompileWithGlobals(t *testing.T) { scriptContent := ` - print(request) + request ` globals := []string{"request"} @@ -41,7 +41,7 @@ func TestCompileWithGlobals(t *testing.T) { // TestCompileNilContent tests the handling of nil script content func TestCompileNilContent(t *testing.T) { - code, err := Compile(nil) + code, err := Compile(nil, nil) require.Error(t, err) require.Nil(t, code) require.ErrorIs(t, err, ErrContentNil) @@ -59,7 +59,7 @@ func TestCompileWithGlobalsNilContent(t *testing.T) { // TestCompileWithGlobalsSyntaxError tests the compilation failure due to syntax errors with globals func TestCompileWithGlobalsSyntaxError(t *testing.T) { scriptContent := ` - print(request + [request ` globals := []string{"request"} diff --git a/engines/risor/evaluator/evaluator.go b/engines/risor/evaluator/evaluator.go index 976c156..753bc40 100644 --- a/engines/risor/evaluator/evaluator.go +++ b/engines/risor/evaluator/evaluator.go @@ -6,8 +6,9 @@ import ( "log/slog" "time" - risorLib "github.com/risor-io/risor" - risorCompiler "github.com/risor-io/risor/compiler" + risor "github.com/deepnoodle-ai/risor/v2" + "github.com/deepnoodle-ai/risor/v2/pkg/bytecode" + risorObject "github.com/deepnoodle-ai/risor/v2/pkg/object" "github.com/robbyt/go-polyscript/engines/risor/internal" "github.com/robbyt/go-polyscript/internal/helpers" "github.com/robbyt/go-polyscript/platform" @@ -16,6 +17,10 @@ import ( "github.com/robbyt/go-polyscript/platform/script" ) +// typeRegistry is initialized once at package level to avoid a data race in +// risor v2's DefaultRegistry() lazy initialization. +var typeRegistry = risorObject.DefaultRegistry() + // Evaluator is an abstraction layer for evaluating bytecode on the Risor engine type Evaluator struct { // ctxKey is the variable name used to access input data inside the engine (ctx) @@ -72,20 +77,26 @@ func (be *Evaluator) loadInputData(ctx context.Context) (map[string]any, error) return inputData, nil } -// exec pulls the latest bytecode, and runs it with some input from options +// exec runs the bytecode with the provided environment map func (be *Evaluator) exec( ctx context.Context, - bytecode *risorCompiler.Code, - options ...risorLib.Option, + bc *bytecode.Code, + env map[string]any, ) (*execResult, error) { startTime := time.Now() - result, err := risorLib.EvalCode(ctx, bytecode, options...) + result, err := risor.Run(ctx, bc, risor.WithEnv(env), risor.WithRawResult(), risor.WithTypeRegistry(typeRegistry)) execTime := time.Since(startTime) if err != nil { return nil, fmt.Errorf("risor execution error: %w", err) } - return newEvalResult(be.logHandler, result, execTime, ""), nil + + obj, ok := result.(risorObject.Object) + if !ok { + return nil, fmt.Errorf("unexpected result type from risor: %T", result) + } + + return newEvalResult(be.logHandler, obj, execTime, ""), nil } // Eval evaluates the loaded bytecode and uses the provided EvalData to pass data in to the Risor engine execution @@ -100,8 +111,8 @@ func (be *Evaluator) Eval(ctx context.Context) (platform.EvaluatorResponse, erro } // Get the bytecode from the executable unit - bytecode := be.execUnit.GetContent().GetByteCode() - if bytecode == nil { + rawBytecode := be.execUnit.GetContent().GetByteCode() + if rawBytecode == nil { return nil, fmt.Errorf("bytecode is nil") } @@ -112,11 +123,11 @@ func (be *Evaluator) Eval(ctx context.Context) (platform.EvaluatorResponse, erro } logger = logger.With("exeID", exeID) - // 1. Type assert the bytecode into *risorCompiler.Code - risorByteCode, ok := bytecode.(*risorCompiler.Code) + // 1. Type assert the bytecode into *bytecode.Code + risorByteCode, ok := rawBytecode.(*bytecode.Code) if !ok { return nil, fmt.Errorf( - "unable to type assert bytecode into *risorCompiler.Code for ID: %s", + "unable to type assert bytecode into *bytecode.Code for ID: %s", exeID, ) } @@ -127,11 +138,11 @@ func (be *Evaluator) Eval(ctx context.Context) (platform.EvaluatorResponse, erro return nil, fmt.Errorf("failed to get input data: %w", err) } - // 3. Convert to Risor engine format - runtimeData := internal.ConvertToRisorOptions(be.ctxKey, rawInputData) + // 3. Build the Risor environment with builtins and input data + runtimeEnv := internal.BuildRisorEnv(be.ctxKey, rawInputData) // 4. Execute the program - result, err := be.exec(ctx, risorByteCode, runtimeData...) + result, err := be.exec(ctx, risorByteCode, runtimeEnv) if err != nil { return nil, fmt.Errorf("exec error: %w", err) } diff --git a/engines/risor/evaluator/evaluator_test.go b/engines/risor/evaluator/evaluator_test.go index debc7b3..259e629 100644 --- a/engines/risor/evaluator/evaluator_test.go +++ b/engines/risor/evaluator/evaluator_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - risorCompiler "github.com/risor-io/risor/compiler" + "github.com/deepnoodle-ai/risor/v2/pkg/bytecode" "github.com/robbyt/go-polyscript/engines/risor/compiler" "github.com/robbyt/go-polyscript/engines/types" "github.com/robbyt/go-polyscript/internal/helpers" @@ -116,19 +116,18 @@ func TestEvaluator_Evaluate(t *testing.T) { // Define a test script that handles HTTP requests testScript := ` - func handle(request) { - if request == nil { + let handle = function(request) { + if (request == nil) { return error("request is nil") } - if request["Method"] == "POST" { + if (request["Method"] == "POST") { return "post" } - if request["URL_Path"] == "/hello" { + if (request["URL_Path"] == "/hello") { return true } return false } - print(ctx) handle(ctx["request"]) ` @@ -257,7 +256,7 @@ func TestEvaluator_Evaluate(t *testing.T) { return &script.ExecutableUnit{ ID: "", Content: &MockContent{ - Content: &risorCompiler.Code{}, + Content: bytecode.NewCode(bytecode.CodeParams{}), }, } }, diff --git a/engines/risor/evaluator/response.go b/engines/risor/evaluator/response.go index 424da53..5c1503b 100644 --- a/engines/risor/evaluator/response.go +++ b/engines/risor/evaluator/response.go @@ -6,7 +6,7 @@ import ( "os" "time" - risorObject "github.com/risor-io/risor/object" + risorObject "github.com/deepnoodle-ai/risor/v2/pkg/object" "github.com/robbyt/go-polyscript/platform/data" ) diff --git a/engines/risor/evaluator/response_test.go b/engines/risor/evaluator/response_test.go index ea0bfcc..a81997c 100644 --- a/engines/risor/evaluator/response_test.go +++ b/engines/risor/evaluator/response_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - rObj "github.com/risor-io/risor/object" - "github.com/risor-io/risor/op" + rObj "github.com/deepnoodle-ai/risor/v2/pkg/object" + "github.com/deepnoodle-ai/risor/v2/pkg/op" "github.com/robbyt/go-polyscript/platform" "github.com/robbyt/go-polyscript/platform/data" "github.com/stretchr/testify/assert" @@ -35,24 +35,22 @@ func (m *RisorObjectMock) Interface() any { return args.Get(0) } -func (m *RisorObjectMock) Hash() (uint32, error) { - args := m.Called() - return args.Get(0).(uint32), args.Error(1) -} - func (m *RisorObjectMock) String() string { args := m.Called() return args.String(0) } -func (m *RisorObjectMock) Cost() int { - args := m.Called() - return args.Int(0) +func (m *RisorObjectMock) Equals(other rObj.Object) bool { + args := m.Called(other) + return args.Bool(0) } -func (m *RisorObjectMock) Equals(other rObj.Object) rObj.Object { - args := m.Called(other) - return args.Get(0).(rObj.Object) +func (m *RisorObjectMock) Attrs() []rObj.AttrSpec { + args := m.Called() + if v := args.Get(0); v != nil { + return v.([]rObj.AttrSpec) + } + return nil } func (m *RisorObjectMock) GetAttr(name string) (rObj.Object, bool) { @@ -70,14 +68,9 @@ func (m *RisorObjectMock) IsTruthy() bool { return args.Bool(0) } -func (m *RisorObjectMock) RunOperation(opType op.BinaryOpType, right rObj.Object) rObj.Object { +func (m *RisorObjectMock) RunOperation(opType op.BinaryOpType, right rObj.Object) (rObj.Object, error) { args := m.Called(opType, right) - return args.Get(0).(rObj.Object) -} - -func (m *RisorObjectMock) Compare(other rObj.Object) (int, error) { - args := m.Called(other) - return args.Int(0), args.Error(1) + return args.Get(0).(rObj.Object), args.Error(1) } // TestResponseMethods tests all the methods of the EvaluatorResponse interface diff --git a/engines/risor/internal/converters.go b/engines/risor/internal/converters.go index 8732f15..8a908ad 100644 --- a/engines/risor/internal/converters.go +++ b/engines/risor/internal/converters.go @@ -1,22 +1,22 @@ package internal import ( - risorLib "github.com/risor-io/risor" + risor "github.com/deepnoodle-ai/risor/v2" ) -// ConvertToRisorOptions converts a Go map into Risor engine options object. -// The input data will be wrapped in a single "ctx" object passed to the engine. +// BuildRisorEnv builds the full Risor environment map with standard builtins and input data. +// The input data is made available under the given ctxKey (typically "ctx"). // -// For example, if the inputData is {"foo": "bar", "baz": 123}, the output will be: +// For example, if the inputData is {"foo": "bar", "baz": 123}, the output will be a map +// containing all standard Risor builtins plus: // -// []risorLib.Option{ -// risorLib.WithGlobal("ctx", map[string]any{ +// "ctx": map[string]any{ // "foo": "bar", // "baz": 123, -// }), // } -func ConvertToRisorOptions(ctxKey string, inputData map[string]any) []risorLib.Option { - return []risorLib.Option{ - risorLib.WithGlobal(ctxKey, inputData), - } +func BuildRisorEnv(ctxKey string, inputData map[string]any) map[string]any { + // Builtins() returns a fresh map on each call, so mutating env is safe. + env := risor.Builtins() + env[ctxKey] = inputData + return env } diff --git a/engines/risor/internal/converters_test.go b/engines/risor/internal/converters_test.go index 89db70d..8b48229 100644 --- a/engines/risor/internal/converters_test.go +++ b/engines/risor/internal/converters_test.go @@ -4,19 +4,30 @@ import ( "testing" "github.com/robbyt/go-polyscript/platform/constants" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestConvertToRisorOptions tests the convertToRisorOptions method -func TestConvertToRisorOptions(t *testing.T) { +func TestBuildRisorEnv(t *testing.T) { t.Parallel() - // Test with empty data - options := ConvertToRisorOptions(constants.Ctx, map[string]any{}) - require.Len(t, options, 1) + t.Run("empty data includes builtins", func(t *testing.T) { + env := BuildRisorEnv(constants.Ctx, map[string]any{}) + require.NotNil(t, env) + // Should contain the ctx key + _, hasCtx := env[constants.Ctx] + assert.True(t, hasCtx, "env should contain the ctx key") + // Should contain standard builtins like len, type, etc. + _, hasLen := env["len"] + assert.True(t, hasLen, "env should contain standard builtins") + }) - // Test with actual data - testData := map[string]any{"foo": "bar"} - options = ConvertToRisorOptions(constants.Ctx, testData) - require.Len(t, options, 1) + t.Run("includes input data under ctx key", func(t *testing.T) { + testData := map[string]any{"foo": "bar"} + env := BuildRisorEnv(constants.Ctx, testData) + require.NotNil(t, env) + ctxData, ok := env[constants.Ctx].(map[string]any) + require.True(t, ok) + assert.Equal(t, "bar", ctxData["foo"]) + }) } diff --git a/engines/risor/new_test.go b/engines/risor/new_test.go index 1f079ac..8f37fe2 100644 --- a/engines/risor/new_test.go +++ b/engines/risor/new_test.go @@ -16,11 +16,8 @@ import ( ) const testRisorScript = ` -// Simple Risor script that uses built-in print function -print("Hello from Risor") - -// Define and call a simple function -func greet(name) { +// Define and call a greeting handler +function greet(name) { return "Hello, " + name } diff --git a/examples/README.md b/examples/README.md index f57b0bd..beeda40 100644 --- a/examples/README.md +++ b/examples/README.md @@ -48,7 +48,7 @@ This pattern separates data preparation from script evaluation: ### Risor -[Risor](https://github.com/risor-io/risor) is a modern embedded scripting language for Go with a focus on simplicity and performance. +[Risor](https://github.com/deepnoodle-ai/risor) is a modern embedded scripting language for Go with a focus on simplicity and performance. ### Extism (WebAssembly) diff --git a/examples/data-prep/risor/testdata/script.risor b/examples/data-prep/risor/testdata/script.risor index 2cbd298..c40e00f 100644 --- a/examples/data-prep/risor/testdata/script.risor +++ b/examples/data-prep/risor/testdata/script.risor @@ -1,42 +1,35 @@ -// Wrap everything in a function for Risor syntax -func process() { - - // Access static config data (set at compile time) - app_version := ctx.get("app_version", "unknown") - environment := ctx.get("environment", "unknown") - config := ctx.get("config", {}) - - // Get name from runtime data (added via AddDataToContext) - name := ctx.get("name", "Default") - - // Get timestamp from runtime data - timestamp := ctx.get("timestamp", "Unknown") - - // Process user data from runtime data - user_data := ctx.get("user_data", {}) - user_role := user_data.get("role", "guest") - user_id := user_data.get("id", "unknown") - - // Access request data if available - request := ctx.get("request", {}) - request_method := request.get("Method", "") - request_path := request.get("URL_Path", "") - - // Construct result dictionary - result := {} - result["greeting"] = "Hello, " + name + "!" - result["timestamp"] = timestamp - result["message"] = "Processed by " + user_role + " at " + timestamp - result["user_id"] = user_id - result["request_info"] = request_method + " " + request_path - result["app_info"] = { - "version": app_version, - "environment": environment, - "features": config.get("feature_flags", {}) - } - - return result +// Access static config data (set at compile time) +let app_version = ctx.get("app_version", "unknown") +let environment = ctx.get("environment", "unknown") +let config = ctx.get("config", {}) + +// Get name from runtime data (added via AddDataToContext) +let name = ctx.get("name", "Default") + +// Get timestamp from runtime data +let timestamp = ctx.get("timestamp", "Unknown") + +// Process user data from runtime data +let user_data = ctx.get("user_data", {}) +let user_role = user_data.get("role", "guest") +let user_id = user_data.get("id", "unknown") + +// Access request data if available +let request = ctx.get("request", {}) +let request_method = request.get("Method", "") +let request_path = request.get("URL_Path", "") + +// Construct result dictionary +let result = {} +result["greeting"] = "Hello, " + name + "!" +result["timestamp"] = timestamp +result["message"] = "Processed by " + user_role + " at " + timestamp +result["user_id"] = user_id +result["request_info"] = request_method + " " + request_path +result["app_info"] = { + "version": app_version, + "environment": environment, + "features": config.get("feature_flags", {}) } -// Call the function and return its result -process() \ No newline at end of file +result diff --git a/examples/multiple-instantiation/risor/testdata/script.risor b/examples/multiple-instantiation/risor/testdata/script.risor index fc56ff7..484360d 100644 --- a/examples/multiple-instantiation/risor/testdata/script.risor +++ b/examples/multiple-instantiation/risor/testdata/script.risor @@ -1,9 +1,9 @@ // Script has access to ctx variable passed from Go -name := ctx["name"] -message := "Hello, " + name + "!" +let name = ctx["name"] +let message = "Hello, " + name + "!" // Return a map with our result { "greeting": message, "length": len(message) -} \ No newline at end of file +} diff --git a/examples/simple/README.md b/examples/simple/README.md index 81b9fac..faf7af5 100644 --- a/examples/simple/README.md +++ b/examples/simple/README.md @@ -30,7 +30,7 @@ These examples follow a consistent pattern: In all examples, scripts access data using a `ctx` global variable: - Starlark: `name = ctx["name"]` -- Risor: `name := ctx["name"]` +- Risor: `let name = ctx["name"]` - Extism: Input data is passed to the WASM module ## Running the Examples diff --git a/examples/simple/risor/testdata/script.risor b/examples/simple/risor/testdata/script.risor index ba3c094..0f763b9 100644 --- a/examples/simple/risor/testdata/script.risor +++ b/examples/simple/risor/testdata/script.risor @@ -1,9 +1,9 @@ // Script has access to ctx variable passed from Go -name := ctx["name"] -message := "Hello, " + name + "!" +let name = ctx["name"] +let message = "Hello, " + name + "!" // Return a map with our result { "greeting": message, "length": len(message) -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index 89c6be4..487e393 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/robbyt/go-polyscript go 1.26.2 require ( + github.com/deepnoodle-ai/risor/v2 v2.1.0 github.com/extism/go-sdk v1.7.1 - github.com/risor-io/risor v1.8.1 github.com/stretchr/testify v1.11.1 github.com/tetratelabs/wazero v1.11.0 go.starlark.net v0.0.0-20260326113308-fadfc96def35 @@ -12,10 +12,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deepnoodle-ai/wonton v0.0.26 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5 // indirect - github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect diff --git a/go.sum b/go.sum index 73ac6a3..51ac260 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,9 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deepnoodle-ai/risor/v2 v2.1.0 h1:2MasWe0uJUNIaKvmd0ru1a64eXGdGakV3KlrxPNUH9g= +github.com/deepnoodle-ai/risor/v2 v2.1.0/go.mod h1:XwfyjmojSwk5HQkWsNhrkxu6MqpsXG1XGVNXyQ+c3Zo= +github.com/deepnoodle-ai/wonton v0.0.26 h1:x8DBfbUbDJlouTgJBNopb5s05tc8QxJQmtBemtLrjcE= +github.com/deepnoodle-ai/wonton v0.0.26/go.mod h1:oyogeHwAHPrVxZ7jtik55Jnj6CwC1jkF+PfHpCRlUGA= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= @@ -11,16 +14,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5 h1:QCtizt3VTaANvnsd8TtD/eonx7JLIVdEKW1//ZNPZ9A= github.com/ianlancetaylor/demangle v0.0.0-20250628045327-2d64ad6b7ec5/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/risor-io/risor v1.8.1 h1:FaycOBo56LudgozpU3FkSBxBgzQJs4GJ4lAno/M2CFo= -github.com/risor-io/risor v1.8.1/go.mod h1:OuP9WH8h3dzvK7NDfBTA+k095dyTaQxWi/qPTCx3W0g= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -37,8 +32,7 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/platform/evaluator_test.go b/platform/evaluator_test.go index 6ba2cb9..5e8544f 100644 --- a/platform/evaluator_test.go +++ b/platform/evaluator_test.go @@ -117,8 +117,8 @@ func TestEvalDataPreparerInterface(t *testing.T) { // The key name may be different in the new implementation scriptData := map[string]any{"greeting": "Hello, World!"} evaluator, err := polyscript.FromRisorStringWithData(` -method := ctx["request"]["Method"] -greeting := ctx["greeting"] // With new implementation, keys are at top level +let method = ctx["request"]["Method"] +let greeting = ctx["greeting"] method + " " + greeting `, scriptData, diff --git a/platform/script/loader/inference_test.go b/platform/script/loader/inference_test.go index c4c04d3..e703e23 100644 --- a/platform/script/loader/inference_test.go +++ b/platform/script/loader/inference_test.go @@ -539,9 +539,9 @@ function test() { return 42; }`, }, { name: "risor script with data access patterns", - input: `func process() { - service_name := ctx.get("service_name", "unknown") - version := ctx.get("version", "1.0.0") + input: `function process() { + let service_name = ctx.get("service_name", "unknown") + let version = ctx.get("version", "1.0.0") return {"message": "Hello from Risor!", "version": version} } process()`, diff --git a/polyscript_test.go b/polyscript_test.go index c382b54..9d13d0d 100644 --- a/polyscript_test.go +++ b/polyscript_test.go @@ -105,7 +105,7 @@ func TestMachineEvaluators(t *testing.T) { }, { name: "FromRisorString", - content: `print("Hello, World!")`, + content: `"Hello, World!"`, machineType: types.Risor, creator: polyscript.FromRisorString, }, @@ -141,7 +141,7 @@ func TestFromStringLoaders(t *testing.T) { }, { name: "FromRisorString - Valid", - content: `print("Hello, World!")`, + content: `"Hello, World!"`, creator: polyscript.FromRisorString, logHandler: nil, expectError: false, @@ -285,7 +285,7 @@ func TestEvalHelpers(t *testing.T) { t.Run("PrepareAndEval", func(t *testing.T) { // Create a simple Risor evaluator script := ` - name := ctx["name"] + let name = ctx["name"] { "message": "Hello, " + name + "!", "length": len(name) @@ -512,12 +512,12 @@ _ = result` // Test script risorScript := ` // Access static data - version := ctx["app_version"] - timeout := ctx["config"]["timeout"] - + let version = ctx["app_version"] + let timeout = ctx["config"]["timeout"] + // Access dynamic data - name := ctx["name"] - + let name = ctx["name"] + { "message": "Hello, " + name + " (v" + version + ")", "timeout": timeout diff --git a/readme_test.go b/readme_test.go index 5c283c0..a66a070 100644 --- a/readme_test.go +++ b/readme_test.go @@ -18,14 +18,14 @@ func TestReadmeQuickStart(t *testing.T) { script := ` // The ctx object from the Go inputData map - name := ctx.get("name") + let name = ctx.get("name") - p := "." - if ctx.get("excited") { + let p = "." + if (ctx.get("excited")) { p = "!" } - message := "Hello, " + name + p + let message = "Hello, " + name + p // Return a map with our result { @@ -60,15 +60,15 @@ func TestReadmeStaticProvider(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) script := ` - name := ctx.get("name") - excited := ctx.get("excited") + let name = ctx.get("name") + let excited = ctx.get("excited") - p := "." - if excited { + let p = "." + if (excited) { p = "!" } - message := "Hello, " + name + p + let message = "Hello, " + name + p { "greeting": message @@ -94,8 +94,8 @@ func TestReadmeContextProvider(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) script := ` - name := ctx.get("name") - relationship := ctx.get("relationship") + let name = ctx.get("name") + let relationship = ctx.get("relationship") { "name": name, @@ -127,15 +127,15 @@ func TestReadmeCombiningStaticAndDynamic(t *testing.T) { script := ` // Access both static and dynamic data - name := ctx.get("name") - excited := ctx.get("excited") + let name = ctx.get("name") + let excited = ctx.get("excited") - p := "." - if excited { + let p = "." + if (excited) { p = "!" } - message := "Hello, " + name + p + let message = "Hello, " + name + p { "greeting": message