Browse Source

feat: String interpolation

Matthias Ladkau 3 years ago
parent
commit
a1ba9b4d93

+ 7 - 15
ecal.md

@@ -147,34 +147,26 @@ Strings can be normal quoted stings which interpret backslash escape characters:
 \uhhhh → a Unicode character whose codepoint can be expressed in 4 hexadecimal digits. (pad 0 in front)
 ```
 
-Normal quoted strings also interpret inline expressions escaped with `{}`:
+Normal quoted strings also interpret inline expressions and statements escaped with `{{}}`:
 ```
-"Foo bar {1+2}"
+"Foo bar {{1+2}}"
 ```
-Inline expression may also specify number formatting:
-```
-"Foo bar {1+2}.2f"
-```
-Formatting|Description
--|-
-{}.f|With decimal point full precision
-{}.3f|Decimal point with precision 3
-{}.5w3f|5 Width with decimal point with precision 3
-{}.e|Scientific notation
 
 Strings can also be expressed in raw form which will not interpret any escape characters.
 ```
-r"Foo bar {1+2}"
+r"Foo bar {{1+2}}"
 ```
 
+Some examples:
+
 Expression|Value
 -|-
 `"foo'bar"`| `foo'bar`
 `'foo"bar'`| `foo"bar`
 `'foo\u0028bar'`| `foo(bar`
 `"foo\u0028bar"`| `foo(bar`
-`"Foo bar {1+2}"`| `Foo bar 3`
-`r"Foo bar {1+2}"`| `Foo bar {1+2}`
+`"Foo bar {{1+2}}"`| `Foo bar 3`
+`r"Foo bar {{1+2}}"`| `Foo bar {{1+2}}`
 
 Variable Assignments
 --

+ 4 - 4
interpreter/func_provider.go

@@ -514,9 +514,9 @@ func (rf *raise) Run(instanceID string, vs parser.Scope, is map[string]interface
 	node := is["astnode"].(*parser.ASTNode)
 
 	return nil, &util.RuntimeErrorWithDetail{
-		erp.NewRuntimeError(err, detailMsg, node).(*util.RuntimeError),
-		vs,
-		detail,
+		RuntimeError: erp.NewRuntimeError(err, detailMsg, node).(*util.RuntimeError),
+		Environment:  vs,
+		Data:         detail,
 	}
 
 }
