Skip to content

Commit

Permalink
Merge pull request #5 from breml/quote-string
Browse files Browse the repository at this point in the history
Add Quote for Logstash strings
  • Loading branch information
breml authored Apr 17, 2021
2 parents c548fbb + 1e7188e commit 5c79312
Show file tree
Hide file tree
Showing 3 changed files with 356 additions and 0 deletions.
91 changes: 91 additions & 0 deletions ast/astutil/quote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package astutil

import (
"bytes"
"errors"
"regexp"

"github.com/breml/logstash-config/ast"
)

var barewordRe = regexp.MustCompile("(?s:^[A-Za-z_][A-Za-z0-9_]+$)")

// Quote returns a a string with quotes for Logstash. Supported quote types
// are ast.DoubleQuoted, ast.SingleQuoted and ast.Bareword.
// If escape is false and the result is not a valid quoted value, an error
// is returned. If escape is true, the value will be escaped such, that the
// returned value is a valid quoted Logstash string.
// For ast.DoubleQuoted, all double quotes (`"`) are escaped to `\"`.
// For ast.SingleQuoted, all single quotes (`'`) are escaped to `\'`.
// For ast.Bareword, all characters not matching "[A-Za-z_][A-Za-z0-9_]+" are
// replaced with `_`.
func Quote(value string, quoteType ast.StringAttributeType, escape bool) (string, error) {
var hasDoubleQuote bool
var hasSingleQuote bool

for i, chr := range value {
if chr == '"' && i > 1 && value[i-1] != '\\' {
hasDoubleQuote = true
}
if chr == '\'' && i > 1 && value[i-1] != '\\' {
hasSingleQuote = true
}
}

switch quoteType {
case ast.DoubleQuoted:
if hasDoubleQuote && !escape {
return "", errors.New("value %q contains unescaped double quotes and can not be quoted with double quotes without escaping")
}
return `"` + escapeQuotes(value, '"') + `"`, nil
case ast.SingleQuoted:
if hasSingleQuote && !escape {
return "", errors.New("value %q contains unescaped single quotes and can not be quoted with double quotes without escaping")
}
return `'` + escapeQuotes(value, '\'') + `'`, nil
case ast.Bareword:
if !barewordRe.MatchString(value) && !escape {
return "", errors.New("value %q contains non bareword characters and can not be quoted as bareword without escaping")
}
return escapeBareword(value), nil
default:
panic("quote type not supported")
}
}

func escapeQuotes(value string, quote byte) string {
b := []byte(value)

for i := 0; i < len(b); i++ {
if b[i] == quote && (i == 0 || i > 1 && b[i-1] != '\\') {
b = append(b[:i], append([]byte{'\\'}, b[i:]...)...)
}
}

return string(b)
}

func escapeBareword(value string) string {
if len(value) == 0 {
return ""
}
b := []byte(value)
if b[0] >= '0' && b[0] <= '9' {
b[0] = '_'
}
barewordMap := func(r rune) rune {
switch {
case r >= '0' && r <= '9':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= 'a' && r <= 'z':
return r
default:
return '_'
}
}
b = bytes.Map(barewordMap, b)

return string(b)
}
96 changes: 96 additions & 0 deletions ast/astutil/quote_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package astutil

import "testing"

func TestEscapeQuotes(t *testing.T) {
tt := []struct {
name string
wantDouble string
wantSingle string
}{
{
name: ``,
wantDouble: ``,
wantSingle: ``,
},
{
name: `"`,
wantDouble: `\"`,
wantSingle: `"`,
},
{
name: `"foo"bar"`,
wantDouble: `\"foo\"bar\"`,
wantSingle: `"foo"bar"`,
},
{
name: `'`,
wantDouble: `'`,
wantSingle: `\'`,
},
{
name: `'foo'bar'`,
wantDouble: `'foo'bar'`,
wantSingle: `\'foo\'bar\'`,
},
}

for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := escapeQuotes(tc.name, '"')
if tc.wantDouble != got {
t.Errorf("want: %q, got: %q", tc.wantDouble, got)
}

got = escapeQuotes(tc.name, '\'')
if tc.wantSingle != got {
t.Errorf("want: %q, got: %q", tc.wantSingle, got)
}
})
}
}

func TestEscapeBareword(t *testing.T) {
tt := []struct {
name string
want string
}{
{
name: "",
want: "",
},
{
name: "0",
want: "_",
},
{
name: "bareword",
want: "bareword",
},
{
name: "BAREWORD",
want: "BAREWORD",
},
{
name: "_bare_word_",
want: "_bare_word_",
},
{
name: "0bare1word9",
want: "_bare1word9",
},
{
name: "-() ",
want: "____",
},
}

for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := escapeBareword(tc.name)
if tc.want != got {
t.Errorf("want: %q, got: %q", tc.want, got)
}
})
}
}
169 changes: 169 additions & 0 deletions ast/astutil/quote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package astutil_test

