Skip to content

Commit

Permalink
fix: fixes issue #597 - improves performance (500x faster than previo…
Browse files Browse the repository at this point in the history
…us) (#605)
  • Loading branch information
a-h committed Mar 15, 2024
1 parent ed05347 commit ee4d1dd
Show file tree
Hide file tree
Showing 8 changed files with 442 additions and 46 deletions.
2 changes: 1 addition & 1 deletion .version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.610
0.2.611
2 changes: 2 additions & 0 deletions parser/v2/goexpression/fuzz.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ echo Case
go test -fuzz=FuzzCaseStandard -fuzztime=120s
echo Default
go test -fuzz=FuzzCaseDefault -fuzztime=120s
echo TemplExpression
go test -fuzz=FuzzTemplExpression -fuzztime=120s
echo Expression
go test -fuzz=FuzzExpression -fuzztime=120s
echo SliceArgs
Expand Down
120 changes: 78 additions & 42 deletions parser/v2/goexpression/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package goexpression

import (
"errors"
"fmt"
"go/ast"
"go/parser"
"go/scanner"
"go/token"
"regexp"
"slices"
"strings"
"unicode"
)
Expand Down Expand Up @@ -104,56 +105,91 @@ func Switch(content string) (start, end int, err error) {
})
}

var boundaryTokens = map[rune]any{
')': nil,
'}': nil,
']': nil,
' ': nil,
'\t': nil,
'\n': nil,
'@': nil,
'<': nil,
'+': nil,
'.': nil,
}
func TemplExpression(src string) (start, end int, err error) {
var s scanner.Scanner
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(src))
errorHandler := func(pos token.Position, msg string) {
err = fmt.Errorf("error parsing expression: %v", msg)
}
s.Init(file, []byte(src), errorHandler, 0)