@@ -586,7 +586,7 @@ func (rf *addevent) addEvent(addFunc func(engine.Processor, *engine.Event, *engi
 			)
 
 			if len(args) > 3 {
-				var scopeMap = map[interface{}]interface{}{}
+				var scopeMap map[interface{}]interface{}
 
 				// Add optional scope - if not specified it is { "": true }
 

+ 0 - 4
interpreter/provider.go

@@ -8,10 +8,6 @@
  * file, You can obtain one at https://opensource.org/licenses/MIT.
  */
 
-// TODO:
-// Context supporting final and exception handling
-// Inline escaping in strings "bla {1+1} bla"
-
 package interpreter
 
 import (

+ 3 - 3
interpreter/rt_sink.go

@@ -212,9 +212,9 @@ func (rt *sinkRuntime) Eval(vs parser.Scope, is map[string]interface{}) (interfa
 								// Provide additional information for unexpected errors
 
 								err = &util.RuntimeErrorWithDetail{
-									err.(*util.RuntimeError),
-									sinkVS,
-									nil,
+									RuntimeError: err.(*util.RuntimeError),
+									Environment:  sinkVS,
+									Data:         nil,
 								}
 							}
 						}

+ 3 - 2
interpreter/rt_statements.go

@@ -41,17 +41,18 @@ func statementsRuntimeInst(erp *ECALRuntimeProvider, node *parser.ASTNode) parse
 Eval evaluate this runtime component.
 */
 func (rt *statementsRuntime) Eval(vs parser.Scope, is map[string]interface{}) (interface{}, error) {
+	var res interface{}
 	_, err := rt.baseRuntime.Eval(vs, is)
 
 	if err == nil {
 		for _, child := range rt.node.Children {
-			if _, err := child.Runtime.Eval(vs, is); err != nil {
+			if res, err = child.Runtime.Eval(vs, is); err != nil {
 				return nil, err
 			}
 		}
 	}
 
-	return nil, err
+	return res, err
 }
 
 // Condition statement

+ 57 - 2
interpreter/rt_value.go

@@ -11,9 +11,12 @@
 package interpreter
 
 import (
+	"fmt"
 	"strconv"
+	"strings"
 
 	"devt.de/krotik/ecal/parser"
+	"devt.de/krotik/ecal/scope"
 )
 
 /*
@@ -73,9 +76,61 @@ Eval evaluate this runtime component.
 func (rt *stringValueRuntime) Eval(vs parser.Scope, is map[string]interface{}) (interface{}, error) {
 	_, err := rt.baseRuntime.Eval(vs, is)
 
-	// Do some string interpolation
+	ret := rt.node.Token.Val
 
-	return rt.node.Token.Val, err
+	if rt.node.Token.AllowEscapes {
+
+		// Do allow string interpolation if escape sequences are allowed
+
+		for {
+			var replace string
+
+			code, ok := rt.GetInfix(ret, "{{", "}}")
+			if !ok {
+				break
+			}
+
+			ast, ierr := parser.ParseWithRuntime(
+				fmt.Sprintf("String interpolation: %v", code), code, rt.erp)
+
+			if ierr == nil {
+
+				if ierr = ast.Runtime.Validate(); ierr == nil {
+					var res interface{}
+
+					res, ierr = ast.Runtime.Eval(
+						vs.NewChild(scope.NameFromASTNode(rt.node)),
+						make(map[string]interface{}))
+
+					if ierr == nil {
+						replace = fmt.Sprint(res)
+					}
+				}
+			}
+
+			if ierr != nil {
+				replace = fmt.Sprintf("#%v", ierr.Error())
+			}
+
+			ret = strings.Replace(ret, fmt.Sprintf("{{%v}}", code), replace, 1)
+		}
+
+	}
+
+	return ret, err
+}
+
+func (rt *stringValueRuntime) GetInfix(str string, start string, end string) (string, bool) {
+	res := str
+
+	if s := strings.Index(str, start); s >= 0 {
+		s += len(start)
+		if e := strings.Index(str, end); e >= 0 {
+			res = str[s:e]
+		}
+	}
+
+	return res, res != str
 }
 
 /*

+ 67 - 0
interpreter/rt_value_test.go

@@ -41,6 +41,73 @@ string: 'test'
 	}
 }
 
+func TestStringInterpolation(t *testing.T) {
+
+	res, err := UnitTestEvalAndAST(
+		`"{{'foo'}}test{{'foo'}}test"`, nil,
+		`
+string: '{{'foo'}}test{{'foo'}}test'
+`[1:])
+
+	if err != nil || res != "footestfootest" {
+		t.Error("Unexpected result: ", res, err)
+		return
+	}
+
+	res, err = UnitTestEvalAndAST(
+		`"{{foo'}}test{{'foo'}}test"`, nil,
+		`
+string: '{{foo'}}test{{'foo'}}test'
+`[1:])
+
+	if err != nil || res != "#Parse error in String interpolation: foo': Lexical "+
+		"error (Cannot parse identifier 'foo''. Identifies may only contain [a-zA-Z] "+
+		"and [a-zA-Z0-9] from the second character) (Line:1 Pos:1)testfootest" {
+		t.Error("Unexpected result: ", res, err)
+		return
+	}
+
+	res, err = UnitTestEval(
+		`"Foo bar {{1+2}}"`, nil)
+
+	if err != nil || res != "Foo bar 3" {
+		t.Error("Unexpected result: ", res, err)
+		return
+	}
+
+	res, err = UnitTestEval(
+		`r"Foo bar {{1+2}}"`, nil)
+
+	if err != nil || res != "Foo bar {{1+2}}" {
+		t.Error("Unexpected result: ", res, err)
+		return
+	}
+
+	res, err = UnitTestEval(
+		`b:=1;"test{{a:=1;concat([1,2,3], [4,5], [a,b])}}test"`, nil)
+
+	if err != nil || res != "test[1 2 3 4 5 1 1]test" {
+		t.Error("Unexpected result: ", res, err)
+		return
+	}
+
+	res, err = UnitTestEval(
+		`b:="foo";"test{{if b { 1 } else { 2 } }}test"`, nil)
+
+	if err != nil || res != "test1test" {
+		t.Error("Unexpected result: ", res, err)
+		return
+	}
+
+	res, err = UnitTestEval(
+		`b:=null;"test{{if b { 1 } else { 2 } }}test"`, nil)
+
+	if err != nil || res != "test2test" {
+		t.Error("Unexpected result: ", res, err)
+		return
+	}
+}
+
 func TestCompositionValues(t *testing.T) {
 
 	res, err := UnitTestEvalAndAST(

+ 13 - 6
parser/helper.go

@@ -259,6 +259,7 @@ func (n *ASTNode) ToJSONObject() map[string]interface{} {
 			ret["value"] = n.Token.Val
 		}
 		ret["identifier"] = n.Token.Identifier
+		ret["allowescapes"] = n.Token.AllowEscapes
 		ret["pos"] = n.Token.Pos
 		ret["line"] = n.Token.Lline
 		ret["linepos"] = n.Token.Lpos
@@ -310,6 +311,11 @@ func ASTFromJSONObject(jsonAST map[string]interface{}) (*ASTNode, error) {
 		identifier = false
 	}
 
+	allowescapes, ok := jsonAST["allowescapes"]
+	if !ok {
+		allowescapes = false
+	}
+
 	if posString, ok := jsonAST["pos"]; ok {
 		pos, _ = strconv.Atoi(fmt.Sprint(posString))
 	} else {
@@ -380,12 +386,13 @@ func ASTFromJSONObject(jsonAST map[string]interface{}) (*ASTNode, error) {
 	}
 
 	token := &LexToken{
-		nodeID,             // ID
-		pos,                // Pos
-		fmt.Sprint(value),  // Val
-		identifier == true, // Identifier
-		line,               // Lline
-		linepos,            // Lpos
+		nodeID,               // ID
+		pos,                  // Pos
+		fmt.Sprint(value),    // Val
+		identifier == true,   // Identifier
+		allowescapes == true, // AllowEscapes
+		line,                 // Lline
+		linepos,              // Lpos
 	}
 
 	return &ASTNode{fmt.Sprint(name), token, astMeta, astChildren, nil, 0, nil, nil}, nil

+ 6 - 0
parser/helper_test.go

@@ -39,6 +39,7 @@ Lpos is different 3 vs 2
   "Pos": 2,
   "Val": "1",
   "Identifier": false,
+  "AllowEscapes": false,
   "Lline": 1,
   "Lpos": 3
 }
@@ -48,6 +49,7 @@ vs
   "Pos": 1,
   "Val": "2",
   "Identifier": false,
+  "AllowEscapes": false,
   "Lline": 1,
   "Lpos": 2
 }
@@ -85,6 +87,7 @@ Identifier is different false vs true
   "Pos": 1,
   "Val": "1",
   "Identifier": false,
+  "AllowEscapes": false,
   "Lline": 1,
   "Lpos": 2
 }
@@ -94,6 +97,7 @@ vs
   "Pos": 1,
   "Val": "a",
   "Identifier": true,
+  "AllowEscapes": false,
   "Lline": 1,
   "Lpos": 2
 }
@@ -207,6 +211,7 @@ Lpos is different 1 vs 10
   "Pos": 0,
   "Val": "1",
   "Identifier": false,
+  "AllowEscapes": false,
   "Lline": 1,
   "Lpos": 1
 }
@@ -216,6 +221,7 @@ vs
   "Pos": 9,
   "Val": "1",
   "Identifier": false,
+  "AllowEscapes": false,
   "Lline": 1,
   "Lpos": 10
 }

+ 20 - 17
parser/lexer.go

@@ -28,12 +28,13 @@ var numberPattern = regexp.MustCompile("^[0-9].*$")
 LexToken represents a token which is returned by the lexer.
 */
 type LexToken struct {
-	ID         LexTokenID // Token kind
-	Pos        int        // Starting position (in bytes)
-	Val        string     // Token value
-	Identifier bool       // Flag if the value is an identifier (not quoted and not a number)
-	Lline      int        // Line in the input this token appears
-	Lpos       int        // Position in the input line this token appears
+	ID           LexTokenID // Token kind
+	Pos          int        // Starting position (in bytes)
+	Val          string     // Token value
+	Identifier   bool       // Flag if the value is an identifier (not quoted and not a number)
+	AllowEscapes bool       // Flag if the value did interpret escape charaters
+	Lline        int        // Line in the input this token appears
+	Lpos         int        // Position in the input line this token appears
 }
 
 /*
@@ -45,6 +46,7 @@ func NewLexTokenInstance(t LexToken) *LexToken {
 		t.Pos,
 		t.Val,
 		t.Identifier,
+		t.AllowEscapes,
 		t.Lline,
 		t.Lpos,
 	}
@@ -404,12 +406,12 @@ emitToken passes a token back to the client.
 */
 func (l *lexer) emitToken(t LexTokenID) {
 	if t == TokenEOF {
-		l.emitTokenAndValue(t, "", false)
+		l.emitTokenAndValue(t, "", false, false)
 		return
 	}
 
 	if l.tokens != nil {
-		l.tokens <- LexToken{t, l.start, l.input[l.start:l.pos], false,
+		l.tokens <- LexToken{t, l.start, l.input[l.start:l.pos], false, false,
 			l.line + 1, l.start - l.lastnl + 1}
 	}
 }
@@ -417,9 +419,9 @@ func (l *lexer) emitToken(t LexTokenID) {
 /*
 emitTokenAndValue passes a token with a given value back to the client.
 */
-func (l *lexer) emitTokenAndValue(t LexTokenID, val string, identifier bool) {
+func (l *lexer) emitTokenAndValue(t LexTokenID, val string, identifier bool, allowEscapes bool) {
 	if l.tokens != nil {
-		l.tokens <- LexToken{t, l.start, val, identifier, l.line + 1, l.start - l.lastnl + 1}
+		l.tokens <- LexToken{t, l.start, val, identifier, allowEscapes, l.line + 1, l.start - l.lastnl + 1}
 	}
 }
 
@@ -428,7 +430,7 @@ emitError passes an error token back to the client.
 */
 func (l *lexer) emitError(msg string) {
 	if l.tokens != nil {
-		l.tokens <- LexToken{TokenError, l.start, msg, false, l.line + 1, l.start - l.lastnl + 1}
+		l.tokens <- LexToken{TokenError, l.start, msg, false, false, l.line + 1, l.start - l.lastnl + 1}
 	}
 }
 
@@ -580,7 +582,7 @@ func lexToken(l *lexer) lexFunc {
 		_, err := strconv.ParseFloat(keywordCandidate, 64)
 
 		if err == nil {
-			l.emitTokenAndValue(TokenNUMBER, keywordCandidate, false)
+			l.emitTokenAndValue(TokenNUMBER, keywordCandidate, false, false)
 			return lexToken
 		}
 	}
@@ -618,7 +620,7 @@ func lexToken(l *lexer) lexFunc {
 
 		// An identifier was found
 
-		l.emitTokenAndValue(TokenIDENTIFIER, identifierCandidate, true)
+		l.emitTokenAndValue(TokenIDENTIFIER, identifierCandidate, true, false)
 	}
 
 	return lexToken
@@ -693,10 +695,11 @@ func lexValue(l *lexer) lexFunc {
 			return nil
 		}
 
-		l.emitTokenAndValue(TokenSTRING, s, true)
+		l.emitTokenAndValue(TokenSTRING, s, true, true)
 
 	} else {
-		l.emitTokenAndValue(TokenSTRING, l.input[l.start+2:l.pos-1], true)
+
+		l.emitTokenAndValue(TokenSTRING, l.input[l.start+2:l.pos-1], true, false)
 	}
 
 	//  Set newline
@@ -724,7 +727,7 @@ func lexComment(l *lexer) lexFunc {
 			r = l.next(0)
 		}
 
-		l.emitTokenAndValue(TokenPOSTCOMMENT, l.input[l.start:l.pos], false)
+		l.emitTokenAndValue(TokenPOSTCOMMENT, l.input[l.start:l.pos], false, false)
 
 		if r == RuneEOF {
 			return nil
@@ -757,7 +760,7 @@ func lexComment(l *lexer) lexFunc {
 			}
 		}
 
-		l.emitTokenAndValue(TokenPRECOMMENT, l.input[l.start:l.pos-1], false)
+		l.emitTokenAndValue(TokenPRECOMMENT, l.input[l.start:l.pos-1], false, false)
 
 		// Consume final /
 

+ 16 - 2
parser/lexer_test.go

@@ -81,6 +81,7 @@ Lpos is different 1 vs 2
   "Pos": 0,
   "Val": "not",
   "Identifier": false,
+  "AllowEscapes": false,
   "Lline": 1,
   "Lpos": 1
 }
@@ -90,6 +91,7 @@ vs
   "Pos": 5,
   "Val": "test",
   "Identifier": true,
+  "AllowEscapes": false,
   "Lline": 2,
   "Lpos": 2
 }` {
@@ -237,18 +239,30 @@ func TestStringLexing(t *testing.T) {
 
 	input = `name r"te
 	st"  'bla'`
-	if res := LexToList("mytest", input); fmt.Sprint(res) != `["name" "te\n\tst" "bla" EOF]` {
+	res := LexToList("mytest", input)
+	if fmt.Sprint(res) != `["name" "te\n\tst" "bla" EOF]` {
 		t.Error("Unexpected lexer result:", res)
 		return
 	}
 
+	if res[1].AllowEscapes {
+		t.Error("String value should not allow escapes")
+		return
+	}
+
 	// Parsing with escape sequences
 
 	input = `"test\n\ttest"  '\nfoo\u0028bar' "test{foo}.5w3f"`
-	if res := LexToList("mytest", input); fmt.Sprint(res) != `["test\n\ttest" "\nfoo(bar" "test{foo}.5w3f" EOF]` {
+	res = LexToList("mytest", input)
+	if fmt.Sprint(res) != `["test\n\ttest" "\nfoo(bar" "test{foo}.5w3f" EOF]` {
 		t.Error("Unexpected lexer result:", res)
 		return
 	}
+
+	if !res[0].AllowEscapes {
+		t.Error("String value should allow escapes")
+		return
+	}
 }
 
 func TestCommentLexing(t *testing.T) {