import (
"testing"

"github.com/breml/logstash-config/ast"
"github.com/breml/logstash-config/ast/astutil"
)

func TestQuote(t *testing.T) {
tt := []struct {
name string
in string

want []string
wantErr []bool
wantEscaped []string
}{
{
name: "bareword",
in: `bareword`,

want: []string{
ast.DoubleQuoted: `"bareword"`,
ast.SingleQuoted: `'bareword'`,
ast.Bareword: `bareword`,
},
wantErr: []bool{
ast.DoubleQuoted: false,
ast.SingleQuoted: false,
ast.Bareword: false,
},
wantEscaped: []string{
ast.DoubleQuoted: `"bareword"`,
ast.SingleQuoted: `'bareword'`,
ast.Bareword: `bareword`,
},
},
{
name: "multiple words",
in: `multiple words`,

want: []string{
ast.DoubleQuoted: `"multiple words"`,
ast.SingleQuoted: `'multiple words'`,
ast.Bareword: ``,
},
wantErr: []bool{
ast.DoubleQuoted: false,
ast.SingleQuoted: false,
ast.Bareword: true,
},
wantEscaped: []string{
ast.DoubleQuoted: `"multiple words"`,
ast.SingleQuoted: `'multiple words'`,
ast.Bareword: `multiple_words`,
},
},
{
name: "double quote",
in: `value with " (double quote)`,

want: []string{
ast.DoubleQuoted: ``,
ast.SingleQuoted: `'value with " (double quote)'`,
ast.Bareword: ``,
},
wantErr: []bool{
ast.DoubleQuoted: true,
ast.SingleQuoted: false,
ast.Bareword: true,
},
wantEscaped: []string{
ast.DoubleQuoted: `"value with \" (double quote)"`,
ast.SingleQuoted: `'value with " (double quote)'`,
ast.Bareword: `value_with____double_quote_`,
},
},
{
name: "escaped double quote",
in: `value with \" (escaped double quote)`,

want: []string{
ast.DoubleQuoted: `"value with \" (escaped double quote)"`,
ast.SingleQuoted: `'value with \" (escaped double quote)'`,
ast.Bareword: ``,
},
wantErr: []bool{
ast.DoubleQuoted: false,
ast.SingleQuoted: false,
ast.Bareword: true,
},
wantEscaped: []string{
ast.DoubleQuoted: `"value with \" (escaped double quote)"`,
ast.SingleQuoted: `'value with \" (escaped double quote)'`,
ast.Bareword: `value_with_____escaped_double_quote_`,
},
},
{
name: "single quote",
in: `value with ' (single quote)`,

want: []string{
ast.DoubleQuoted: `"value with ' (single quote)"`,
ast.SingleQuoted: ``,
ast.Bareword: ``,
},
wantErr: []bool{
ast.DoubleQuoted: false,
ast.SingleQuoted: true,
ast.Bareword: true,
},
wantEscaped: []string{
ast.DoubleQuoted: `"value with ' (single quote)"`,
ast.SingleQuoted: `'value with \' (single quote)'`,
ast.Bareword: `value_with____single_quote_`,
},
},
{
name: "escaped single quote",
in: `value with \' (escaped single quote)`,

want: []string{
ast.DoubleQuoted: `"value with \' (escaped single quote)"`,
ast.SingleQuoted: `'value with \' (escaped single quote)'`,
ast.Bareword: ``,
},
wantErr: []bool{
ast.DoubleQuoted: false,
ast.SingleQuoted: false,
ast.Bareword: true,
},
wantEscaped: []string{
ast.DoubleQuoted: `"value with \' (escaped single quote)"`,
ast.SingleQuoted: `'value with \' (escaped single quote)'`,
ast.Bareword: `value_with_____escaped_single_quote_`,
},
},
}

quoteTypes := map[string]ast.StringAttributeType{
"double quote": ast.DoubleQuoted,
"single quote": ast.SingleQuoted,
"bareword": ast.Bareword,
}

for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
if len(tc.want) != 4 && len(tc.wantErr) != 4 {
}
for name, quoteType := range quoteTypes {
t.Run(name, func(t *testing.T) {
got, err := astutil.Quote(tc.in, quoteType, false)
if tc.wantErr[quoteType] != (err != nil) {
t.Errorf("wantErr %t, err: %v", tc.wantErr[quoteType], err)
}
if tc.want[quoteType] != got {
t.Errorf("want: %q, got: %q", tc.want[quoteType], got)
}

gotEscaped, _ := astutil.Quote(tc.in, quoteType, true)
if tc.wantEscaped[quoteType] != gotEscaped {
t.Errorf("want: %q, got: %q", tc.wantEscaped[quoteType], gotEscaped)
}
})
}
})
}
}

0 comments on commit 5c79312

Please sign in to comment.