diff --git a/matchers/compliance/compliance_test.go b/matchers/compliance/compliance_test.go new file mode 100644 index 0000000000..4721411943 --- /dev/null +++ b/matchers/compliance/compliance_test.go @@ -0,0 +1,376 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build compliance + +package compliance + +import ( + "reflect" + "testing" + + "github.com/prometheus/alertmanager/matchers/parse" + "github.com/prometheus/alertmanager/pkg/labels" +) + +func TestCompliance(t *testing.T) { + for _, tc := range []struct { + input string + want labels.Matchers + err string + }{ + { + input: `{}`, + want: make(labels.Matchers, 0), + }, + { + input: `{foo='}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "'") + return append(ms, m) + }(), + }, + { + input: "{foo=`}", + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "`") + return append(ms, m) + }(), + }, + { + input: "{foo=\\\"}", + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "\"") + return append(ms, m) + }(), + }, + { + input: `{foo=bar}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + return append(ms, m) + }(), + }, + { + input: `{foo="bar"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + return append(ms, m) + }(), + }, + { + input: `{foo=~bar.*}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchRegexp, "foo", "bar.*") + return append(ms, m) + }(), + }, + { + input: `{foo=~"bar.*"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchRegexp, "foo", "bar.*") + return append(ms, m) + }(), + }, + { + input: `{foo!=bar}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchNotEqual, "foo", "bar") + return append(ms, m) + }(), + }, + { + input: `{foo!="bar"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchNotEqual, "foo", "bar") + return append(ms, m) + }(), + }, + { + input: `{foo!~bar.*}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchNotRegexp, "foo", "bar.*") + return append(ms, m) + }(), + }, + { + input: `{foo!~"bar.*"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchNotRegexp, "foo", "bar.*") + return append(ms, m) + }(), + }, + { + input: `{foo="bar", baz!="quux"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + m2, _ := labels.NewMatcher(labels.MatchNotEqual, "baz", "quux") + return append(ms, m, m2) + }(), + }, + { + input: `{foo="bar", baz!~"quux.*"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + m2, _ := labels.NewMatcher(labels.MatchNotRegexp, "baz", "quux.*") + return append(ms, m, m2) + }(), + }, + { + input: `{foo="bar",baz!~".*quux", derp="wat"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + m2, _ := labels.NewMatcher(labels.MatchNotRegexp, "baz", ".*quux") + m3, _ := labels.NewMatcher(labels.MatchEqual, "derp", "wat") + return append(ms, m, m2, m3) + }(), + }, + { + input: `{foo="bar", baz!="quux", derp="wat"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + m2, _ := labels.NewMatcher(labels.MatchNotEqual, "baz", "quux") + m3, _ := labels.NewMatcher(labels.MatchEqual, "derp", "wat") + return append(ms, m, m2, m3) + }(), + }, + { + input: `{foo="bar", baz!~".*quux.*", derp="wat"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + m2, _ := labels.NewMatcher(labels.MatchNotRegexp, "baz", ".*quux.*") + m3, _ := labels.NewMatcher(labels.MatchEqual, "derp", "wat") + return append(ms, m, m2, m3) + }(), + }, + { + input: `{foo="bar", instance=~"some-api.*"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + m2, _ := labels.NewMatcher(labels.MatchRegexp, "instance", "some-api.*") + return append(ms, m, m2) + }(), + }, + { + input: `{foo=""}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "") + return append(ms, m) + }(), + }, + { + input: `{foo="bar,quux", job="job1"}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar,quux") + m2, _ := labels.NewMatcher(labels.MatchEqual, "job", "job1") + return append(ms, m, m2) + }(), + }, + { + input: `{foo = "bar", dings != "bums", }`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + m2, _ := labels.NewMatcher(labels.MatchNotEqual, "dings", "bums") + return append(ms, m, m2) + }(), + }, + { + input: `foo=bar,dings!=bums`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") + m2, _ := labels.NewMatcher(labels.MatchNotEqual, "dings", "bums") + return append(ms, m, m2) + }(), + }, + { + input: `{quote="She said: \"Hi, ladies! That's gender-neutral…\""}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "quote", `She said: "Hi, ladies! That's gender-neutral…"`) + return append(ms, m) + }(), + }, + { + input: `statuscode=~"5.."`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchRegexp, "statuscode", "5..") + return append(ms, m) + }(), + }, + { + input: `tricky=~~~`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchRegexp, "tricky", "~~") + return append(ms, m) + }(), + }, + { + input: `trickier==\\=\=\"`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "trickier", `=\=\="`) + return append(ms, m) + }(), + }, + { + input: `contains_quote != "\"" , contains_comma !~ "foo,bar" , `, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchNotEqual, "contains_quote", `"`) + m2, _ := labels.NewMatcher(labels.MatchNotRegexp, "contains_comma", "foo,bar") + return append(ms, m, m2) + }(), + }, + { + input: `{foo=bar}}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar}") + return append(ms, m) + }(), + }, + { + input: `{foo=bar}},}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar}}") + return append(ms, m) + }(), + }, + { + input: `{foo=,bar=}}`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m1, _ := labels.NewMatcher(labels.MatchEqual, "foo", "") + m2, _ := labels.NewMatcher(labels.MatchEqual, "bar", "}") + return append(ms, m1, m2) + }(), + }, + { + input: `job=`, + want: func() labels.Matchers { + m, _ := labels.NewMatcher(labels.MatchEqual, "job", "") + return labels.Matchers{m} + }(), + }, + { + input: `{,}`, + err: "bad matcher format: ", + }, + { + input: `job="value`, + err: `matcher value contains unescaped double quote: "value`, + }, + { + input: `job=value"`, + err: `matcher value contains unescaped double quote: value"`, + }, + { + input: `trickier==\\=\=\""`, + err: `matcher value contains unescaped double quote: =\\=\=\""`, + }, + { + input: `contains_unescaped_quote = foo"bar`, + err: `matcher value contains unescaped double quote: foo"bar`, + }, + { + input: `{invalid-name = "valid label"}`, + err: `bad matcher format: invalid-name = "valid label"`, + }, + { + input: `{foo=~"invalid[regexp"}`, + err: "error parsing regexp: missing closing ]: `[regexp)$`", + }, + // Double escaped strings. + { + input: `"{foo=\"bar"}`, + err: `bad matcher format: "{foo=\"bar"`, + }, + { + input: `"foo=\"bar"`, + err: `bad matcher format: "foo=\"bar"`, + }, + { + input: `"foo=\"bar\""`, + err: `bad matcher format: "foo=\"bar\""`, + }, + { + input: `"foo=\"bar\"`, + err: `bad matcher format: "foo=\"bar\"`, + }, + { + input: `"{foo=\"bar\"}"`, + err: `bad matcher format: "{foo=\"bar\"}"`, + }, + { + input: `"foo="bar""`, + err: `bad matcher format: "foo="bar""`, + }, + { + input: `{{foo=`, + err: `bad matcher format: {foo=`, + }, + { + input: `{foo=`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "") + return append(ms, m) + }(), + }, + { + input: `{foo=}b`, + want: func() labels.Matchers { + ms := labels.Matchers{} + m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "}b") + return append(ms, m) + }(), + }, + } { + t.Run(tc.input, func(t *testing.T) { + got, err := parse.Parse(tc.input) + if err != nil && tc.err == "" { + t.Fatalf("got error where none expected: %v", err) + } + if err == nil && tc.err != "" { + t.Fatalf("expected error but got none: %v", tc.err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("labels not equal:\ngot %v\nwant %v", got, tc.want) + } + }) + } +} diff --git a/matchers/parse/lexer.go b/matchers/parse/lexer.go new file mode 100644 index 0000000000..9c49dc471d --- /dev/null +++ b/matchers/parse/lexer.go @@ -0,0 +1,328 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parse + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + eof rune = -1 +) + +func isAlpha(r rune) bool { + return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' +} + +func isNum(r rune) bool { + return r >= '0' && r <= '9' +} + +// ExpectedError is returned when the next rune does not match what is expected. +type ExpectedError struct { + input string + offsetStart int + offsetEnd int + columnStart int + columnEnd int + expected string +} + +func (e ExpectedError) Error() string { + if e.offsetEnd >= len(e.input) { + return fmt.Sprintf("%d:%d: unexpected end of input, expected one of '%s'", + e.columnStart, + e.columnEnd, + e.expected, + ) + } + return fmt.Sprintf("%d:%d: %s: expected one of '%s'", + e.columnStart, + e.columnEnd, + e.input[e.offsetStart:e.offsetEnd], + e.expected, + ) +} + +// InvalidInputError is returned when the next rune in the input does not match +// the grammar of Prometheus-like matchers. +type InvalidInputError struct { + input string + offsetStart int + offsetEnd int + columnStart int + columnEnd int +} + +func (e InvalidInputError) Error() string { + return fmt.Sprintf("%d:%d: %s: invalid input", + e.columnStart, + e.columnEnd, + e.input[e.offsetStart:e.offsetEnd], + ) +} + +// UnterminatedError is returned when text in quotes does not have a closing quote. +type UnterminatedError struct { + input string + offsetStart int + offsetEnd int + columnStart int + columnEnd int + quote rune +} + +func (e UnterminatedError) Error() string { + return fmt.Sprintf("%d:%d: %s: missing end %c", + e.columnStart, + e.columnEnd, + e.input[e.offsetStart:e.offsetEnd], + e.quote, + ) +} + +// Lexer scans a sequence of tokens that match the grammar of Prometheus-like +// matchers. A token is emitted for each call to Scan() which returns the +// next token in the input or an error if the input does not conform to the +// grammar. A token can be one of a number of kinds and corresponds to a +// subslice of the input. Once the input has been consumed successive calls to +// Scan() return a TokenNone token. +type Lexer struct { + input string + err error + start int // the offset of the current token + pos int // the position of the cursor in the input + width int // the width of the last rune + column int // the column offset of the current token + cols int // the number of columns (runes) decoded from the input +} + +func NewLexer(input string) Lexer { + return Lexer{ + input: input, + } +} + +func (l *Lexer) Peek() (Token, error) { + start := l.start + pos := l.pos + width := l.width + column := l.column + cols := l.cols + // Do not reset l.err because we can return it on the next call to Scan() + defer func() { + l.start = start + l.pos = pos + l.width = width + l.column = column + l.cols = cols + }() + return l.Scan() +} + +func (l *Lexer) Scan() (Token, error) { + tok := Token{} + + // Do not attempt to emit more tokens if the input is invalid + if l.err != nil { + return tok, l.err + } + + // Iterate over each rune in the input and either emit a token or an error + for r := l.next(); r != eof; r = l.next() { + switch { + case r == '{': + tok = l.emit(TokenOpenBrace) + return tok, l.err + case r == '}': + tok = l.emit(TokenCloseBrace) + return tok, l.err + case r == ',': + tok = l.emit(TokenComma) + return tok, l.err + case r == '=' || r == '!': + l.rewind() + tok, l.err = l.scanOperator() + return tok, l.err + case r == '"': + l.rewind() + tok, l.err = l.scanQuoted() + return tok, l.err + case r == '_' || isAlpha(r): + l.rewind() + tok, l.err = l.scanIdent() + return tok, l.err + case unicode.IsSpace(r): + l.skip() + default: + l.err = InvalidInputError{ + input: l.input, + offsetStart: l.start, + offsetEnd: l.pos, + columnStart: l.column, + columnEnd: l.cols, + } + return tok, l.err + } + } + + return tok, l.err +} + +func (l *Lexer) scanIdent() (Token, error) { + for r := l.next(); r != eof; r = l.next() { + if !isAlpha(r) && !isNum(r) && r != '_' && r != ':' { + l.rewind() + break + } + } + return l.emit(TokenIdent), nil +} + +func (l *Lexer) scanOperator() (Token, error) { + if err := l.expect("!="); err != nil { + return Token{}, err + } + + // Rewind because we need to know if the rune was an '!' or an '=' + l.rewind() + + // If the first rune is an '!' then it must be followed with either an + // '=' or '~' to not match a string or regex + if l.accept("!") { + if err := l.expect("=~"); err != nil { + return Token{}, err + } + return l.emit(TokenOperator), nil + } + + // If the first rune is an '=' then it can be followed with an optional + // '~' to match a regex + l.accept("=") + l.accept("~") + return l.emit(TokenOperator), nil +} + +func (l *Lexer) scanQuoted() (Token, error) { + if err := l.expect("\""); err != nil { + return Token{}, err + } + var isEscaped bool + for r := l.next(); r != eof; r = l.next() { + if isEscaped { + isEscaped = false + } else if r == '\\' { + isEscaped = true + } else if r == '"' { + l.rewind() + break + } + } + if err := l.expect("\""); err != nil { + return Token{}, UnterminatedError{ + input: l.input, + offsetStart: l.start, + offsetEnd: l.pos, + columnStart: l.column, + columnEnd: l.cols, + quote: '"', + } + } + return l.emit(TokenQuoted), nil +} + +func (l *Lexer) accept(valid string) bool { + if strings.ContainsRune(valid, l.next()) { + return true + } + l.rewind() + return false +} + +func (l *Lexer) acceptRun(valid string) { + for strings.ContainsRune(valid, l.next()) { + } + l.rewind() +} + +func (l *Lexer) expect(valid string) error { + r := l.next() + if r == -1 { + l.rewind() + return ExpectedError{ + input: l.input, + offsetStart: l.start, + offsetEnd: l.pos, + columnStart: l.column, + columnEnd: l.cols, + expected: valid, + } + } else if !strings.ContainsRune(valid, r) { + l.rewind() + return ExpectedError{ + input: l.input, + offsetStart: l.start, + offsetEnd: l.pos, + columnStart: l.column, + columnEnd: l.cols, + expected: valid, + } + } else { + return nil + } +} + +func (l *Lexer) emit(kind TokenKind) Token { + tok := Token{ + Kind: kind, + Value: l.input[l.start:l.pos], + Position: Position{ + OffsetStart: l.start, + OffsetEnd: l.pos, + ColumnStart: l.column, + ColumnEnd: l.cols, + }, + } + l.start = l.pos + l.column = l.cols + return tok +} + +func (l *Lexer) next() rune { + if l.pos >= len(l.input) { + l.width = 0 + return eof + } + r, width := utf8.DecodeRuneInString(l.input[l.pos:]) + l.width = width + l.pos += width + l.cols++ + return r +} + +func (l *Lexer) rewind() { + if l.width > 0 { + l.pos -= l.width + l.width = 0 + l.cols-- + } +} + +func (l *Lexer) skip() { + l.start = l.pos + l.column++ +} diff --git a/matchers/parse/lexer_test.go b/matchers/parse/lexer_test.go new file mode 100644 index 0000000000..eba850b1ec --- /dev/null +++ b/matchers/parse/lexer_test.go @@ -0,0 +1,564 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parse + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLexer_Scan(t *testing.T) { + tests := []struct { + name string + input string + expected []Token + err string + }{{ + name: "open brace", + input: "{", + expected: []Token{{ + Kind: TokenOpenBrace, + Value: "{", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 1, + ColumnStart: 0, + ColumnEnd: 1, + }, + }}, + }, { + name: "open brace with space", + input: " {", + expected: []Token{{ + Kind: TokenOpenBrace, + Value: "{", + Position: Position{ + OffsetStart: 1, + OffsetEnd: 2, + ColumnStart: 1, + ColumnEnd: 2, + }, + }}, + }, { + name: "close brace", + input: "}", + expected: []Token{{ + Kind: TokenCloseBrace, + Value: "}", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 1, + ColumnStart: 0, + ColumnEnd: 1, + }, + }}, + }, { + name: "close brace with space", + input: "}", + expected: []Token{{ + Kind: TokenCloseBrace, + Value: "}", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 1, + ColumnStart: 0, + ColumnEnd: 1, + }, + }}, + }, { + name: "open and closing braces", + input: "{}", + expected: []Token{{ + Kind: TokenOpenBrace, + Value: "{", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 1, + ColumnStart: 0, + ColumnEnd: 1, + }, + }, { + Kind: TokenCloseBrace, + Value: "}", + Position: Position{ + OffsetStart: 1, + OffsetEnd: 2, + ColumnStart: 1, + ColumnEnd: 2, + }, + }}, + }, { + name: "open and closing braces with space", + input: "{ }", + expected: []Token{{ + Kind: TokenOpenBrace, + Value: "{", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 1, + ColumnStart: 0, + ColumnEnd: 1, + }, + }, { + Kind: TokenCloseBrace, + Value: "}", + Position: Position{ + OffsetStart: 2, + OffsetEnd: 3, + ColumnStart: 2, + ColumnEnd: 3, + }, + }}, + }, { + name: "ident", + input: "hello", + expected: []Token{{ + Kind: TokenIdent, + Value: "hello", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 5, + ColumnStart: 0, + ColumnEnd: 5, + }, + }}, + }, { + name: "ident with underscore", + input: "hello_world", + expected: []Token{{ + Kind: TokenIdent, + Value: "hello_world", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 11, + ColumnStart: 0, + ColumnEnd: 11, + }, + }}, + }, { + name: "ident with colon", + input: "hello:world", + expected: []Token{{ + Kind: TokenIdent, + Value: "hello:world", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 11, + ColumnStart: 0, + ColumnEnd: 11, + }, + }}, + }, { + name: "ident with numbers", + input: "hello0123456789", + expected: []Token{{ + Kind: TokenIdent, + Value: "hello0123456789", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 15, + ColumnStart: 0, + ColumnEnd: 15, + }, + }}, + }, { + name: "ident can start with underscore", + input: "_hello", + expected: []Token{{ + Kind: TokenIdent, + Value: "_hello", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 6, + ColumnStart: 0, + ColumnEnd: 6, + }, + }}, + }, { + name: "idents separated with space", + input: "hello world", + expected: []Token{{ + Kind: TokenIdent, + Value: "hello", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 5, + ColumnStart: 0, + ColumnEnd: 5, + }, + }, { + Kind: TokenIdent, + Value: "world", + Position: Position{ + OffsetStart: 6, + OffsetEnd: 11, + ColumnStart: 6, + ColumnEnd: 11, + }, + }}, + }, { + name: "quoted", + input: "\"hello\"", + expected: []Token{{ + Kind: TokenQuoted, + Value: "\"hello\"", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 7, + ColumnStart: 0, + ColumnEnd: 7, + }, + }}, + }, { + name: "quoted with unicode", + input: "\"hello 🙂\"", + expected: []Token{{ + Kind: TokenQuoted, + Value: "\"hello 🙂\"", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 12, + ColumnStart: 0, + ColumnEnd: 9, + }, + }}, + }, { + name: "quoted with space", + input: "\"hello world\"", + expected: []Token{{ + Kind: TokenQuoted, + Value: "\"hello world\"", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 13, + ColumnStart: 0, + ColumnEnd: 13, + }, + }}, + }, { + name: "quoted with newline", + input: "\"hello\nworld\"", + expected: []Token{{ + Kind: TokenQuoted, + Value: "\"hello\nworld\"", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 13, + ColumnStart: 0, + ColumnEnd: 13, + }, + }}, + }, { + name: "quoted with tab", + input: "\"hello\tworld\"", + expected: []Token{{ + Kind: TokenQuoted, + Value: "\"hello\tworld\"", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 13, + ColumnStart: 0, + ColumnEnd: 13, + }, + }}, + }, { + name: "quoted with escaped quotes", + input: "\"hello \\\"world\\\"\"", + expected: []Token{{ + Kind: TokenQuoted, + Value: "\"hello \\\"world\\\"\"", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 17, + ColumnStart: 0, + ColumnEnd: 17, + }, + }}, + }, { + name: "quoted with escaped backslash", + input: "\"hello world\\\\\"", + expected: []Token{{ + Kind: TokenQuoted, + Value: "\"hello world\\\\\"", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 15, + ColumnStart: 0, + ColumnEnd: 15, + }, + }}, + }, { + name: "equals operator", + input: "=", + expected: []Token{{ + Kind: TokenOperator, + Value: "=", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 1, + ColumnStart: 0, + ColumnEnd: 1, + }, + }}, + }, { + name: "not equals operator", + input: "!=", + expected: []Token{{ + Kind: TokenOperator, + Value: "!=", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 2, + ColumnStart: 0, + ColumnEnd: 2, + }, + }}, + }, { + name: "matches regex operator", + input: "=~", + expected: []Token{{ + Kind: TokenOperator, + Value: "=~", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 2, + ColumnStart: 0, + ColumnEnd: 2, + }, + }}, + }, { + name: "not matches regex operator", + input: "!~", + expected: []Token{{ + Kind: TokenOperator, + Value: "!~", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 2, + ColumnStart: 0, + ColumnEnd: 2, + }, + }}, + }, { + name: "unexpected $", + input: "$", + err: "0:1: $: invalid input", + }, { + name: "unexpected emoji", + input: "🙂", + err: "0:1: 🙂: invalid input", + }, { + name: "unexpected unicode letter", + input: "Σ", + err: "0:1: Σ: invalid input", + }, { + name: "unexpected : at start of ident", + input: ":hello", + err: "0:1: :: invalid input", + }, { + name: "unexpected $ in ident", + input: "hello$", + expected: []Token{{ + Kind: TokenIdent, + Value: "hello", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 5, + ColumnStart: 0, + ColumnEnd: 5, + }, + }}, + err: "5:6: $: invalid input", + }, { + name: "unexpected unicode letter in ident", + input: "helloΣ", + expected: []Token{{ + Kind: TokenIdent, + Value: "hello", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 5, + ColumnStart: 0, + ColumnEnd: 5, + }, + }}, + err: "5:6: Σ: invalid input", + }, { + name: "unexpected emoji in ident", + input: "hello🙂", + expected: []Token{{ + Kind: TokenIdent, + Value: "hello", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 5, + ColumnStart: 0, + ColumnEnd: 5, + }, + }}, + err: "5:6: 🙂: invalid input", + }, { + name: "invalid operator", + input: "!", + err: "0:1: unexpected end of input, expected one of '=~'", + }, { + name: "another invalid operator", + input: "~", + err: "0:1: ~: invalid input", + }, { + name: "unexpected $ in operator", + input: "=$", + expected: []Token{{ + Kind: TokenOperator, + Value: "=", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 1, + ColumnStart: 0, + ColumnEnd: 1, + }, + }}, + err: "1:2: $: invalid input", + }, { + name: "unexpected ! after operator", + input: "=!", + expected: []Token{{ + Kind: TokenOperator, + Value: "=", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 1, + ColumnStart: 0, + ColumnEnd: 1, + }, + }}, + err: "1:2: unexpected end of input, expected one of '=~'", + }, { + name: "unexpected !! after operator", + input: "!=!!", + expected: []Token{{ + Kind: TokenOperator, + Value: "!=", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 2, + ColumnStart: 0, + ColumnEnd: 2, + }, + }}, + err: "2:3: !: expected one of '=~'", + }, { + name: "unterminated quoted", + input: "\"hello", + err: "0:6: \"hello: missing end \"", + }, { + name: "unterminated quoted with escaped quote", + input: "\"hello\\\"", + err: "0:8: \"hello\\\": missing end \"", + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + l := NewLexer(test.input) + // scan all expected tokens + for i := 0; i < len(test.expected); i++ { + tok, err := l.Scan() + require.NoError(t, err) + require.Equal(t, test.expected[i], tok) + } + if test.err == "" { + // check there are no more tokens + tok, err := l.Scan() + require.NoError(t, err) + require.Equal(t, Token{}, tok) + } else { + // check if expected error is returned + tok, err := l.Scan() + require.Equal(t, Token{}, tok) + require.EqualError(t, err, test.err) + } + }) + } +} + +// This test asserts that the lexer does not emit more tokens after an +// error has occurred. +func TestLexer_ScanError(t *testing.T) { + l := NewLexer("\"hello") + for i := 0; i < 10; i++ { + tok, err := l.Scan() + require.Equal(t, Token{}, tok) + require.EqualError(t, err, "0:6: \"hello: missing end \"") + } +} + +func TestLexer_Peek(t *testing.T) { + l := NewLexer("hello world") + expected1 := Token{ + Kind: TokenIdent, + Value: "hello", + Position: Position{ + OffsetStart: 0, + OffsetEnd: 5, + ColumnStart: 0, + ColumnEnd: 5, + }, + } + expected2 := Token{ + Kind: TokenIdent, + Value: "world", + Position: Position{ + OffsetStart: 6, + OffsetEnd: 11, + ColumnStart: 6, + ColumnEnd: 11, + }, + } + // check that Peek() returns the first token + tok, err := l.Peek() + require.NoError(t, err) + require.Equal(t, expected1, tok) + // check that Scan() returns the peeked token + tok, err = l.Scan() + require.NoError(t, err) + require.Equal(t, expected1, tok) + // check that Peek() returns the second token until the next Scan() + for i := 0; i < 10; i++ { + tok, err = l.Peek() + require.NoError(t, err) + require.Equal(t, expected2, tok) + } + // check that Scan() returns the last token + tok, err = l.Scan() + require.NoError(t, err) + require.Equal(t, expected2, tok) + // should not be able to Peek() further tokens + for i := 0; i < 10; i++ { + tok, err = l.Peek() + require.NoError(t, err) + require.Equal(t, Token{}, tok) + } +} + +// This test asserts that the lexer does not emit more tokens after an +// error has occurred. +func TestLexer_PeekError(t *testing.T) { + l := NewLexer("\"hello") + for i := 0; i < 10; i++ { + tok, err := l.Peek() + require.Equal(t, Token{}, tok) + require.EqualError(t, err, "0:6: \"hello: missing end \"") + } +} diff --git a/matchers/parse/parse.go b/matchers/parse/parse.go new file mode 100644 index 0000000000..8f1b60940d --- /dev/null +++ b/matchers/parse/parse.go @@ -0,0 +1,311 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parse + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/prometheus/alertmanager/pkg/labels" +) + +var ( + ErrEOF = errors.New("end of input") + ErrNoOpenBrace = errors.New("expected opening brace") + ErrNoCloseBrace = errors.New("expected close brace") + ErrNoLabelName = errors.New("expected label name") + ErrNoLabelValue = errors.New("expected label value") + ErrNoOperator = errors.New("expected an operator such as '=', '!=', '=~' or '!~'") + ErrExpectedEOF = errors.New("expected end of input") +) + +// Parser reads the sequence of tokens from the lexer and returns either a +// series of matchers or an error. An error can occur if the lexer attempts +// to scan text that does not match the expected grammar, or if the tokens +// returned from the lexer cannot be parsed into a complete series of matchers. +// For example, the input is missing an opening bracket, has missing label +// names or label values, a trailing comma, or missing closing bracket. +type Parser struct { + done bool + err error + hasOpenParen bool + input string + lexer Lexer + matchers labels.Matchers +} + +func NewParser(input string) Parser { + return Parser{ + input: input, + lexer: NewLexer(input), + } +} + +// Error returns the error that caused parsing to fail. +func (p *Parser) Error() error { + return p.err +} + +// Parse returns a series of matchers or an error. It can be called more than +// once, however successive calls return the matchers and err from the first +// call. +func (p *Parser) Parse() (labels.Matchers, error) { + if !p.done { + p.done = true + p.matchers, p.err = p.parse() + } + return p.matchers, p.err +} + +// accept returns true if the next token is one of the expected kinds, or +// an error if the next token that would be returned from the lexer does not +// match the expected grammar, or if the lexer has reached the end of the input +// and TokenNone is not one of the accepted kinds. It is possible to use either +// Scan() or Peek() as fn depending on whether accept should consume or peek +// the next token. +func (p *Parser) accept(fn func() (Token, error), kind ...TokenKind) (bool, error) { + var ( + err error + tok Token + ) + if tok, err = fn(); err != nil { + return false, err + } + for _, k := range kind { + if tok.Kind == k { + return true, nil + } + } + if tok.Kind == TokenNone { + return false, fmt.Errorf("0:%d: %w", len(p.input), ErrEOF) + } + return false, nil +} + +// expect returns the next token if it is one of the expected kinds. It returns +// an error if the next token that would be returned from the lexer does not +// match the expected grammar, or if the lexer has reached the end of the input +// and TokenNone is not one of the expected kinds. It is possible to use either +// Scan() or Peek() as fn depending on whether expect should consume or peek +// the next token. +func (p *Parser) expect(fn func() (Token, error), kind ...TokenKind) (Token, error) { + var ( + err error + tok Token + ) + if tok, err = fn(); err != nil { + return Token{}, err + } + for _, k := range kind { + if tok.Kind == k { + return tok, nil + } + } + if tok.Kind == TokenNone { + return Token{}, fmt.Errorf("0:%d: %w", len(p.input), ErrEOF) + } + return Token{}, fmt.Errorf("%d:%d: unexpected %s", tok.ColumnStart, tok.ColumnEnd, tok.Value) +} + +func (p *Parser) parse() (labels.Matchers, error) { + var ( + err error + fn = p.parseOpenParen + l = &p.lexer + ) + for { + if fn, err = fn(l); err != nil { + return nil, err + } else if fn == nil { + break + } + } + return p.matchers, nil +} + +type parseFn func(l *Lexer) (parseFn, error) + +func (p *Parser) parseOpenParen(l *Lexer) (parseFn, error) { + // Can start with an optional open brace + hasOpenParen, err := p.accept(l.Peek, TokenOpenBrace) + if err != nil { + if errors.Is(err, ErrEOF) { + return p.parseEOF, nil + } + return nil, err + } + if hasOpenParen { + // If the token was an open brace it must be scanned so the token + // following it can be peeked + if _, err = l.Scan(); err != nil { + panic("Unexpected error scanning open brace") + } + } + p.hasOpenParen = hasOpenParen + // If the next token is a close brace there are no matchers in the input + // and we can just parse the close brace + if hasCloseParen, err := p.accept(l.Peek, TokenCloseBrace); err != nil { + return nil, fmt.Errorf("%s: %w", err, ErrNoCloseBrace) + } else if hasCloseParen { + return p.parseCloseParen, nil + } + return p.parseLabelMatcher, nil +} + +func (p *Parser) parseCloseParen(l *Lexer) (parseFn, error) { + if p.hasOpenParen { + // If there was an open brace there must be a matching close brace + if _, err := p.expect(l.Scan, TokenCloseBrace); err != nil { + return nil, fmt.Errorf("%s: %w", err, ErrNoCloseBrace) + } + } else { + // If there was no open brace there must not be a close brace either + if _, err := p.expect(l.Peek, TokenCloseBrace); err == nil { + return nil, fmt.Errorf("0:%d: }: %w", len(p.input), ErrNoOpenBrace) + } + } + return p.parseEOF, nil +} + +func (p *Parser) parseComma(l *Lexer) (parseFn, error) { + if _, err := p.expect(l.Scan, TokenComma); err != nil { + return nil, fmt.Errorf("%s: %s", err, "expected a comma") + } + // The token after the comma can be another matcher, a close brace or the + // end of input + tok, err := p.expect(l.Peek, TokenCloseBrace, TokenIdent, TokenQuoted) + if err != nil { + if errors.Is(err, ErrEOF) { + // If this is the end of input we still need to check if the optional + // open brace has a matching close brace + return p.parseCloseParen, nil + } + return nil, fmt.Errorf("%s: %s", err, "expected a matcher or close brace after comma") + } + if tok.Kind == TokenCloseBrace { + return p.parseCloseParen, nil + } + return p.parseLabelMatcher, nil +} + +func (p *Parser) parseEOF(l *Lexer) (parseFn, error) { + if _, err := p.expect(l.Scan, TokenNone); err != nil { + return nil, fmt.Errorf("%s: %w", err, ErrExpectedEOF) + } + return nil, nil +} + +func (p *Parser) parseLabelMatcher(l *Lexer) (parseFn, error) { + var ( + err error + tok Token + labelName string + labelValue string + ty labels.MatchType + ) + + // The next token is the label name. This can either be an ident which + // accepts just [a-zA-Z_] or a quoted which accepts all UTF-8 characters + // in double quotes + if tok, err = p.expect(l.Scan, TokenIdent, TokenQuoted); err != nil { + return nil, fmt.Errorf("%s: %w", err, ErrNoLabelName) + } + labelName = tok.Value + + // The next token is the operator such as '=', '!=', '=~' and '!~' + if tok, err = p.expect(l.Scan, TokenOperator); err != nil { + return nil, fmt.Errorf("%s: %s", err, ErrNoOperator) + } + if ty, err = matchType(tok.Value); err != nil { + panic("Unexpected operator") + } + + // The next token is the label value. This too can either be an ident + // which accepts just [a-zA-Z_] or a quoted which accepts all UTF-8 + // characters in double quotes + if tok, err = p.expect(l.Scan, TokenIdent, TokenQuoted); err != nil { + return nil, fmt.Errorf("%s: %s", err, ErrNoLabelValue) + } + if tok.Kind == TokenIdent { + labelValue = tok.Value + } else { + labelValue, err = strconv.Unquote(tok.Value) + if err != nil { + return nil, fmt.Errorf("%d:%d: %s: invalid input", tok.ColumnStart, tok.ColumnEnd, tok.Value) + } + } + + m, err := labels.NewMatcher(ty, labelName, labelValue) + if err != nil { + return nil, fmt.Errorf("failed to create matcher: %s", err) + } + p.matchers = append(p.matchers, m) + + return p.parseLabelMatcherEnd, nil +} + +func (p *Parser) parseLabelMatcherEnd(l *Lexer) (parseFn, error) { + tok, err := p.expect(l.Peek, TokenComma, TokenCloseBrace) + if err != nil { + // If this is the end of input we still need to check if the optional + // open brace has a matching close brace + if errors.Is(err, ErrEOF) { + return p.parseCloseParen, nil + } + return nil, fmt.Errorf("%s: %s", err, "expected a comma or close brace") + } + if tok.Kind == TokenCloseBrace { + return p.parseCloseParen, nil + } else if tok.Kind == TokenComma { + return p.parseComma, nil + } else { + panic("unreachable") + } +} + +func Parse(input string) (labels.Matchers, error) { + p := NewParser(input) + return p.Parse() +} + +func ParseMatcher(input string) (*labels.Matcher, error) { + if strings.HasPrefix(input, "{") { + return nil, errors.New("Individual matchers cannot start and end with braces") + } + m, err := Parse(input) + if err != nil { + return nil, err + } + if len(m) > 1 { + return nil, fmt.Errorf("expected 1 matcher, found %d", len(m)) + } + return m[0], nil +} + +func matchType(s string) (labels.MatchType, error) { + switch s { + case "=": + return labels.MatchEqual, nil + case "!=": + return labels.MatchNotEqual, nil + case "=~": + return labels.MatchRegexp, nil + case "!~": + return labels.MatchNotRegexp, nil + default: + return -1, fmt.Errorf("unexpected operator: %s", s) + } +} diff --git a/matchers/parse/parse_test.go b/matchers/parse/parse_test.go new file mode 100644 index 0000000000..3599141a4e --- /dev/null +++ b/matchers/parse/parse_test.go @@ -0,0 +1,169 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parse + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/prometheus/alertmanager/pkg/labels" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + expected labels.Matchers + error string + }{{ + name: "no braces", + input: "", + expected: nil, + }, { + name: "open and closing braces", + input: "{}", + expected: nil, + }, { + name: "equals", + input: "{foo=\"bar\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, + }, { + name: "equals unicode emoji", + input: "{foo=\"🙂\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "🙂")}, + }, { + name: "equals without quotes", + input: "{foo=bar}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, + }, { + name: "equals without braces", + input: "foo=\"bar\"", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, + }, { + name: "equals without braces or quotes", + input: "foo=bar", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, + }, { + name: "equals with trailing comma", + input: "{foo=\"bar\",}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, + }, { + name: "equals without braces but trailing comma", + input: "foo=\"bar\",", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, + }, { + name: "equals with newline", + input: "{foo=\"bar\\n\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar\n")}, + }, { + name: "equals with tab", + input: "{foo=\"bar\\t\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar\t")}, + }, { + name: "equals with escaped quotes", + input: "{foo=\"\\\"bar\\\"\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "\"bar\"")}, + }, { + name: "equals with escaped backslash", + input: "{foo=\"bar\\\\\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar\\")}, + }, { + name: "not equals", + input: "{foo!=\"bar\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchNotEqual, "foo", "bar")}, + }, { + name: "match regex", + input: "{foo=~\"[a-z]+\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchRegexp, "foo", "[a-z]+")}, + }, { + name: "doesn't match regex", + input: "{foo!~\"[a-z]+\"}", + expected: labels.Matchers{mustNewMatcher(t, labels.MatchNotRegexp, "foo", "[a-z]+")}, + }, { + name: "complex", + input: "{foo=\"bar\",bar!=\"baz\"}", + expected: labels.Matchers{ + mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), + mustNewMatcher(t, labels.MatchNotEqual, "bar", "baz"), + }, + }, { + name: "complex without quotes", + input: "{foo=bar,bar!=baz}", + expected: labels.Matchers{ + mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), + mustNewMatcher(t, labels.MatchNotEqual, "bar", "baz"), + }, + }, { + name: "complex without braces", + input: "foo=\"bar\",bar!=\"baz\"", + expected: labels.Matchers{ + mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), + mustNewMatcher(t, labels.MatchNotEqual, "bar", "baz"), + }, + }, { + name: "complex without braces or quotes", + input: "foo=bar,bar!=baz", + expected: labels.Matchers{ + mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), + mustNewMatcher(t, labels.MatchNotEqual, "bar", "baz"), + }, + }, { + name: "open brace", + input: "{", + error: "0:1: end of input: expected close brace", + }, { + name: "close brace", + input: "}", + error: "0:1: }: expected opening brace", + }, { + name: "no open brace", + input: "foo=\"bar\"}", + error: "0:10: }: expected opening brace", + }, { + name: "no close brace", + input: "{foo=\"bar\"", + error: "0:10: end of input: expected close brace", + }, { + name: "invalid operator", + input: "{foo=:\"bar\"}", + error: "5:6: :: invalid input: expected label value", + }, { + name: "another invalid operator", + input: "{foo%=\"bar\"}", + error: "4:5: %: invalid input: expected an operator such as '=', '!=', '=~' or '!~'", + }, { + name: "invalid escape sequence", + input: "{foo=\"bar\\w\"}", + error: "5:12: \"bar\\w\": invalid input", + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + matchers, err := Parse(test.input) + if test.error != "" { + require.EqualError(t, err, test.error) + } else { + require.Nil(t, err) + require.EqualValues(t, test.expected, matchers) + } + }) + } +} + +func mustNewMatcher(t *testing.T, op labels.MatchType, name, value string) *labels.Matcher { + m, err := labels.NewMatcher(op, name, value) + require.NoError(t, err) + return m +} diff --git a/matchers/parse/token.go b/matchers/parse/token.go new file mode 100644 index 0000000000..4fb021e3b1 --- /dev/null +++ b/matchers/parse/token.go @@ -0,0 +1,70 @@ +// Copyright 2023 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package parse + +import ( + "fmt" +) + +type TokenKind int + +const ( + TokenNone TokenKind = iota + TokenCloseBrace + TokenComma + TokenIdent + TokenOpenBrace + TokenOperator + TokenQuoted +) + +func (k TokenKind) String() string { + switch k { + case TokenCloseBrace: + return "CloseBrace" + case TokenComma: + return "Comma" + case TokenIdent: + return "Ident" + case TokenOpenBrace: + return "OpenBrace" + case TokenOperator: + return "Op" + case TokenQuoted: + return "Quoted" + default: + return "None" + } +} + +type Token struct { + Kind TokenKind + Value string + Position +} + +func (t Token) String() string { + return fmt.Sprintf("(%s) '%s'", t.Kind, t.Value) +} + +func IsNone(t Token) bool { + return t == Token{} +} + +type Position struct { + OffsetStart int // The start position in the input + OffsetEnd int // The end position in the input + ColumnStart int // The column number + ColumnEnd int // The end of the column +}