Skip to content

Commit 5c79312

Browse files
authored
Merge pull request #5 from breml/quote-string
Add Quote for Logstash strings
2 parents c548fbb + 1e7188e commit 5c79312

File tree

3 files changed

+356
-0
lines changed

3 files changed

+356
-0
lines changed

ast/astutil/quote.go

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package astutil
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"regexp"
7+
8+
"github.com/breml/logstash-config/ast"
9+
)
10+
11+
var barewordRe = regexp.MustCompile("(?s:^[A-Za-z_][A-Za-z0-9_]+$)")
12+
13+
// Quote returns a a string with quotes for Logstash. Supported quote types
14+
// are ast.DoubleQuoted, ast.SingleQuoted and ast.Bareword.
15+
// If escape is false and the result is not a valid quoted value, an error
16+
// is returned. If escape is true, the value will be escaped such, that the
17+
// returned value is a valid quoted Logstash string.
18+
// For ast.DoubleQuoted, all double quotes (`"`) are escaped to `\"`.
19+
// For ast.SingleQuoted, all single quotes (`'`) are escaped to `\'`.
20+
// For ast.Bareword, all characters not matching "[A-Za-z_][A-Za-z0-9_]+" are
21+
// replaced with `_`.
22+
func Quote(value string, quoteType ast.StringAttributeType, escape bool) (string, error) {
23+
var hasDoubleQuote bool
24+
var hasSingleQuote bool
25+
26+
for i, chr := range value {
27+
if chr == '"' && i > 1 && value[i-1] != '\\' {
28+
hasDoubleQuote = true
29+
}
30+
if chr == '\'' && i > 1 && value[i-1] != '\\' {
31+
hasSingleQuote = true
32+
}
33+
}
34+
35+
switch quoteType {
36+
case ast.DoubleQuoted:
37+
if hasDoubleQuote && !escape {
38+
return "", errors.New("value %q contains unescaped double quotes and can not be quoted with double quotes without escaping")
39+
}
40+
return `"` + escapeQuotes(value, '"') + `"`, nil
41+
case ast.SingleQuoted:
42+
if hasSingleQuote && !escape {
43+
return "", errors.New("value %q contains unescaped single quotes and can not be quoted with double quotes without escaping")
44+
}
45+
return `'` + escapeQuotes(value, '\'') + `'`, nil
46+
case ast.Bareword:
47+
if !barewordRe.MatchString(value) && !escape {
48+
return "", errors.New("value %q contains non bareword characters and can not be quoted as bareword without escaping")
49+
}
50+
return escapeBareword(value), nil
51+
default:
52+
panic("quote type not supported")
53+
}
54+
}
55+
56+
func escapeQuotes(value string, quote byte) string {
57+
b := []byte(value)
58+
59+
for i := 0; i < len(b); i++ {
60+
if b[i] == quote && (i == 0 || i > 1 && b[i-1] != '\\') {
61+
b = append(b[:i], append([]byte{'\\'}, b[i:]...)...)
62+
}
63+
}
64+
65+
return string(b)
66+
}
67+
68+
func escapeBareword(value string) string {
69+
if len(value) == 0 {
70+
return ""
71+
}
72+
b := []byte(value)
73+
if b[0] >= '0' && b[0] <= '9' {
74+
b[0] = '_'
75+
}
76+
barewordMap := func(r rune) rune {
77+
switch {
78+
case r >= '0' && r <= '9':
79+
return r
80+
case r >= 'A' && r <= 'Z':
81+
return r
82+
case r >= 'a' && r <= 'z':
83+
return r
84+
default:
85+
return '_'
86+
}
87+
}
88+
b = bytes.Map(barewordMap, b)
89+
90+
return string(b)
91+
}

