diff --git a/ucl/ast.go b/ucl/ast.go index 2e0a493..2b58025 100644 --- a/ucl/ast.go +++ b/ucl/ast.go @@ -8,9 +8,30 @@ import ( "github.com/alecthomas/participle/v2/lexer" ) +type astStringStringSpan struct { + Pos lexer.Position + Chars *string `parser:"@SingleChar"` +} + +type astDoubleStringSpan struct { + Pos lexer.Position + Chars *string `parser:"@Char"` + Escaped *string `parser:"| @Escaped"` + IdentRef *string `parser:"| @IdentRef"` +} + +type astDoubleString struct { + Spans []astDoubleStringSpan `parser:"StringStart @@* StringEnd"` +} + +type astSingleString struct { + Spans []astStringStringSpan `parser:"SingleStringStart @@* SingleStringEnd"` +} + type astLiteral struct { - Str *string `parser:"@String"` - Int *int `parser:"| @Int"` + StrInter *astDoubleString `parser:"@@"` + SingleStrInter *astSingleString `parser:"| @@"` + Int *int `parser:"| @Int"` } type astIdentNames struct { @@ -91,7 +112,8 @@ var scanner = lexer.MustStateful(lexer.Rules{ "Root": { {"Whitespace", `[ \t]+`, nil}, {"Comment", `[#].*`, nil}, - {"String", `"(\\"|[^"])*"`, nil}, + {"StringStart", `"`, lexer.Push("String")}, + {"SingleStringStart", `'`, lexer.Push("SingleString")}, {"Int", `[-]?[0-9][0-9]*`, nil}, {"DOLLAR", `\$`, nil}, {"COLON", `\:`, nil}, @@ -106,6 +128,16 @@ var scanner = lexer.MustStateful(lexer.Rules{ {"PIPE", `\|`, nil}, {"Ident", `[-]*[a-zA-Z_][\w-]*`, nil}, }, + "String": { + {"Escaped", `\\.`, nil}, + {"StringEnd", `"`, lexer.Pop()}, + {"IdentRef", `\$[-]*[a-zA-Z_][\w-]*`, nil}, + {"Char", `[^$"\\]+`, nil}, + }, + "SingleString": { + {"SingleStringEnd", `'`, lexer.Pop()}, + {"SingleChar", `[^']+`, nil}, + }, }) var parser = participle.MustBuild[astScript](participle.Lexer(scanner), participle.Elide("Whitespace", "Comment")) diff --git a/ucl/eval.go b/ucl/eval.go index 2d89ccf..1bb9a80 100644 --- a/ucl/eval.go +++ b/ucl/eval.go @@ -3,7 +3,7 @@ package ucl import ( "context" "errors" - "strconv" + "strings" ) type evaluator struct { @@ -245,18 +245,64 @@ func (e evaluator) evalListOrHash(ctx context.Context, ec *evalCtx, loh *astList func (e evaluator) evalLiteral(ctx context.Context, ec *evalCtx, n *astLiteral) (Object, error) { switch { - case n.Str != nil: - uq, err := strconv.Unquote(*n.Str) + case n.StrInter != nil: + sval, err := e.interpolateDoubleQuotedString(ctx, ec, n.StrInter) if err != nil { return nil, err } - return StringObject(uq), nil + return sval, nil + case n.SingleStrInter != nil: + sval, err := e.interpolateSingleQuotedString(ctx, ec, n.SingleStrInter) + if err != nil { + return nil, err + } + return sval, nil case n.Int != nil: return intObject(*n.Int), nil } return nil, errors.New("unhandled literal type") } +func (e evaluator) interpolateSingleQuotedString(ctx context.Context, ec *evalCtx, s *astSingleString) (Object, error) { + var sb strings.Builder + for _, n := range s.Spans { + switch { + case n.Chars != nil: + sb.WriteString(*n.Chars) + } + } + return StringObject(sb.String()), nil +} + +func (e evaluator) interpolateDoubleQuotedString(ctx context.Context, ec *evalCtx, s *astDoubleString) (Object, error) { + var sb strings.Builder + for _, n := range s.Spans { + switch { + case n.Chars != nil: + sb.WriteString(*n.Chars) + case n.Escaped != nil: + switch (*n.Escaped)[1:] { + case "\\": + sb.WriteByte('\\') + case "n": + sb.WriteByte('\n') + case "t": + sb.WriteByte('\t') + case "$": + sb.WriteByte('$') + default: + return nil, errors.New("unrecognised escaped pattern: \\" + *n.Escaped) + } + case n.IdentRef != nil: + identVal := (*n.IdentRef)[1:] + if v, ok := ec.getVar(identVal); ok && v != nil { + sb.WriteString(v.String()) + } + } + } + return StringObject(sb.String()), nil +} + func (e evaluator) evalSub(ctx context.Context, ec *evalCtx, n *astPipeline) (Object, error) { pipelineRes, err := e.evalPipeline(ctx, ec, n) if err != nil { diff --git a/ucl/testbuiltins_test.go b/ucl/testbuiltins_test.go index c3b08f8..0b7aaef 100644 --- a/ucl/testbuiltins_test.go +++ b/ucl/testbuiltins_test.go @@ -111,6 +111,18 @@ func TestBuiltins_Echo(t *testing.T) { echo "world" # command after this ; `, want: "Hello\nworld\n"}, + {desc: "interpolated string 1", expr: `set what "world" ; echo "Hello, $what"`, want: "Hello, world\n"}, + {desc: "interpolated string 2", expr: `set what "world" ; echo "Hello, \$what"`, want: "Hello, $what\n"}, + {desc: "interpolated string 3", expr: `echo "separate\nlines\n\tand tabs"`, want: "separate\nlines\n\tand tabs\n"}, + {desc: "interpolated string 4", expr: `set what "Hello" ; set where "world" ; echo "$what, $where"`, want: "Hello, world\n"}, + {desc: "interpolated string 5", expr: ` + foreach [123 "foo" true ()] { |x| + echo "[[$x]]" + } + `, want: "[[123]]\n[[foo]]\n[[true]]\n[[]]\n"}, + {desc: "single quote string 1", expr: `echo 'Hello, world'`, want: "Hello, world\n"}, + {desc: "single quote string 2", expr: `echo 'No $vars here'`, want: "No $vars here\n"}, + {desc: "single quote string 3", expr: `echo 'No \escape \nhere'`, want: "No \\escape \\nhere\n"}, } for _, tt := range tests {