Skip to content

Commit

Permalink
File load, key lookup, and switch statement (#103)
Browse files Browse the repository at this point in the history
* File load, key lookup, and switch statement
* Flag to disable expressions external loading
  • Loading branch information
zix99 authored May 19, 2024
1 parent fddc4e6 commit 0192d22
Show file tree
Hide file tree
Showing 12 changed files with 296 additions and 0 deletions.
6 changes: 6 additions & 0 deletions cmd/testdata/lookup.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#test 22
bob 33
jill 44

cat 55
key
43 changes: 43 additions & 0 deletions docs/usage/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ Syntax: `{if val ifTrue ifFalse}`, `{if val ifTrue}`, `{unless val ifFalse}`

If `val` is truthy, then return `ifTrue` else optionally return `ifFalse`

#### Switch

Syntax: `{switch ifTrue val ifTrue val ... [ifFalseVal]}`

In pairs, if a given value is truthy, return the value immediately after. If
there is an odd number of arguments, the last value is used as the "else" result.
Otherwise, empty string is returned.

#### Equals, NotEquals, Not

Syntax: `{eq a b}`, `{neq a b}`, `{not a}`
Expand Down Expand Up @@ -259,6 +267,41 @@ to form arrays that have meaning for a given aggregator.

Specifying multiple expressions is equivalent, eg. `{$ a b}` is the same as `-e a -e b`

### File Loading and Lookup Tables

Load external static content as either raw string, or to be used to lookup
a value given a key.

#### Load

Syntax: `{load "filename"}`

Loads a given filename as text.

To globally disable file loading in expressions for security reasons, specify
`--noload` as global argument.

#### Lookup, HasKey

Syntax: `{lookup key "kv-pairs" ["commentPrefix"]}`, `{haskey key "kv-pairs" ["commentPrefix"]}`

Given a set of kv-pairs (eg. from a loaded file), lookup a key. For `lookup` return a value
and for `haskey` return truthy or falsey.

If a `commentPrefix` is provided, lines in lookup text are ignored if they start with the prefix.

Example kv-pairs text. Keys and values are separated by any whitespace.

```
key1 val1
key2 val2
#comment if '#' set as prefix
key3 val3
#blank lines are ignored
too many values are also ignored
```

### Ranges (Arrays)

Range functions provide the ability to work with arrays in expressions. You
Expand Down
9 changes: 9 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"rare/cmd"
"rare/cmd/helpers"
"rare/pkg/color"
"rare/pkg/expressions/stdlib"
"rare/pkg/fastregex"
"rare/pkg/humanize"
"rare/pkg/logger"
Expand Down Expand Up @@ -58,6 +59,11 @@ func buildApp() *cli.App {
Aliases: []string{"nu"},
Usage: "Disable usage of unicode characters",
},
&cli.BoolFlag{
Name: "noload",
Aliases: []string{"nl"},
Usage: "Disable external file loading in expressions",
},
&cli.BoolFlag{
Name: "color",
Usage: "Force-enable color output",
Expand Down Expand Up @@ -102,6 +108,9 @@ func buildApp() *cli.App {
if c.Bool("nounicode") {
termunicode.UnicodeEnabled = false
}
if c.Bool("noload") {
stdlib.DisableLoad = true
}
return nil
})

Expand Down
8 changes: 8 additions & 0 deletions pkg/expressions/stdlib/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
ErrorEnum = "<ENUM>" // A given value is not contained within a set
ErrorArgName = "<NAME>" // A variable accessed by a given name does not exist
ErrorEmpty = "<EMPTY>" // A value was expected, but was empty
ErrorFile = "<FILE>"
)

// Compilation errors
Expand All @@ -33,6 +34,7 @@ var (
ErrConst = newFuncErr(ErrorConst, "expected const")
ErrEnum = newFuncErr(ErrorEnum, "unable to find value in set")
ErrEmpty = newFuncErr(ErrorEmpty, "invalid empty value")
ErrFile = newFuncErr(ErrorFile, "unable to read file")
)

var (
Expand All @@ -45,6 +47,12 @@ func stageError(err funcError) (KeyBuilderStage, error) {
}, err.err
}

func stageErrorf(err funcError, msg string) (KeyBuilderStage, error) {
return func(ctx KeyBuilderContext) string {
return err.expr
}, fmt.Errorf("%s, %w", msg, err.err)
}

