Update docs

This commit is contained in:
Anton Medvedev
2024-05-08 22:20:37 +02:00
parent 77e80fe655
commit 1ae9d68e8f
14 changed files with 501 additions and 408 deletions
+99 -120
View File
@@ -1,156 +1,135 @@
# Configuration
Expr can be configured with options. For example, you can pass the environment with variables and functions.
## Return type
## AllowUndefinedVariables()
Usually, the return type of expression is anything. But we can instruct type checker to verify the return type of the
expression.
For example, in filter expressions, we expect the return type to be a boolean.
This option allows undefined variables in the expression. By default, Expr will return an error
if the expression contains undefined variables.
```go
program, err := expr.Compile(code, expr.AsBool())
if err != nil {
panic(err)
}
```go
program, err := expr.Compile(`foo + bar`, expr.AllowUndefinedVariables())
output, err := expr.Run(program, env)
if err != nil {
panic(err)
}
ok := output.(bool) // It is safe to assert the output to bool, if the expression is type checked as bool.
```
## AsBool()
If `code` variable for example returns a string, the compiler will return an error.
This option forces the expression to return a boolean value. If the expression returns a non-boolean value,
Expr will return an error.
Expr has a few options to specify the return type:
```go
program, err := expr.Compile(`Title contains "Hello"`, expr.AsBool())
- [expr.AsBool()](https://pkg.go.dev/github.com/expr-lang/expr#AsBool) - expects the return type to be a bool.
- [expr.AsInt()](https://pkg.go.dev/github.com/expr-lang/expr#AsInt) - expects the return type to be an int (float64,
uint, int32, and other will be cast to int).
- [expr.AsInt64()](https://pkg.go.dev/github.com/expr-lang/expr#AsInt64) - expects the return type to be an int64 (
float64, uint, int32, and other will be cast to int64).
- [expr.AsFloat64()](https://pkg.go.dev/github.com/expr-lang/expr#AsFloat64) - expects the return type to be a float64 (
float32 will be cast to float64).
- [expr.AsAny()](https://pkg.go.dev/github.com/expr-lang/expr#AsAny) - expects the return type to be anything.
- [expr.AsKind(reflect.Kind)](https://pkg.go.dev/github.com/expr-lang/expr#AsKind) - expects the return type to be a
specific kind.
:::tip Warn on any
By default, type checker will accept any type, even if the return type is specified. Consider following examples:
```expr
let arr = [1, 2, 3]; arr[0]
```
## AsFloat64()
The return type of the expression is `any`. Arrays created in Expr are of type `[]any`. The type checker will not return
an error if the return type is specified as `expr.AsInt()`. The output of the expression is `1`, which is an int, but the
type checker will not return an error.
This option forces the expression to return a float64 value. If the expression returns a non-float64 value,
Expr will return an error.
But we can instruct the type checker to warn us if the return type is `any`. Use [`expr.WarnOnAny()`](https://pkg.go.dev/github.com/expr-lang/expr#WarnOnAny) to enable this behavior.
```go
program, err := expr.Compile(`42`, expr.AsFloat64())
program, err := expr.Compile(code, expr.AsInt(), expr.WarnOnAny())
```
:::note
If the expression returns integer value, Expr will convert it to float64.
The type checker will return an error if the return type is `any`. We need to modify the expression to return a specific
type.
```expr
let arr = [1, 2, 3]; int(arr[0])
```
:::
## AsInt()
This option forces the expression to return an int value. If the expression returns a non-int value,
Expr will return an error.
## WithContext
```go
program, err := expr.Compile(`42`, expr.AsInt())
Although the compiled program is guaranteed to be terminated, some user defined functions may not be. For example, if a
user defined function calls a remote service, we may want to pass a context to the function.
This is possible via the [`WithContext`](https://pkg.go.dev/github.com/expr-lang/expr#WithContext) option.
This option will modify function calls to include the context as the first argument (only if the function signature
accepts a context).
```expr
customFunc(42)
// will be transformed to
customFunc(ctx, 42)
```
:::note
If the expression returns a float value, Expr truncates it to int.
:::
## AsInt64()
Same as `AsInt()` but returns an int64 value.
Function `expr.WithContext()` takes the name of context variable. The context variable must be defined in the environment.
```go
program, err := expr.Compile(`42`, expr.AsInt64())
env := map[string]any{
"ctx": context.Background(),
}
program, err := expr.Compile(code, expr.Env(env), expr.WithContext("ctx"))
```
## AsKind()
## ConstExpr
This option forces the expression to return a value of the specified kind.
If the expression returns a value of a different kind, Expr will return an error.
For some user defined functions, we may want to evaluate the expression at compile time. This is possible via the
[`ConstExpr`](https://pkg.go.dev/github.com/expr-lang/expr#ConstExpr) option.
```go
program, err := expr.Compile(`42`, expr.AsKind(reflect.String))
func fib(n int) int {
if n <= 1 {
return n
}
return fib(n-1) + fib(n-2)
}
env := map[string]any{
"fib": fib,
}
program, err := expr.Compile(`fib(10)`, expr.Env(env), expr.ConstExpr("fib"))
```
## ConstExpr()
If all arguments of the function are constants, the function will be evaluated at compile time. The result of the function
will be used as a constant in the expression.
This option tells Expr to treat specified functions as constant expressions.
If all arguments of the function are constants, Expr will replace the function call with the result
during the compile step.
```expr
fib(10) // will be transformed to 55 during the compilation
fib(12+12) // will be transformed to 267914296 during the compilation
fib(x) // will **not** be transformed and will be evaluated at runtime
```
## Options
Compiler options can be defined as an array:
```go
program, err := expr.Compile(`fib(42)`, expr.ConstExpr("fib"))
options := []expr.Option{
expr.Env(Env{})
expr.AsInt(),
expr.WarnOnAny(),
expr.WithContext("ctx"),
expr.ConstExpr("fib"),
}
program, err := expr.Compile(code, options...)
```
[ConstExpr Example](https://pkg.go.dev/github.com/expr-lang/expr?tab=doc#ConstExpr)
## Env()
This option passes the environment with variables and functions to the expression.
```go
program, err := expr.Compile(`foo + bar`, expr.Env(Env{}))
```
## Function()
This option adds a function to the expression.
```go
atoi := expr.Function(
"atoi",
func(params ...any) (any, error) {
return strconv.Atoi(params[0].(string))
},
)
program, err := expr.Compile(`atoi("42")`, atoi)
```
Expr sees the `atoi` function as a function with a variadic number of arguments of type `any` and returns a value of type `any`. But, we can specify the types of arguments and the return value by adding the correct function
signature or multiple signatures.
```go
atoi := expr.Function(
"atoi",
func(params ...any) (any, error) {
return strconv.Atoi(params[0].(string))
},
new(func(string) int),
)
```
Or we can simply reuse the `strconv.Atoi` function.
```go
atoi := expr.Function(
"atoi",
func(params ...any) (any, error) {
return strconv.Atoi(params[0].(string))
},
strconv.Atoi,
)
```
Here is another example with a few function signatures:
```go
toInt := expr.Function(
"toInt",
func(params ...any) (any, error) {
switch params[0].(type) {
case float64:
return int(params[0].(float64)), nil
case string:
return strconv.Atoi(params[0].(string))
}
return nil, fmt.Errorf("invalid type")
},
new(func(float64) int),
new(func(string) int),
)
```
## Operator()
This options defines an [operator overloading](operator-overloading).
## Optimize()
This option enables [optimizations](internals.md). By default, Expr will optimize the expression.
## Patch()
This option allows you to [patch the expression](visitor-and-patch) before compilation.
Full list of available options can be found in the [pkg.go.dev](https://pkg.go.dev/github.com/expr-lang/expr#Option) documentation.
+95
View File
@@ -0,0 +1,95 @@
# Environment
The environment is a map or a struct that contains the variables and functions that the expression can access.
## Struct as Environment
Let's consider the following example:
```go
type Env struct {
UpdatedAt time.Time
Posts []Post
Map map[string]string `expr:"tags"`
}
```
The `Env` struct contains 3 variables that the expression can access: `UpdatedAt`, `Posts`, and `tags`.
:::info
The `expr` tag is used to rename the `Map` field to `tags` variable in the expression.
:::
The `Env` struct can also contain methods. The methods defined on the struct become functions that the expression can
call.
```go
func (Env) Format(t time.Time) string {
return t.Format(time.RFC822)
}
```
:::tip
Methods defined on embedded structs are also accessible.
```go
type Env struct {
Helpers
}
type Helpers struct{}
func (Helpers) Format(t time.Time) string {
return t.Format(time.RFC822)
}
```
:::
We can use an empty struct `Env{}` to with [expr.Env](https://pkg.go.dev/github.com/expr-lang/expr#Env) to create an environment. Expr will use reflection to find
the fields and methods of the struct.
```go
program, err := expr.Compile(code, expr.Env(Env{}))
```
Compiler will type check the expression against the environment. After the compilation, we can run the program with the environment.
You should use the same type of environment that you passed to the `expr.Env` function.
```go
output, err := expr.Run(program, Env{
UpdatedAt: time.Now(),
Posts: []Post{{Title: "Hello, World!"}},
Map: map[string]string{"tag1": "value1"},
})
```
## Map as Environment
You can also use a map as an environment.
```go
env := map[string]any{
"UpdatedAt": time.Time{},
"Posts": []Post{},
"tags": map[string]string{},
"sprintf": fmt.Sprintf,
}
program, err := expr.Compile(code, expr.Env(env))
```
A map defines variables and functions that the expression can access. The key is the variable name, and the type
is the value's type.
```go
env := map[string]any{
"object": map[string]any{
"field": 42,
},
}
```
Expr will infer the type of the `object` variable as `map[string]any`.
By default, Expr will return an error if unknown variables are used in the expression.
You can disable this behavior by passing [`AllowUndefinedVariables`](https://pkg.go.dev/github.com/expr-lang/expr#AllowUndefinedVariables) option to the compiler.
+86
View File
@@ -0,0 +1,86 @@
# Functions
Expr comes with a set of [builtin](language-definition.md) functions, but you can also define your own functions.
The easiest way to define a custom function is to add it to the environment.
```go
env := map[string]any{
"add": func(a, b int) int {
return a + b
},
}
```
Or you can use functions defined on a struct:
```go
type Env struct{}
func (Env) Add(a, b int) int {
return a + b
}
```
:::info
If functions are marked with [`ConstExpr`](./configuration.md#constexpr) option, they will be evaluated at compile time.
:::
The best way to define a function from a performance perspective is to use a [`Function`](https://pkg.go.dev/github.com/expr-lang/expr#Function) option.
```go
atoi := expr.Function(
"atoi",
func(params ...any) (any, error) {
return strconv.Atoi(params[0].(string))
},
)
program, err := expr.Compile(`atoi("42")`, atoi)
```
Type checker sees the `atoi` function as a function with a variadic number of arguments of type `any`, and returns
a value of type `any`. But, we can specify the types of arguments and the return value by adding the correct function
signature or multiple signatures.
```go
atoi := expr.Function(
"atoi",
func(params ...any) (any, error) {
return strconv.Atoi(params[0].(string))
},
new(func(string) int),
)
```
Or we can simply reuse the strconv.Atoi function as a type:
```go
atoi := expr.Function(
"atoi",
func(params ...any) (any, error) {
return strconv.Atoi(params[0].(string))
},
// highlight-next-line
strconv.Atoi,
)
```
It is possible to define multiple signatures for a function:
```go
toInt := expr.Function(
"toInt",
func(params ...any) (any, error) {
switch params[0].(type) {
case float64:
return int(params[0].(float64)), nil
case string:
return strconv.Atoi(params[0].(string))
}
return nil, fmt.Errorf("invalid type")
},
new(func(float64) int),
new(func(string) int),
)
```
+46 -38
View File
@@ -26,20 +26,24 @@ fmt.Print(output) // 4
```
Expr compiles the expression `2 + 2` into a bytecode program. Then we run
the program and get the output. The output is `4` as expected.
the program and get the output.
:::tip
In performance-critical applications, you can reuse the compiled program. Compiled programs are safe for concurrent use.
**Compile once** and run **multiple** times.
:::
The `expr.Compile` function returns a `*vm.Program` and an error. The `expr.Run` function takes a program and an
environment. The environment is a map of variables that can be used in the expression. In this example, we use `nil` as
an environment because we don't need any variables.
The `expr.Compile` function returns a `*vm.Program` and an error. The program
can be reused between runs. The `expr.Run` function takes a program and an
environment. The environment is a map of variables that can be used in the
expression. In this example, we use `nil` as an environment because we don't
need any variables.
Now let's pass some variables to the expression:
```go
env := map[string]any{
"foo": 100,
"bar": 200,
"bar": 200,
}
program, err := expr.Compile(`foo + bar`, expr.Env(env))
@@ -56,7 +60,9 @@ fmt.Print(output) // 300
```
Why do we need to pass the environment to the `expr.Compile` function? Expr can be used as a type-safe language.
Expr can infer the type of the expression and check it against the environment. Here is an example:
Expr can infer the type of the expression and check it against the environment.
Here is an example:
```go
env := map[string]any{
@@ -70,73 +76,75 @@ if err != nil {
}
```
Expr can work with any Go types. Here is an example:
Expr can work with any Go types:
```go
env := map[string]any{
"greet": "Hello, %v!",
"names": []string{"world", "you"},
"sprintf": fmt.Sprintf,
"greet": "Hello, %v!",
"names": []string{"world", "you"},
"sprintf": fmt.Sprintf,
}
code := `sprintf(greet, names[0])`
program, err := expr.Compile(code, expr.Env(env))
if err != nil {
panic(err)
panic(err)
}
output, err := expr.Run(program, env)
if err != nil {
panic(err)
panic(err)
}
fmt.Print(output) // Hello, world!
```
Also, Expr can use a struct as an environment. Methods defined on the struct become functions.
The struct fields can be renamed with the `expr` tag. Here is an example:
The struct fields can be renamed with the `expr` tag.
Here is an example:
```go
type Env struct {
Posts []Post `expr:"posts"`
Posts []Post `expr:"posts"`
}
func (Env) Format(t time.Time) string {
return t.Format(time.RFC822)
return t.Format(time.RFC822)
}
type Post struct {
Body string
Date time.Time
Body string
Date time.Time
}
func main() {
code := `map(posts, Format(.Date) + ": " + .Body)`
program, err := expr.Compile(code, expr.Env(Env{}))
if err != nil {
panic(err)
}
code := `map(posts, Format(.Date) + ": " + .Body)`
program, err := expr.Compile(code, expr.Env(Env{}))
if err != nil {
panic(err)
}
env := Env{
Posts: []Post{
{"Oh My God!", time.Now()},
{"How you doin?", time.Now()},
{"Could I be wearing any more clothes?", time.Now()},
},
}
env := Env{
Posts: []Post{
{"Oh My God!", time.Now()},
{"How you doin?", time.Now()},
{"Could I be wearing any more clothes?", time.Now()},
},
}
output, err := expr.Run(program, env)
if err != nil {
panic(err)
}
output, err := expr.Run(program, env)
if err != nil {
panic(err)
}
fmt.Print(output)
fmt.Print(output)
}
```
The compiled program can be reused between runs. Here is an example:
The compiled program can be reused between runs.
```go
type Env struct {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

-61
View File
@@ -1,61 +0,0 @@
# Internals
Expr is a stack-based virtual machine. It compiles expressions to bytecode and
runs it. The compilation is done in a few steps:
- Parse expression to AST
- Type check AST
- Apply operator overloading
- Apply custom AST patching
- Optimize AST
- Compile AST to a bytecode
The compiler has a bunch of optimization which will produce a more optimal program.
## In array
```
value in ['foo', 'bar', 'baz']
```
If Expr finds an `in` or `not in` expression with an array, it will be
transformed into:
```
value in {"foo": true, "bar": true, "baz": true}
```
## Constant folding
Arithmetic expressions with constants are computed on compile step and replaced
with the result.
```
-(2 - 5) ** 3 - 2 / (+4 - 3) + -2
```
Will be compiled into just a single number:
```
23
```
## In range
```
user.Age in 18..32
```
Will be replaced with a binary operator:
```
18 <= user.Age && user.Age <= 32
```
## Const range
```
1..10_000
```
Ranges computed on the compile stage, are replaced with pre-created slices.
+1 -1
View File
@@ -486,7 +486,7 @@ date("2023-08-14 00:00:00", "2006-01-02 15:04:05", "Europe/Zurich")
Available methods on the date:
- `Year()` - returns the year
- `Year()` - returns the year
- `Month()` - returns the month (starting from 1)
- `Day()` - returns the day of the month
- `Hour()` - returns the hour
-52
View File
@@ -1,52 +0,0 @@
# Operator Overloading
**Expr** supports operator overloading. For example, you may rewrite such expression:
```
Now().Sub(CreatedAt)
```
To use `-` operator:
```
Now() - CreatedAt
```
To overload the operator use [Operator](https://pkg.go.dev/github.com/expr-lang/expr?tab=doc#Operator) option:
```go
func main() {
code := `Now() - CreatedAt`
options := []expr.Option{
expr.Env(Env{}),
expr.Operator("-", "Sub"), // Replace `-` operator with function `Sub`.
}
program, err := expr.Compile(code, options...)
if err != nil {
panic(err)
}
env := Env{
CreatedAt: time.Date(1987, time.November, 24, 20, 0, 0, 0, time.UTC),
}
output, err := expr.Run(program, env)
if err != nil {
panic(err)
}
fmt.Print(output)
}
type Env struct {
CreatedAt time.Time
}
func (Env) Now() time.Time { return time.Now() }
func (Env) Sub(a, b time.Time) time.Duration { return a.Sub(b) }
```
**Expr** uses functions from `Env` for operator overloading. If types of
operands match types of a function, the operator will be replaced with a
function call.
+125
View File
@@ -0,0 +1,125 @@
# Patch
Sometimes it may be necessary to modify an expression before the compilation.
Expr provides a powerful mechanism to modify the expression using
the [`Patch`](https://pkg.go.dev/github.com/expr-lang/expr#Patch) option.
## Simple example
Let's start with a simple example. We have an expression that uses a variable `foo`:
```go
program, err := expr.Compile(`foo + bar`)
```
We want to replace the `foo` variable with a constant `42`. First, we need to implement a [visitor](./visitor.md):
```go
type FooPatcher struct{}
func (FooPatcher) Visit(node *ast.Node) {
if n, ok := (*node).(*ast.IdentifierNode); ok && n.Value == "foo" {
ast.Patch(node, &ast.IntegerNode{Value: 42})
}
}
```
Now we can use the `FooPatcher` to modify the expression:
```go
program, err := expr.Compile(`foo + bar`, expr.Patch(FooPatcher{}))
```
## Advanced example
Let's consider a more complex example. We have an expression that uses variables `foo` and `bar` of type `Decimal`:
```go
type Decimal struct {
Value int
}
```
And we want to transform the following expression:
```expr
a + b + c
```
Into functions calls that accept `Decimal` arguments:
```expr
add(add(a, b), c)
```
First, we need to implement a visitor that will transform the expression:
```go
type DecimalPatcher struct{}
var decimalType = reflect.TypeOf(Decimal{})
func (DecimalPatcher) Visit(node *ast.Node) {
if n, ok := (*node).(*ast.BinaryNode); ok && n.Operator == "+" {
if !n.Left.Type().AssignableTo(decimalType) {
return // skip, left side is not a Decimal
}
if !n.Right.Type().AssignableTo(decimalType) {
return // skip, right side is not a Decimal
}
ast.Patch(node, &ast.CallNode{
Callee: &ast.IdentifierNode{Value: "add"},
Arguments: []ast.Node{n.Left, n.Right},
})
(*node).SetType(decimalType) // set the type, so patcher can be used multiple times
}
}
```
We used [Type()](https://pkg.go.dev/github.com/expr-lang/expr/ast#Node.Type) method to get the type of the expression node.
The `AssignableTo` method is used to check if the type is `Decimal`. If both sides are `Decimal`, we replace the expression with a function call.
The important part of this patcher is to set correct types for the nodes. As we constructed a new `CallNode`, it lacks the type information.
So after the first patcher run, if we want the patcher to be applied recursively, we need to set the type of the node.
Now we can use the `DecimalPatcher` to modify the expression:
```go
env := map[string]interface{}{
"a": Decimal{1},
"b": Decimal{2},
"c": Decimal{3},
"add": func(x, y Decimal) Decimal {
return Decimal{x.Value + y.Value}
},
}
code := `a + b + c`
program, err := expr.Compile(code, expr.Env(env), expr.Patch(DecimalPatcher{}))
if err != nil {
panic(err)
}
output, err := expr.Run(program, env)
if err != nil {
panic(err)
}
fmt.Println(output) // Decimal{6}
```
:::info
Expr comes with already implemented patcher that simplifies operator overloading.
The `DecimalPatcher` can be replaced with the [Operator](https://pkg.go.dev/github.com/expr-lang/expr#Operator) option.
```go
program, err := expr.Compile(code, expr.Env(env), expr.Operator("+", "add"))
```
Operator overloading patcher will check if provided functions (`"add"`) satisfy the operator (`"+"`), and
replace the operator with the function call.
:::
-38
View File
@@ -1,38 +0,0 @@
# Tips
## Reuse VM
It is possible to reuse a virtual machine between re-runs on the program.
In some cases, it can add a small increase in performance (~10%).
```go
package main
import (
"fmt"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
)
func main() {
env := map[string]interface{}{
"foo": 1,
"bar": 2,
}
program, err := expr.Compile("foo + bar", expr.Env(env))
if err != nil {
panic(err)
}
// Reuse this vm instance between runs
v := vm.VM{}
out, err := v.Run(program, env)
if err != nil {
panic(err)
}
fmt.Print(out)
}
```
-98
View File
@@ -1,98 +0,0 @@
# Visitor and Patch
The [ast](https://pkg.go.dev/github.com/expr-lang/expr/ast?tab=doc) package
provides the `ast.Visitor` interface and the `ast.Walk` function. It can be
used to customize the AST before compiling.
For example, to collect all variable names:
```go
package main
import (
"fmt"
"github.com/expr-lang/expr/ast"
"github.com/expr-lang/expr/parser"
)
type visitor struct {
identifiers []string
}
func (v *visitor) Visit(node *ast.Node) {
if n, ok := (*node).(*ast.IdentifierNode); ok {
v.identifiers = append(v.identifiers, n.Value)
}
}
func main() {
tree, err := parser.Parse("foo + bar")
if err != nil {
panic(err)
}
visitor := &visitor{}
ast.Walk(&tree.Node, visitor)
fmt.Printf("%v", visitor.identifiers) // outputs [foo bar]
}
```
## Patch
Specify a visitor to modify the AST with `expr.Patch` function.
```go
program, err := expr.Compile(code, expr.Patch(&visitor{}))
```
For example, let's pass a context to every function call:
```go
package main
import (
"context"
"fmt"
"reflect"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/ast"
)
func main() {
env := map[string]interface{}{
"foo": func(ctx context.Context, arg int) any {
// ...
},
"ctx": context.Background(),
}
code := `foo(42)` // will be converted to foo(ctx, 42)
program, err := expr.Compile(code, expr.Env(env), expr.Patch(patcher{}))
if err != nil {
panic(err)
}
output, err := expr.Run(program, env)
if err != nil {
panic(err)
}
fmt.Print(output)
}
type patcher struct{}
var contextType = reflect.TypeOf((*context.Context)(nil)).Elem()
func (patcher) Visit(node *ast.Node) {
callNode, ok := (*node).(*ast.CallNode)
if !ok {
return
}
callNode.Arguments = append([]ast.Node{&ast.IdentifierNode{Value: "ctx"}}, callNode.Arguments...)
}
```
+49
View File
@@ -0,0 +1,49 @@
# Visitor
Expr provides an interface to traverse the AST of the expression before the compilation.
The `Visitor` interface allows you to collect information about the expression, modify the expression, or even generate
a new expression.
Let's start with an [ast.Visitor](https://pkg.go.dev/github.com/expr-lang/expr/ast#Visitor) implementation which will
collect all variables used in the expression:
```go
type Visitor struct {
Identifiers []string
}
func (v *Visitor) Visit(node *ast.Node) {
if n, ok := (*node).(*ast.IdentifierNode); ok {
v.Identifiers = append(v.Identifiers, n.Value)
}
}
```
Full list of available AST nodes can be found in the [ast](https://pkg.go.dev/github.com/expr-lang/expr/ast) documentation.
Let's parse the expression and use [ast.Walk](https://pkg.go.dev/github.com/expr-lang/expr/ast#Walk) to traverse the AST:
```go
tree, err := parser.Parse(`foo + bar`)
if err != nil {
panic(err)
}
v := &Visitor{}
ast.Walk(&tree.Node, v)
fmt.Println(v.Identifiers) // [foo, bar]
```
:::note
Although it is possible to access the AST of compiled program, it may be already be modified by patchers, optimizers, etc.
```go
node := program.Node()
v := &Visitor{}
ast.Walk(&node, v)
```
:::