diff --git a/docs/language.md b/docs/language.md index 45eb041..069b388 100644 --- a/docs/language.md +++ b/docs/language.md @@ -262,41 +262,50 @@ you can do with variables, like: #### Scopes -In general, side effects (i.e. functions with bang modifier) affect all following sibling and child -expressions, but not the parent. This is like doing +Rudi is roughly function-scoped, like JavaScript: Once a variable is defined using `set!`, it is +available for the rest of the current program (or user-defined function). + +##### Variables + +A statement like `(set! $foo 42) (if (condition) (set! $foo 7))` would be equal to ```go foo := 42 if condition { - foo := 7 + foo = 7 } println(foo) // prints 42 ``` -in Go. Variables are meant to be helpers and are so scoped to the scope where they are defined: +in Go. However function-scoping means that a variable is actually defined one it was set and is then +valid for all subsequent expressions, so ```lisp (set! $var 42) -$var # 42 +(if (gt? $var 4) (set! $tooLarge true)) +$tooLarge +``` -# This set! function would set the value for the entire positive branch of the "if" tuple, -# but it will not leak outside of the "if". +will yield `true`, since the `$tooLarge` variable is available even after `if` has finished. -(if true - (set! $var "new-value")) # "new-value" -$var # 42 +##### User-Defined Functions -# ... but the new variable is valid in its scope. +Functions defined using `func!` form a sub-program. This means any scoped variable defined on the +outside is not visible inside the function, the following is therefore invalid: -(if true - (do - (set! $var "new-value") - (append $var "-suffix"))) # "new-value-suffix" -$var # 42 +```lisp +(set! $foo) +(func! do-stuff [] (+ $foo 1)) +(do-stuff) ``` +User-defined functions run in their own scope, where only the arguments and global variables are +available (though new, function-scoped variables can of course be defined). + +##### Global Document + The exception from this rule is the global document. As the name implies, it is meant to be global and to allow for effective, readable Rudi code, there is only one document and it can be modified from anywhere. diff --git a/pkg/builtin/core/functions.go b/pkg/builtin/core/functions.go index 8c44776..20e7adb 100644 --- a/pkg/builtin/core/functions.go +++ b/pkg/builtin/core/functions.go @@ -67,13 +67,12 @@ func ifElseFunction(ctx types.Context, test bool, yes, no ast.Expression) (any, // exported. func DoFunction(ctx types.Context, args ...ast.Expression) (any, error) { var ( - tupleCtx = ctx - result any - err error + result any + err error ) for _, arg := range args { - tupleCtx, result, err = ctx.Runtime().EvalExpression(tupleCtx, arg) + ctx, result, err = ctx.Runtime().EvalExpression(ctx, arg) if err != nil { return nil, err } @@ -285,7 +284,7 @@ func overwriteEverythingBangHandler(ctx types.Context, originalArgs []ast.Expres // update the target, ignoring the path expression on the symbol. if symbol.Variable != nil { varName := string(*symbol.Variable) - ctx = ctx.WithVariable(varName, value) + ctx.SetVariable(varName, value) } else { ctx.GetDocument().Set(value) } diff --git a/pkg/builtin/core/functions_test.go b/pkg/builtin/core/functions_test.go index d94df73..7d0bf2a 100644 --- a/pkg/builtin/core/functions_test.go +++ b/pkg/builtin/core/functions_test.go @@ -150,7 +150,7 @@ func TestSetFunction(t *testing.T) { Expression: `(set! $var 1) $var`, Expected: int64(1), }, - // can overwrite variables on the top level + // can overwrite global variables { Expression: `(set! $myvar 12) $myvar`, Variables: testVariables(), @@ -209,14 +209,14 @@ func TestSetFunction(t *testing.T) { Variables: testVariables(), Expected: []any{"first", 2, "third"}, }, - // ...but not leak into upper scopes + // variables are program/rudifunc-scoped, so changes in sub expressions should modify the variable { Expression: `(set! $a 1) (if true (set! $a 2)) $a`, - Expected: int64(1), + Expected: int64(2), }, { Expression: `(set! $a 1) (if true (set! $b 2)) $b`, - Invalid: true, + Expected: int64(2), }, // do not accidentally set a key without creating a new context { @@ -225,7 +225,7 @@ func TestSetFunction(t *testing.T) { }, { Expression: `(set! $a {foo "bar"}) (if true (set! $a.foo "updated")) $a.foo`, - Expected: "bar", + Expected: "updated", }, // handle bad paths { @@ -258,8 +258,8 @@ func TestSetFunction(t *testing.T) { }, { Expression: `(set! $obj.aString "new value") $obj.aString`, - Expected: "new value", Variables: testVariables(), + Expected: "new value", }, // add a new sub key { @@ -589,25 +589,27 @@ func TestDoFunction(t *testing.T) { Expression: `(do 3)`, Expected: int64(3), }, - - // test that the runtime context is inherited from one step to another + // do should not open a sub-scope, allowing all expressions to freely live in the same + // context { Expression: `(do (set! $var "foo") $var)`, Expected: "foo", }, { - Expression: `(do (set! $var "foo") $var (set! $var "new") $var)`, - Expected: "new", + Expression: `(do (set! $var "foo")) $var`, + Expected: "foo", }, - - // test that the runtime context doesn't leak { - Expression: `(set! $var "outer") (do (set! $var "inner")) $var`, - Expected: "outer", + Expression: `(set! $var "foo") (do (set! $var "bar")) $var`, + Expected: "bar", }, { - Expression: `(do (set! $var "inner")) $var`, - Invalid: true, + Expression: `(do (set! $var "foo") (set! $var "new") $var)`, + Expected: "new", + }, + { + Expression: `(do (set! $var "foo") (set! $var "new") "dummy") $var`, + Expected: "new", }, } diff --git a/pkg/builtin/lists/functions.go b/pkg/builtin/lists/functions.go index 33207e8..8ffd435 100644 --- a/pkg/builtin/lists/functions.go +++ b/pkg/builtin/lists/functions.go @@ -61,10 +61,7 @@ func rangeVectorFunction(ctx types.Context, data []any, namingVec ast.Expression return nil, fmt.Errorf("argument #1: not a valid naming vector: %w", err) } - var ( - result any - loopCtx = ctx - ) + var result any for i, item := range data { vars := map[string]any{ @@ -75,10 +72,12 @@ func rangeVectorFunction(ctx types.Context, data []any, namingVec ast.Expression vars[loopIndexName] = i } - // do not use separate contexts for each loop iteration, as the loop might build up a counter - loopCtx = loopCtx.WithVariables(vars) + // Do not use separate contexts for each loop iteration, as the loop might build up a counter, + // but only use the loop variables in a shallow scope, where only these two temporary variables + // are laid over the regular scoped/global variables. + scope := ctx.NewShallowScope(vars) - loopCtx, result, err = ctx.Runtime().EvalExpression(loopCtx, expr) + _, result, err = ctx.Runtime().EvalExpression(scope, expr) if err != nil { return nil, err } @@ -97,8 +96,7 @@ func rangeObjectFunction(ctx types.Context, data map[string]any, namingVec ast.E } var ( - result any - loopCtx = ctx + result any ) for key, value := range data { @@ -110,10 +108,7 @@ func rangeObjectFunction(ctx types.Context, data map[string]any, namingVec ast.E vars[loopIndexName] = key } - // do not use separate contexts for each loop iteration, as the loop might build up a counter - loopCtx = loopCtx.WithVariables(vars) - - loopCtx, result, err = ctx.Runtime().EvalExpression(loopCtx, expr) + _, result, err = ctx.Runtime().EvalExpression(ctx.NewShallowScope(vars), expr) if err != nil { return nil, err } @@ -157,9 +152,7 @@ func mapVectorExpressionFunction(ctx types.Context, data []any, namingVec ast.Ex vars[indexVarName] = index } - ctx = ctx.WithVariables(vars) - - return ctx.Runtime().EvalExpression(ctx, expr) + return ctx.Runtime().EvalExpression(ctx.NewShallowScope(vars), expr) } return mapVector(ctx, data, mapHandler) @@ -219,9 +212,7 @@ func mapObjectExpressionFunction(ctx types.Context, data map[string]any, namingV vars[keyVarName] = key } - ctx = ctx.WithVariables(vars) - - return ctx.Runtime().EvalExpression(ctx, expr) + return ctx.Runtime().EvalExpression(ctx.NewShallowScope(vars), expr) } return mapObject(ctx, data, mapHandler) @@ -281,9 +272,7 @@ func filterVectorExpressionFunction(ctx types.Context, data []any, namingVec ast vars[indexVarName] = index } - ctx = ctx.WithVariables(vars) - - return ctx.Runtime().EvalExpression(ctx, expr) + return ctx.Runtime().EvalExpression(ctx.NewShallowScope(vars), expr) } return filterVector(ctx, data, mapHandler) @@ -349,9 +338,7 @@ func filterObjectExpressionFunction(ctx types.Context, data map[string]any, nami vars[keyVarName] = key } - ctx = ctx.WithVariables(vars) - - return ctx.Runtime().EvalExpression(ctx, expr) + return ctx.Runtime().EvalExpression(ctx.NewShallowScope(vars), expr) } return filterObject(ctx, data, mapHandler) diff --git a/pkg/builtin/rudifunc/functions.go b/pkg/builtin/rudifunc/functions.go index f1bf440..da89637 100644 --- a/pkg/builtin/rudifunc/functions.go +++ b/pkg/builtin/rudifunc/functions.go @@ -54,7 +54,9 @@ func funcBangHandler(ctx types.Context, originalArgs []ast.Expression, value any panic("This should never happen: func! bang handler called with non-intermediate function.") } - return ctx.WithRudispaceFunction(intermediate.name, intermediate), nil, nil + ctx.SetRudispaceFunction(intermediate.name, intermediate) + + return ctx, nil, nil } type rudispaceFunc struct { @@ -85,7 +87,9 @@ func (f rudispaceFunc) Evaluate(ctx types.Context, args []ast.Expression) (any, } // user-defined functions form a sub-program and all statements share the same context - funcCtx := ctx.WithVariables(funcArgs) + funcCtx := ctx.NewScope() + funcCtx.SetVariables(funcArgs) + runtime := ctx.Runtime() var ( diff --git a/pkg/builtin/rudifunc/functions_test.go b/pkg/builtin/rudifunc/functions_test.go new file mode 100644 index 0000000..22747ca --- /dev/null +++ b/pkg/builtin/rudifunc/functions_test.go @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2024 Christoph Mewes +// SPDX-License-Identifier: MIT + +package rudifunc_test + +import ( + "testing" + + "go.xrstf.de/rudi/pkg/builtin/core" + "go.xrstf.de/rudi/pkg/builtin/math" + "go.xrstf.de/rudi/pkg/builtin/rudifunc" + "go.xrstf.de/rudi/pkg/builtin/strings" + "go.xrstf.de/rudi/pkg/runtime/types" + "go.xrstf.de/rudi/pkg/testutil" +) + +func TestUserDefinedFunctions(t *testing.T) { + // testDoc := map[string]any{ + // "int": int64(4), + // "float": float64(1.2), + // "bool": true, + // "string": "foo", + // "null": nil, + // "vector": []any{int64(1)}, + // "object": map[string]any{ + // "key": "value", + // }, + // } + + testcases := []testutil.Testcase{ + // syntax checks + + { + Expression: `(func!)`, + Invalid: true, + }, + { + Expression: `(func! foo)`, + Invalid: true, + }, + { + Expression: `(func! foo bar)`, + Invalid: true, + }, + { + Expression: `(func! foo [])`, + Invalid: true, + }, + { + Expression: `(func! foo [1] (bar))`, + Invalid: true, + }, + + // defining functions, but not calling them + + { + Expression: `(func! foo [] (bar))`, + Expected: nil, + }, + { + Expression: `(func! foo [] (bar) (bar) (bar))`, + Expected: nil, + }, + { + Expression: `(func! foo [a b c] (bar))`, + Expected: nil, + }, + + // functions can be redefined + + { + Expression: `(func! foo [] (bar)) (func! foo [] (other))`, + Expected: nil, + }, + + // functions can be defined at any place + + { + Expression: `(if true (func! foo [] 12)) (foo)`, + Expected: int64(12), + }, + { + Expression: `(if false (func! foo [] 12)) (foo)`, + Invalid: true, + }, + + // calling functions + + { + Expression: `(func! foo [] 12) (foo)`, + Expected: int64(12), + }, + { + Expression: `(func! foo [] (append "foo" "bar")) (foo)`, + Expected: "foobar", + }, + { + Expression: `(func! foo [] "foo" 12) (foo)`, + Expected: int64(12), + }, + + // .. with arguments + + { + Expression: `(func! foo [a] (+ $a 1)) (foo)`, + Invalid: true, + }, + { + Expression: `(func! foo [a] (+ $a 1)) (foo 1)`, + Expected: int64(2), + }, + { + Expression: `(func! foo [a] (+ $a 1)) (foo 1 2)`, + Invalid: true, + }, + { + Expression: `(func! foo [a b] (+ $a $b)) (foo 1 2)`, + Expected: int64(3), + }, + + // functions form a singular scope + + { + Expression: `(func! foo [] (set! $a 1) (+ $a 1)) (foo)`, + Expected: int64(2), + }, + + // variables from within a function do not leak outside + + { + Expression: `(func! foo [] (set! $a 1)) (foo) $a`, + Invalid: true, + }, + + // arguments only live inside their function as well + + { + Expression: `(func! foo [arg] (+ $arg 1)) (foo 1)`, + Expected: int64(2), + }, + { + Expression: `(func! foo [arg] (+ $arg 1)) (foo 1) $arg`, + Invalid: true, + }, + { + Expression: `(func! foo [arg] (set! $arg 2) (+ $arg 1)) (foo 1)`, + Expected: int64(3), + }, + { + Expression: `(func! foo [arg] (set! $arg 2) (+ $arg 1)) (foo 1) $arg`, + Invalid: true, + }, + + // functions only see their arguments, no other variables + + { + Expression: `(func! foo [] (+ $a 1)) (set! $a 1) (foo)`, + Invalid: true, + }, + { + Expression: `(set! $a 1) (func! foo [] (+ $a 1)) (foo)`, + Invalid: true, + }, + { + Expression: `(func! foo [a] (+ $a 1)) (set! $a 1) (foo $a)`, + Expected: int64(2), + }, + { + Expression: `(set! $a 1) (func! foo [a] (+ $a 1)) (foo $a)`, + Expected: int64(2), + }, + { + Expression: `(set! $a 1) (func! foo [b] (+ $b $a)) (foo $a)`, + Invalid: true, + }, + + // cannot call before it's defined + + { + Expression: `(foo) (func! foo [] 12)`, + Invalid: true, + }, + } + + funcs := types.NewFunctions() + funcs.Add(rudifunc.Functions) + funcs.Add(core.Functions) + funcs.Add(math.Functions) + funcs.Add(strings.Functions) + + for _, testcase := range testcases { + testcase.Functions = funcs + t.Run(testcase.String(), testcase.Run) + } +} diff --git a/pkg/runtime/interpreter/eval_program.go b/pkg/runtime/interpreter/eval_program.go index fc0b1fb..1a66ce7 100644 --- a/pkg/runtime/interpreter/eval_program.go +++ b/pkg/runtime/interpreter/eval_program.go @@ -21,7 +21,7 @@ func (i *interpreter) EvalProgram(ctx types.Context, p *ast.Program) (types.Cont return ctx, nil, nil } - innerCtx := ctx + scope := ctx.NewScope() var ( result any @@ -29,11 +29,11 @@ func (i *interpreter) EvalProgram(ctx types.Context, p *ast.Program) (types.Cont ) for _, stmt := range p.Statements { - innerCtx, result, err = i.EvalStatement(innerCtx, stmt) + _, result, err = i.EvalStatement(scope, stmt) if err != nil { return ctx, nil, fmt.Errorf("failed to eval statement %s: %w", stmt.String(), err) } } - return innerCtx, result, nil + return ctx, result, nil } diff --git a/pkg/runtime/interpreter/eval_tuple.go b/pkg/runtime/interpreter/eval_tuple.go index c9eaab4..b331c14 100644 --- a/pkg/runtime/interpreter/eval_tuple.go +++ b/pkg/runtime/interpreter/eval_tuple.go @@ -120,7 +120,7 @@ func (*interpreter) CallFunction(ctx types.Context, fun ast.Identifier, args []a if updateSymbol.Variable != nil { varName := string(*updateSymbol.Variable) - resultCtx = resultCtx.WithVariable(varName, updatedValue) + resultCtx.SetVariable(varName, updatedValue) } else { ctx.GetDocument().Set(updatedValue) } diff --git a/pkg/runtime/interpreter/test/program_test.go b/pkg/runtime/interpreter/test/program_test.go index 44d37e1..736ca07 100644 --- a/pkg/runtime/interpreter/test/program_test.go +++ b/pkg/runtime/interpreter/test/program_test.go @@ -80,7 +80,7 @@ func TestEvalProgram(t *testing.T) { ), Expected: 1, }, - // context changes from inner statements should not leak + // all variables share one scope, even in sub expressions // (set! $foo (set! $bar 1)) $bar { AST: makeProgram( @@ -95,7 +95,7 @@ func TestEvalProgram(t *testing.T) { ), makeVar("bar", nil), ), - Invalid: true, + Expected: 1, }, } diff --git a/pkg/runtime/types/context.go b/pkg/runtime/types/context.go index eb4edc2..c8ecbca 100644 --- a/pkg/runtime/types/context.go +++ b/pkg/runtime/types/context.go @@ -12,13 +12,15 @@ import ( ) type Context struct { - ctx context.Context - document *Document - fixedFuncs Functions - userFuncs Functions - variables Variables - coalescer coalescing.Coalescer - runtime Runtime + ctx context.Context + document *Document + fixedFuncs Functions + userFuncs Functions + globalVariables Variables + scopeVariables Variables + tempVariables Variables + coalescer coalescing.Coalescer + runtime Runtime } func NewContext(runtime Runtime, ctx context.Context, doc Document, variables Variables, funcs Functions, coalescer coalescing.Coalescer) (Context, error) { @@ -43,16 +45,32 @@ func NewContext(runtime Runtime, ctx context.Context, doc Document, variables Va } return Context{ - ctx: ctx, - document: &doc, - fixedFuncs: funcs, - userFuncs: NewFunctions(), - variables: variables, - coalescer: coalescer, - runtime: runtime, + ctx: ctx, + document: &doc, + fixedFuncs: funcs, + userFuncs: NewFunctions(), + globalVariables: variables, + scopeVariables: NewVariables(), + coalescer: coalescer, + runtime: runtime, }, nil } +func (c Context) NewScope() Context { + clone := c.shallowCopy() + clone.scopeVariables = NewVariables() + clone.tempVariables = nil + + return clone +} + +func (c Context) NewShallowScope(extraVars Variables) Context { + clone := c.shallowCopy() + clone.tempVariables = extraVars + + return clone +} + // Coalesce is named this way to make the frequent calls read fluently // (for example "ctx.Coalesce().ToBool(...)"). func (c Context) Coalesce() coalescing.Coalescer { @@ -72,7 +90,17 @@ func (c Context) GetDocument() *Document { } func (c Context) GetVariable(name string) (any, bool) { - return c.variables.Get(name) + value, ok := c.tempVariables.Get(name) + if ok { + return value, true + } + + value, ok = c.scopeVariables.Get(name) + if ok { + return value, true + } + + return c.globalVariables.Get(name) } func (c Context) GetFunction(name string) (Function, bool) { @@ -91,47 +119,48 @@ func (c Context) WithGoContext(ctx context.Context) Context { return clone } -func (c Context) WithVariable(name string, val any) Context { +func (c Context) WithCoalescer(coalescer coalescing.Coalescer) Context { clone := c.shallowCopy() - clone.variables = c.variables.With(name, val) + clone.coalescer = coalescer return clone } -func (c Context) WithVariables(vars map[string]any) Context { - if len(vars) == 0 { - return c - } +func (c Context) SetVariable(name string, val any) { + var vars Variables - clone := c.shallowCopy() - clone.variables = c.variables.WithMany(vars) + if _, ok := c.tempVariables.Get(name); ok { + vars = c.tempVariables + } else if _, ok := c.globalVariables.Get(name); ok { + vars = c.globalVariables + } else { + vars = c.scopeVariables + } - return clone + vars.Set(name, val) } -func (c Context) WithCoalescer(coalescer coalescing.Coalescer) Context { - clone := c.shallowCopy() - clone.coalescer = coalescer - - return clone +func (c Context) SetVariables(vars Variables) { + for key, value := range vars { + c.SetVariable(key, value) + } } -func (c Context) WithRudispaceFunction(funcName string, fun Function) Context { - clone := c.shallowCopy() - clone.userFuncs = c.userFuncs.DeepCopy().Set(funcName, fun) - - return clone +func (c Context) SetRudispaceFunction(funcName string, fun Function) { + c.userFuncs = c.userFuncs.Set(funcName, fun) } func (c Context) shallowCopy() Context { return Context{ - ctx: c.ctx, - document: c.document, - fixedFuncs: c.fixedFuncs, - userFuncs: c.userFuncs, - variables: c.variables, - coalescer: c.coalescer, - runtime: c.runtime, + ctx: c.ctx, + document: c.document, + fixedFuncs: c.fixedFuncs, + userFuncs: c.userFuncs, + globalVariables: c.globalVariables, + scopeVariables: c.scopeVariables, + tempVariables: c.tempVariables, + coalescer: c.coalescer, + runtime: c.runtime, } } diff --git a/pkg/runtime/types/variables.go b/pkg/runtime/types/variables.go index e97ba0e..69849f0 100644 --- a/pkg/runtime/types/variables.go +++ b/pkg/runtime/types/variables.go @@ -21,6 +21,17 @@ func (v Variables) Set(name string, val any) Variables { return v } +// SetMany calls Set() for each of the other Variables, copying their values +// into the current variables. +// The function returns the same variables to allow fluent access. +func (v Variables) SetMany(other Variables) Variables { + for key, value := range other { + v.Set(key, value) + } + + return v +} + // With returns a copy of the variables, with the new variable being added to it. func (v Variables) With(name string, val any) Variables { return v.DeepCopy().Set(name, val)