func stageArgError(err funcError, argIndex int) (KeyBuilderStage, error) {
return func(ctx KeyBuilderContext) string {
return err.expr
Expand Down
6 changes: 6 additions & 0 deletions pkg/expressions/stdlib/funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var StandardFunctions = map[string]KeyBuilderFunction{

// Comparisons
"if": KeyBuilderFunction(kfIf),
"switch": kfSwitch,
"unless": KeyBuilderFunction(kfUnless),
"eq": stringComparator(func(a, b string) string {
if a == b {
Expand Down Expand Up @@ -94,6 +95,11 @@ var StandardFunctions = map[string]KeyBuilderFunction{
"dirname": kfPathDir,
"extname": kfPathExt,

// File operations
"load": kfLoadFile,
"lookup": kfLookupKey,
"haskey": kfHasKey,

// Formatting
"hi": KeyBuilderFunction(kfHumanizeInt),
"hf": KeyBuilderFunction(kfHumanizeFloat),
Expand Down
19 changes: 19 additions & 0 deletions pkg/expressions/stdlib/funcsComparators.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ func kfIf(args []KeyBuilderStage) (KeyBuilderStage, error) {
}), nil
}

// {switch ifTrue val ifTrue val ... [ifFalseVal]}
func kfSwitch(args []KeyBuilderStage) (KeyBuilderStage, error) {
if len(args) <= 1 {
return stageErrArgRange(args, "2+")
}

return func(context KeyBuilderContext) string {
for i := 0; i+1 < len(args); i += 2 {
if Truthy(args[i](context)) {
return args[i+1](context)
}
}
if len(args)%2 == 1 {
return args[len(args)-1](context)
}
return ""
}, nil
}

func kfUnless(args []KeyBuilderStage) (KeyBuilderStage, error) {
if len(args) != 2 {
return stageErrArgCount(args, 2)
Expand Down
9 changes: 9 additions & 0 deletions pkg/expressions/stdlib/funcsComparators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ func TestIfStatement(t *testing.T) {
testExpression(t, mockContext(), `{if {neq "" ""} true false}`, "false")
}

func TestSwitch(t *testing.T) {
testExpression(t, mockContext("a"), "{switch {eq {0} a} isa {eq {0} b} isb 1 null}", "isa")
testExpression(t, mockContext("b"), "{switch {eq {0} a} isa {eq {0} b} isb 1 null}", "isb")
testExpression(t, mockContext("c"), "{switch {eq {0} a} isa {eq {0} b} isb 1 null}", "null")
testExpression(t, mockContext("c"), "{switch {eq {0} a} isa {eq {0} b} isb null}", "null")
testExpression(t, mockContext("a"), "{switch {eq {0} a} isa {eq {0} b} isb 1}", "isa")
testExpressionErr(t, mockContext("a"), "{switch {eq {0} a}}", "<ARGN>", ErrArgCount)
}

func TestUnlessStatement(t *testing.T) {
testExpression(t, mockContext("abc"), `{unless {1} {0}} {unless abc efg} {unless "" bob}`, "abc bob")
testExpressionErr(t, mockContext("abc"), `{unless joe}`, "<ARGN>", ErrArgCount)
Expand Down
111 changes: 111 additions & 0 deletions pkg/expressions/stdlib/funcsLookups.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package stdlib

import (
"bufio"
"io"
"os"
"rare/pkg/expressions"
"strings"
)

var DisableLoad = false

// {load "filename"}
// loads static file as string
func kfLoadFile(args []expressions.KeyBuilderStage) (expressions.KeyBuilderStage, error) {
if DisableLoad {
return stageErrorf(ErrFile, "loading disabled")
}

if len(args) != 1 {
return stageErrArgCount(args, 1)
}

filename, ok := expressions.EvalStaticStage(args[0])
if !ok {
return stageError(ErrConst)
}

f, err := os.Open(filename)
if err != nil {
return stageErrorf(ErrFile, "Unable to open file: "+filename)
}
defer f.Close()

content, err := io.ReadAll(f)
if err != nil {
return stageErrorf(ErrFile, "Error reading file: "+filename)
}

sContent := string(content)

return func(context expressions.KeyBuilderContext) string {
return sContent
}, nil
}

func buildLookupTable(content string, commentPrefix string) map[string]string {
lookup := make(map[string]string)
scanner := bufio.NewScanner(strings.NewReader(content))

for scanner.Scan() {
line := scanner.Text()

if commentPrefix != "" && strings.HasPrefix(line, commentPrefix) {
continue
}

parts := strings.Fields(line)
switch len(parts) {
case 0: //noop
case 1:
lookup[parts[0]] = ""
case 2:
lookup[parts[0]] = parts[1]
}
}
return lookup
}

// {lookup key "table" [commentPrefix]}
func kfLookupKey(args []expressions.KeyBuilderStage) (expressions.KeyBuilderStage, error) {
if !isArgCountBetween(args, 2, 3) {
return stageErrArgRange(args, "2-3")
}

content, ok := expressions.EvalStaticStage(args[1])
if !ok {
return stageArgError(ErrConst, 1)
}

commentPrefix := expressions.EvalStageIndexOrDefault(args, 2, "")

lookup := buildLookupTable(content, commentPrefix)

return func(context expressions.KeyBuilderContext) string {
key := args[0](context)
return lookup[key]
}, nil
}

// {haskey key "table" [commentprefix]}
func kfHasKey(args []expressions.KeyBuilderStage) (expressions.KeyBuilderStage, error) {
if !isArgCountBetween(args, 2, 3) {
return stageErrArgRange(args, "2-3")
}

content, ok := expressions.EvalStaticStage(args[1])
if !ok {
return stageArgError(ErrConst, 1)
}

commentPrefix := expressions.EvalStageIndexOrDefault(args, 2, "")

lookup := buildLookupTable(content, commentPrefix)

return func(context expressions.KeyBuilderContext) string {
key := args[0](context)
_, has := lookup[key]
return expressions.TruthyStr(has)
}, nil
}
42 changes: 42 additions & 0 deletions pkg/expressions/stdlib/funcsLookups_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package stdlib

import (
"rare/pkg/testutil"
"testing"
)

func TestLoadFile(t *testing.T) {
testExpression(t, mockContext(), "{load ../../../cmd/testdata/graph.txt}", "bob 22\njack 93\njill 3\nmaria 19")
testExpressionErr(t, mockContext(), "{load {0}}", "<CONST>", ErrConst)
testExpressionErr(t, mockContext(), "{load a b}", "<ARGN>", ErrArgCount)
testExpressionErr(t, mockContext(), "{load notarealfile.txt}", "<FILE>", ErrFile)

testutil.SwitchGlobal(&DisableLoad, true)
defer testutil.RestoreGlobals()
testExpressionErr(t, mockContext(), "{load ../../../cmd/testdata/graph.txt}", "<FILE>", ErrFile)
}

func TestLookup(t *testing.T) {
testExpression(t, mockContext("bob"), "{lookup {0} {load ../../../cmd/testdata/lookup.txt}}", "33")
testExpression(t, mockContext("bobert"), "{lookup {0} {load ../../../cmd/testdata/lookup.txt}}", "")
testExpression(t, mockContext("#test"), "{lookup {0} {load ../../../cmd/testdata/lookup.txt}}", "22")
testExpression(t, mockContext("#test"), "{lookup {0} {load ../../../cmd/testdata/lookup.txt} \"#\"}", "")
testExpressionErr(t, mockContext(), "{lookup a}", "<ARGN>", ErrArgCount)
testExpressionErr(t, mockContext(), "{lookup a b c d}", "<ARGN>", ErrArgCount)
testExpressionErr(t, mockContext("fn"), "{lookup fn {0}}", "<CONST>", ErrConst)
}

func TestHasKey(t *testing.T) {
testExpression(t, mockContext("bob"), "{haskey {0} {load ../../../cmd/testdata/lookup.txt}}", "1")
testExpression(t, mockContext("nop"), "{haskey {0} {load ../../../cmd/testdata/lookup.txt}}", "")
testExpression(t, mockContext("key"), "{haskey {0} {load ../../../cmd/testdata/lookup.txt}}", "1")
testExpression(t, mockContext("#test"), "{haskey {0} {load ../../../cmd/testdata/lookup.txt}}", "1")
testExpression(t, mockContext("#test"), "{haskey {0} {load ../../../cmd/testdata/lookup.txt} #}", "")
testExpressionErr(t, mockContext(), "{haskey a}", "<ARGN>", ErrArgCount)
testExpressionErr(t, mockContext(), "{haskey a b c d}", "<ARGN>", ErrArgCount)
testExpressionErr(t, mockContext("fn"), "{haskey fn {0}}", "<CONST>", ErrConst)
}

func BenchmarkLoadFile(b *testing.B) {
benchmarkExpression(b, mockContext(), "{load ../../../cmd/testdata/graph.txt}", "bob 22\njack 93\njill 3\nmaria 19")
}
18 changes: 18 additions & 0 deletions pkg/expressions/stdlib/testutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,21 @@ func testExpressionErr(t *testing.T, context KeyBuilderContext, expression strin
}
}
}

// benchmark an expreession, as a sub-benchmark. Checks value before running test
func benchmarkExpression(b *testing.B, context KeyBuilderContext, expression, expected string) {
kb, err := NewStdKeyBuilderEx(false).Compile(expression)
if err != nil {
b.Fatal(err)
}

if s := kb.BuildKey(context); s != expected {
b.Fatalf("%s != %s", s, expected)
}

b.Run(expression, func(b *testing.B) {
for i := 0; i < b.N; i++ {
kb.BuildKey(context)
}
})
}
7 changes: 7 additions & 0 deletions pkg/expressions/truthy.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ const FalsyVal = ""
func Truthy(s string) bool {
return strings.TrimSpace(s) != FalsyVal
}

func TruthyStr(is bool) string {
if is {
return TruthyVal
}
return FalsyVal
}
18 changes: 18 additions & 0 deletions pkg/expressions/truthy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package expressions

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestTruthy(t *testing.T) {
assert.True(t, Truthy(TruthyVal))
assert.False(t, Truthy(FalsyVal))
assert.False(t, Truthy(" "))
}

func TestTruthyStr(t *testing.T) {
assert.Equal(t, TruthyVal, TruthyStr(true))
assert.Equal(t, FalsyVal, TruthyStr(false))
}

0 comments on commit 0192d22

Please sign in to comment.