ast/astutil/quote_internal_test.go

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package astutil
2+
3+
import "testing"
4+
5+
func TestEscapeQuotes(t *testing.T) {
6+
tt := []struct {
7+
name string
8+
wantDouble string
9+
wantSingle string
10+
}{
11+
{
12+
name: ``,
13+
wantDouble: ``,
14+
wantSingle: ``,
15+
},
16+
{
17+
name: `"`,
18+
wantDouble: `\"`,
19+
wantSingle: `"`,
20+
},
21+
{
22+
name: `"foo"bar"`,
23+
wantDouble: `\"foo\"bar\"`,
24+
wantSingle: `"foo"bar"`,
25+
},
26+
{
27+
name: `'`,
28+
wantDouble: `'`,
29+
wantSingle: `\'`,
30+
},
31+
{
32+
name: `'foo'bar'`,
33+
wantDouble: `'foo'bar'`,
34+
wantSingle: `\'foo\'bar\'`,
35+
},
36+
}
37+
38+
for _, tc := range tt {
39+
t.Run(tc.name, func(t *testing.T) {
40+
got := escapeQuotes(tc.name, '"')
41+
if tc.wantDouble != got {
42+
t.Errorf("want: %q, got: %q", tc.wantDouble, got)
43+
}
44+
45+
got = escapeQuotes(tc.name, '\'')
46+
if tc.wantSingle != got {
47+
t.Errorf("want: %q, got: %q", tc.wantSingle, got)
48+
}
49+
})
50+
}
51+
}
52+
53+
func TestEscapeBareword(t *testing.T) {
54+
tt := []struct {
55+
name string
56+
want string
57+
}{
58+
{
59+
name: "",
60+
want: "",
61+
},
62+
{
63+
name: "0",
64+
want: "_",
65+
},
66+
{
67+
name: "bareword",
68+
want: "bareword",
69+
},
70+
{
71+
name: "BAREWORD",
72+
want: "BAREWORD",
73+
},
74+
{
75+
name: "_bare_word_",
76+
want: "_bare_word_",
77+
},
78+
{
79+
name: "0bare1word9",
80+
want: "_bare1word9",
81+
},
82+
{
83+
name: "-() ",
84+
want: "____",
85+
},
86+
}
87+
88+
for _, tc := range tt {
89+
t.Run(tc.name, func(t *testing.T) {
90+
got := escapeBareword(tc.name)
91+
if tc.want != got {
92+
t.Errorf("want: %q, got: %q", tc.want, got)
93+
}
94+
})
95+
}
96+
}

