aboutsummaryrefslogtreecommitdiffstats
path: root/query
diff options
context:
space:
mode:
authorMichael Muré <batolettre@gmail.com>2020-03-14 16:47:38 +0100
committerMichael Muré <batolettre@gmail.com>2020-03-28 17:13:27 +0100
commit5e4dc87ffec7f87bbf3ebfcf256777ad773e8450 (patch)
tree04553cfb7ab8ea279c7415586ce1d0fe5c819996 /query
parent58abc6b0a35b679ac0c34579ff1cb53c8fa71af4 (diff)
downloadgit-bug-5e4dc87ffec7f87bbf3ebfcf256777ad773e8450.tar.gz
cache: replace the all-in-one query parser by a complete one with AST/lexer/parser
Diffstat (limited to 'query')
-rw-r--r--query/ast/ast.go45
-rw-r--r--query/lexer.go71
-rw-r--r--query/lexer_test.go45
-rw-r--r--query/parser.go100
-rw-r--r--query/parser_test.go98
5 files changed, 359 insertions, 0 deletions
diff --git a/query/ast/ast.go b/query/ast/ast.go
new file mode 100644
index 00000000..fe77abf9
--- /dev/null
+++ b/query/ast/ast.go
@@ -0,0 +1,45 @@
+package ast
+
+import "github.com/MichaelMure/git-bug/bug"
+
+type Query struct {
+ Filters
+ OrderBy
+ OrderDirection
+}
+
+// NewQuery return an identity query with the default sorting (creation-desc).
+func NewQuery() *Query {
+ return &Query{
+ OrderBy: OrderByCreation,
+ OrderDirection: OrderDescending,
+ }
+}
+
+// Filters is a collection of Filter that implement a complex filter
+type Filters struct {
+ Status []bug.Status
+ Author []string
+ Actor []string
+ Participant []string
+ Label []string
+ Title []string
+ NoLabel bool
+}
+
+type OrderBy int
+
+const (
+ _ OrderBy = iota
+ OrderById
+ OrderByCreation
+ OrderByEdit
+)
+
+type OrderDirection int
+
+const (
+ _ OrderDirection = iota
+ OrderAscending
+ OrderDescending
+)
diff --git a/query/lexer.go b/query/lexer.go
new file mode 100644
index 00000000..ec05f44e
--- /dev/null
+++ b/query/lexer.go
@@ -0,0 +1,71 @@
+package query
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+type token struct {
+ qualifier string
+ value string
+}
+
+// TODO: this lexer implementation behave badly with unmatched quotes.
+// A hand written one would be better instead of relying on strings.FieldsFunc()
+
+// tokenize parse and break a input into tokens ready to be
+// interpreted later by a parser to get the semantic.
+func tokenize(query string) ([]token, error) {
+ fields := splitQuery(query)
+
+ var tokens []token
+ for _, field := range fields {
+ split := strings.Split(field, ":")
+ if len(split) != 2 {
+ return nil, fmt.Errorf("can't tokenize \"%s\"", field)
+ }
+
+ if len(split[0]) == 0 {
+ return nil, fmt.Errorf("can't tokenize \"%s\": empty qualifier", field)
+ }
+ if len(split[1]) == 0 {
+ return nil, fmt.Errorf("empty value for qualifier \"%s\"", split[0])
+ }
+
+ tokens = append(tokens, token{
+ qualifier: split[0],
+ value: removeQuote(split[1]),
+ })
+ }
+ return tokens, nil
+}
+
+func splitQuery(query string) []string {
+ lastQuote := rune(0)
+ f := func(c rune) bool {
+ switch {
+ case c == lastQuote:
+ lastQuote = rune(0)
+ return false
+ case lastQuote != rune(0):
+ return false
+ case unicode.In(c, unicode.Quotation_Mark):
+ lastQuote = c
+ return false
+ default:
+ return unicode.IsSpace(c)
+ }
+ }
+
+ return strings.FieldsFunc(query, f)
+}
+
+func removeQuote(field string) string {
+ if len(field) >= 2 {
+ if field[0] == '"' && field[len(field)-1] == '"' {
+ return field[1 : len(field)-1]
+ }
+ }
+ return field
+}
diff --git a/query/lexer_test.go b/query/lexer_test.go
new file mode 100644
index 00000000..922e3fc9
--- /dev/null
+++ b/query/lexer_test.go
@@ -0,0 +1,45 @@
+package query
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTokenize(t *testing.T) {
+ var tests = []struct {
+ input string
+ tokens []token
+ }{
+ {"gibberish", nil},
+ {"status:", nil},
+ {":value", nil},
+
+ {"status:open", []token{{"status", "open"}}},
+ {"status:closed", []token{{"status", "closed"}}},
+
+ {"author:rene", []token{{"author", "rene"}}},
+ {`author:"René Descartes"`, []token{{"author", "René Descartes"}}},
+
+ {
+ `status:open status:closed author:rene author:"René Descartes"`,
+ []token{
+ {"status", "open"},
+ {"status", "closed"},
+ {"author", "rene"},
+ {"author", "René Descartes"},
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ tokens, err := tokenize(tc.input)
+ if tc.tokens == nil {
+ assert.Error(t, err)
+ assert.Nil(t, tokens)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tc.tokens, tokens)
+ }
+ }
+}
diff --git a/query/parser.go b/query/parser.go
new file mode 100644
index 00000000..89893b60
--- /dev/null
+++ b/query/parser.go
@@ -0,0 +1,100 @@
+package query
+
+import (
+ "fmt"
+
+ "github.com/MichaelMure/git-bug/bug"
+ "github.com/MichaelMure/git-bug/query/ast"
+)
+
+// Parse parse a query DSL
+//
+// Ex: "status:open author:descartes sort:edit-asc"
+//
+// Supported filter qualifiers and syntax are described in docs/queries.md
+func Parse(query string) (*ast.Query, error) {
+ tokens, err := tokenize(query)
+ if err != nil {
+ return nil, err
+ }
+
+ q := &ast.Query{
+ OrderBy: ast.OrderByCreation,
+ OrderDirection: ast.OrderDescending,
+ }
+ sortingDone := false
+
+ for _, t := range tokens {
+ switch t.qualifier {
+ case "status", "state":
+ status, err := bug.StatusFromString(t.value)
+ if err != nil {
+ return nil, err
+ }
+ q.Status = append(q.Status, status)
+ case "author":
+ q.Author = append(q.Author, t.value)
+ case "actor":
+ q.Actor = append(q.Actor, t.value)
+ case "participant":
+ q.Participant = append(q.Participant, t.value)
+ case "label":
+ q.Label = append(q.Label, t.value)
+ case "title":
+ q.Title = append(q.Title, t.value)
+ case "no":
+ switch t.value {
+ case "label":
+ q.NoLabel = true
+ default:
+ return nil, fmt.Errorf("unknown \"no\" filter \"%s\"", t.value)
+ }
+ case "sort":
+ if sortingDone {
+ return nil, fmt.Errorf("multiple sorting")
+ }
+ err = parseSorting(q, t.value)
+ if err != nil {
+ return nil, err
+ }
+ sortingDone = true
+
+ default:
+ return nil, fmt.Errorf("unknown qualifier \"%s\"", t.qualifier)
+ }
+ }
+ return q, nil
+}
+
+func parseSorting(q *ast.Query, value string) error {
+ switch value {
+ // default ASC
+ case "id-desc":
+ q.OrderBy = ast.OrderById
+ q.OrderDirection = ast.OrderDescending
+ case "id", "id-asc":
+ q.OrderBy = ast.OrderById
+ q.OrderDirection = ast.OrderAscending
+
+ // default DESC
+ case "creation", "creation-desc":
+ q.OrderBy = ast.OrderByCreation
+ q.OrderDirection = ast.OrderDescending
+ case "creation-asc":
+ q.OrderBy = ast.OrderByCreation
+ q.OrderDirection = ast.OrderAscending
+
+ // default DESC
+ case "edit", "edit-desc":
+ q.OrderBy = ast.OrderByEdit
+ q.OrderDirection = ast.OrderDescending
+ case "edit-asc":
+ q.OrderBy = ast.OrderByEdit
+ q.OrderDirection = ast.OrderAscending
+
+ default:
+ return fmt.Errorf("unknown sorting %s", value)
+ }
+
+ return nil
+}
diff --git a/query/parser_test.go b/query/parser_test.go
new file mode 100644
index 00000000..065e647a
--- /dev/null
+++ b/query/parser_test.go
@@ -0,0 +1,98 @@
+package query
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/MichaelMure/git-bug/bug"
+ "github.com/MichaelMure/git-bug/query/ast"
+)
+
+func TestParse(t *testing.T) {
+ var tests = []struct {
+ input string
+ output *ast.Query
+ }{
+ {"gibberish", nil},
+ {"status:", nil},
+ {":value", nil},
+
+ {"status:open", &ast.Query{
+ Filters: ast.Filters{Status: []bug.Status{bug.OpenStatus}},
+ }},
+ {"status:closed", &ast.Query{
+ Filters: ast.Filters{Status: []bug.Status{bug.ClosedStatus}},
+ }},
+ {"status:unknown", nil},
+
+ {"author:rene", &ast.Query{
+ Filters: ast.Filters{Author: []string{"rene"}},
+ }},
+ {`author:"René Descartes"`, &ast.Query{
+ Filters: ast.Filters{Author: []string{"René Descartes"}},
+ }},
+
+ {"actor:bernhard", &ast.Query{
+ Filters: ast.Filters{Actor: []string{"bernhard"}},
+ }},
+ {"participant:leonhard", &ast.Query{
+ Filters: ast.Filters{Participant: []string{"leonhard"}},
+ }},
+
+ {"label:hello", &ast.Query{
+ Filters: ast.Filters{Label: []string{"hello"}},
+ }},
+ {`label:"Good first issue"`, &ast.Query{
+ Filters: ast.Filters{Label: []string{"Good first issue"}},
+ }},
+
+ {"title:titleOne", &ast.Query{
+ Filters: ast.Filters{Title: []string{"titleOne"}},
+ }},
+ {`title:"Bug titleTwo"`, &ast.Query{
+ Filters: ast.Filters{Title: []string{"Bug titleTwo"}},
+ }},
+
+ {"no:label", &ast.Query{
+ Filters: ast.Filters{NoLabel: true},
+ }},
+
+ {"sort:edit", &ast.Query{
+ OrderBy: ast.OrderByEdit,
+ }},
+ {"sort:unknown", nil},
+
+ {`status:open author:"René Descartes" participant:leonhard label:hello label:"Good first issue" sort:edit-desc`,
+ &ast.Query{
+ Filters: ast.Filters{
+ Status: []bug.Status{bug.OpenStatus},
+ Author: []string{"René Descartes"},
+ Participant: []string{"leonhard"},
+ Label: []string{"hello", "Good first issue"},
+ },
+ OrderBy: ast.OrderByEdit,
+ OrderDirection: ast.OrderDescending,
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.input, func(t *testing.T) {
+ query, err := Parse(tc.input)
+ if tc.output == nil {
+ assert.Error(t, err)
+ assert.Nil(t, query)
+ } else {
+ assert.NoError(t, err)
+ if tc.output.OrderBy != 0 {
+ assert.Equal(t, tc.output.OrderBy, query.OrderBy)
+ }
+ if tc.output.OrderDirection != 0 {
+ assert.Equal(t, tc.output.OrderDirection, query.OrderDirection)
+ }
+ assert.Equal(t, tc.output.Filters, query.Filters)
+ }
+ })
+ }
+}