func allBoundaries(content string) (boundaries []int) {
for i, r := range content {
if _, ok := boundaryTokens[r]; ok {
boundaries = append(boundaries, i)
boundaries = append(boundaries, i+1)
// Read chains of identifiers, e.g.:
// components.Variable
// components[0].Variable
// components["name"].Function()
// functionCall(withLots(), func() { return true })
ep := NewExpressionParser()
for {
pos, tok, lit := s.Scan()
stop, err := ep.Insert(pos, tok, lit)
if err != nil {
return 0, 0, err
}
if stop {
break
}
}
boundaries = append(boundaries, len(content))
return
return 0, ep.End, nil
}

func Expression(content string) (start, end int, err error) {
var candidates []int
for _, to := range allBoundaries(content) {
expr, err := parser.ParseExpr(content[:to])
if err != nil {
continue
}
switch expr := expr.(type) {
case *ast.CallExpr:
end = int(expr.Rparen)
default:
end = int(expr.End()) - 1
func Expression(src string) (start, end int, err error) {
var s scanner.Scanner
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(src))
errorHandler := func(pos token.Position, msg string) {
err = fmt.Errorf("error parsing expression: %v", msg)
}
s.Init(file, []byte(src), errorHandler, 0)

// Read chains of identifiers and constants up until RBRACE, e.g.:
// true
// 123.45 == true
// components.Variable
// components[0].Variable
// components["name"].Function()
// functionCall(withLots(), func() { return true })
// !true
parenDepth := 0
bracketDepth := 0
braceDepth := 0
loop:
for {
pos, tok, lit := s.Scan()
if tok == token.EOF {
break loop
}
// If the expression ends with `...` then it's a child spread expression.
if end < len(content) {
if strings.HasPrefix(content[end:], "...") {
end += len("...")
switch tok {
case token.LPAREN: // (
parenDepth++
case token.RPAREN: // )
end = int(pos)
parenDepth--
case token.LBRACK: // [
bracketDepth++
case token.RBRACK: // ]
end = int(pos)
bracketDepth--
case token.LBRACE: // {
braceDepth++
case token.RBRACE: // }
braceDepth--
if braceDepth < 0 {
// We've hit the end of the expression.
break loop
}
end = int(pos)
case token.IDENT, token.INT, token.FLOAT, token.IMAG, token.CHAR, token.STRING:
end = int(pos) + len(lit) - 1
case token.SEMICOLON:
continue
case token.ILLEGAL:
return 0, 0, fmt.Errorf("illegal token: %v", lit)
default:
end = int(pos) + len(tok.String()) - 1
}
candidates = append(candidates, end)
}
if len(candidates) == 0 {
return 0, 0, ErrExpectedNodeNotFound
}
slices.Sort(candidates)
return 0, candidates[len(candidates)-1], nil
return start, end, nil
}

func SliceArgs(content string) (expr string, err error) {
Expand Down
106 changes: 104 additions & 2 deletions parser/v2/goexpression/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,22 @@ func FuzzCaseDefault(f *testing.F) {
}

var expressionTests = []testInput{
{
name: "string literal",
input: `"hello"`,
},
{
name: "string literal with escape",
input: `"hello\n"`,
},
{
name: "backtick string literal",
input: "`hello`",
},
{
name: "backtick string literal containing double quote",
input: "`hello" + `"` + `world` + "`",
},
{
name: "function call in package",
input: `components.Other()`,
Expand Down Expand Up @@ -347,8 +363,6 @@ func TestExpression(t *testing.T) {
"}",
"\t}",
" }",
"</div>",
"<p>/</p>",
}
for _, test := range expressionTests {
for i, suffix := range suffixes {
Expand All @@ -357,6 +371,94 @@ func TestExpression(t *testing.T) {
}
}

var templExpressionTests = []testInput{
{
name: "function call in package",
input: `components.Other()`,
},
{
name: "slice index call",
input: `components[0].Other()`,
},
{
name: "map index function call",
input: `components["name"].Other()`,
},
{
name: "map index function call backtick literal",
input: "components[`name" + `"` + "`].Other()",
},
{
name: "function literal",
input: `components["name"].Other(func() bool { return true })`,
},
{
name: "multiline function call",
input: `component(map[string]string{
"namea": "name_a",
"nameb": "name_b",
})`,
},
{
name: "call with braces and brackets",
input: `templates.New(test{}, other())`,
},
{
name: "struct method call",
input: `typeName{}.Method()`,
},
{
name: "struct method call in other package",
input: "layout.DefaultLayout{}.Compile()",
},
{
name: "bare variable",
input: `component`,
},
}

func TestTemplExpression(t *testing.T) {
prefix := ""
suffixes := []string{
"",
"}",
"\t}",
" }",
"</div>",
"<p>/</p>",
" just some text",
" { <div>Child content</div> }",
}
for _, test := range templExpressionTests {
for i, suffix := range suffixes {
t.Run(fmt.Sprintf("%s_%d", test.name, i), run(test, prefix, suffix, TemplExpression))
}
}
}

func FuzzTemplExpression(f *testing.F) {
suffixes := []string{
"",
" }",
" }}</a>\n}",
"...",
}
for _, test := range expressionTests {
for _, suffix := range suffixes {
f.Add(test.input + suffix)
}
}
f.Fuzz(func(t *testing.T, s string) {
src := "switch " + s
start, end, err := TemplExpression(src)
if err != nil {
t.Skip()
return
}
panicIfInvalid(src, start, end)
})
}

func FuzzExpression(f *testing.F) {
suffixes := []string{
"",
Expand Down
105 changes: 105 additions & 0 deletions parser/v2/goexpression/parsebench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package goexpression

import "testing"

var testStringExpression = `"this string expression" }
<div>
But afterwards, it keeps searching.
<div>
<div>
But that's not right, we can stop searching. It won't find anything valid.
</div>
<div>
Certainly not later in the file.
</div>
<div>
It's going to try all the tokens.
)}]@<+.
</div>
<div>
It's going to try all the tokens.
)}]@<+.
</div>
<div>
It's going to try all the tokens.
)}]@<+.
</div>
<div>
It's going to try all the tokens.
)}]@<+.
</div>
`

func BenchmarkExpression(b *testing.B) {
// Baseline...
// BenchmarkExpression-10 6484 184862 ns/op
// Updated...
// BenchmarkExpression-10 3942538 279.6 ns/op
for n := 0; n < b.N; n++ {
start, end, err := Expression(testStringExpression)
if err != nil {
b.Fatal(err)
}
if start != 0 || end != 24 {
b.Fatalf("expected 0, 24, got %d, %d", start, end)
}
}
}

var testTemplExpression = `templates.CallMethod(map[string]any{
"name": "this string expression",
})
<div>
But afterwards, it keeps searching.
<div>
<div>
But that's not right, we can stop searching. It won't find anything valid.
</div>
<div>
Certainly not later in the file.
</div>
<div>
It's going to try all the tokens.
)}]@<+.
</div>
<div>
It's going to try all the tokens.
)}]@<+.
</div>
<div>
It's going to try all the tokens.
)}]@<+.
</div>
<div>
It's going to try all the tokens.
)}]@<+.
</div>
`

func BenchmarkTemplExpression(b *testing.B) {
// BenchmarkTemplExpression-10 2694 431934 ns/op
// Updated...
// BenchmarkTemplExpression-10 1339399 897.6 ns/op
for n := 0; n < b.N; n++ {
start, end, err := TemplExpression(testTemplExpression)
if err != nil {
b.Fatal(err)
}
if start != 0 || end != 74 {
b.Fatalf("expected 0, 74, got %d, %d", start, end)
}
}
}
Loading

0 comments on commit ee4d1dd

Please sign in to comment.