ast/astutil/quote_test.go

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package astutil_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/breml/logstash-config/ast"
7+
"github.com/breml/logstash-config/ast/astutil"
8+
)
9+
10+
func TestQuote(t *testing.T) {
11+
tt := []struct {
12+
name string
13+
in string
14+
15+
want []string
16+
wantErr []bool
17+
wantEscaped []string
18+
}{
19+
{
20+
name: "bareword",
21+
in: `bareword`,
22+
23+
want: []string{
24+
ast.DoubleQuoted: `"bareword"`,
25+
ast.SingleQuoted: `'bareword'`,
26+
ast.Bareword: `bareword`,
27+
},
28+
wantErr: []bool{
29+
ast.DoubleQuoted: false,
30+
ast.SingleQuoted: false,
31+
ast.Bareword: false,
32+
},
33+
wantEscaped: []string{
34+
ast.DoubleQuoted: `"bareword"`,
35+
ast.SingleQuoted: `'bareword'`,
36+
ast.Bareword: `bareword`,
37+
},
38+
},
39+
{
40+
name: "multiple words",
41+
in: `multiple words`,
42+
43+
want: []string{
44+
ast.DoubleQuoted: `"multiple words"`,
45+
ast.SingleQuoted: `'multiple words'`,
46+
ast.Bareword: ``,
47+
},
48+
wantErr: []bool{
49+
ast.DoubleQuoted: false,
50+
ast.SingleQuoted: false,
51+
ast.Bareword: true,
52+
},
53+
wantEscaped: []string{
54+
ast.DoubleQuoted: `"multiple words"`,
55+
ast.SingleQuoted: `'multiple words'`,
56+
ast.Bareword: `multiple_words`,
57+
},
58+
},
59+
{
60+
name: "double quote",
61+
in: `value with " (double quote)`,
62+
63+
want: []string{
64+
ast.DoubleQuoted: ``,
65+
ast.SingleQuoted: `'value with " (double quote)'`,
66+
ast.Bareword: ``,
67+
},
68+
wantErr: []bool{
69+
ast.DoubleQuoted: true,
70+
ast.SingleQuoted: false,
71+
ast.Bareword: true,
72+
},
73+
wantEscaped: []string{
74+
ast.DoubleQuoted: `"value with \" (double quote)"`,
75+
ast.SingleQuoted: `'value with " (double quote)'`,
76+
ast.Bareword: `value_with____double_quote_`,
77+
},
78+
},
79+
{
80+
name: "escaped double quote",
81+
in: `value with \" (escaped double quote)`,
82+
83+
want: []string{
84+
ast.DoubleQuoted: `"value with \" (escaped double quote)"`,
85+
ast.SingleQuoted: `'value with \" (escaped double quote)'`,
86+
ast.Bareword: ``,
87+
},
88+
wantErr: []bool{
89+
ast.DoubleQuoted: false,
90+
ast.SingleQuoted: false,
91+
ast.Bareword: true,
92+
},
93+
wantEscaped: []string{
94+
ast.DoubleQuoted: `"value with \" (escaped double quote)"`,
95+
ast.SingleQuoted: `'value with \" (escaped double quote)'`,
96+
ast.Bareword: `value_with_____escaped_double_quote_`,
97+
},
98+
},
99+
{
100+
name: "single quote",
101+
in: `value with ' (single quote)`,
102+
103+
want: []string{
104+
ast.DoubleQuoted: `"value with ' (single quote)"`,
105+
ast.SingleQuoted: ``,
106+
ast.Bareword: ``,
107+
},
108+
wantErr: []bool{
109+
ast.DoubleQuoted: false,
110+
ast.SingleQuoted: true,
111+
ast.Bareword: true,
112+
},
113+
wantEscaped: []string{
114+
ast.DoubleQuoted: `"value with ' (single quote)"`,
115+
ast.SingleQuoted: `'value with \' (single quote)'`,
116+
ast.Bareword: `value_with____single_quote_`,
117+
},
118+
},
119+
{
120+
name: "escaped single quote",
121+
in: `value with \' (escaped single quote)`,
122+
123+
want: []string{
124+
ast.DoubleQuoted: `"value with \' (escaped single quote)"`,
125+
ast.SingleQuoted: `'value with \' (escaped single quote)'`,
126+
ast.Bareword: ``,
127+
},
128+
wantErr: []bool{
129+
ast.DoubleQuoted: false,
130+
ast.SingleQuoted: false,
131+
ast.Bareword: true,
132+
},
133+
wantEscaped: []string{
134+
ast.DoubleQuoted: `"value with \' (escaped single quote)"`,
135+
ast.SingleQuoted: `'value with \' (escaped single quote)'`,
136+
ast.Bareword: `value_with_____escaped_single_quote_`,
137+
},
138+
},
139+
}
140+
141+
quoteTypes := map[string]ast.StringAttributeType{
142+
"double quote": ast.DoubleQuoted,
143+
"single quote": ast.SingleQuoted,
144+
"bareword": ast.Bareword,
145+
}
146+
147+
for _, tc := range tt {
148+
t.Run(tc.name, func(t *testing.T) {
149+
if len(tc.want) != 4 && len(tc.wantErr) != 4 {
150+
}
151+
for name, quoteType := range quoteTypes {
152+
t.Run(name, func(t *testing.T) {
153+
got, err := astutil.Quote(tc.in, quoteType, false)
154+
if tc.wantErr[quoteType] != (err != nil) {
155+
t.Errorf("wantErr %t, err: %v", tc.wantErr[quoteType], err)
156+
}
157+
if tc.want[quoteType] != got {
158+
t.Errorf("want: %q, got: %q", tc.want[quoteType], got)
159+
}
160+
161+
gotEscaped, _ := astutil.Quote(tc.in, quoteType, true)
162+
if tc.wantEscaped[quoteType] != gotEscaped {
163+
t.Errorf("want: %q, got: %q", tc.wantEscaped[quoteType], gotEscaped)
164+
}
165+
})
166+
}
167+
})
168+
}
169+
}

0 commit comments

Comments
 (0)