cache: replace the all-in-one query parser by a complete one with AST/lexer/parser

Michael Muré created

Change summary

cache/filter.go           |  47 ++++++++--
cache/query.go            | 172 -----------------------------------------
cache/query_test.go       |  41 ---------
cache/repo_cache.go       |  20 +++-
cache/repo_cache_test.go  |   5 
cache/sorting.go          |  18 ----
commands/ls.go            |  57 +++++-------
graphql/resolvers/repo.go |  13 +-
query/ast/ast.go          |  45 ++++++++++
query/lexer.go            |  71 ++++++++++++++++
query/lexer_test.go       |  45 ++++++++++
query/parser.go           | 100 +++++++++++++++++++++++
query/parser_test.go      |  98 +++++++++++++++++++++++
termui/bug_table.go       |   8 +
termui/termui.go          |   5 
15 files changed, 451 insertions(+), 294 deletions(-)

Detailed changes

cache/filter.go 🔗

@@ -5,6 +5,7 @@ import (
 
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/query/ast"
 )
 
 // resolver has the resolving functions needed by filters.
@@ -17,15 +18,10 @@ type resolver interface {
 type Filter func(excerpt *BugExcerpt, resolver resolver) bool
 
 // StatusFilter return a Filter that match a bug status
-func StatusFilter(query string) (Filter, error) {
-	status, err := bug.StatusFromString(query)
-	if err != nil {
-		return nil, err
-	}
-
+func StatusFilter(status bug.Status) Filter {
 	return func(excerpt *BugExcerpt, resolver resolver) bool {
 		return excerpt.Status == status
-	}, nil
+	}
 }
 
 // AuthorFilter return a Filter that match a bug author
@@ -116,8 +112,8 @@ func NoLabelFilter() Filter {
 	}
 }
 
-// Filters is a collection of Filter that implement a complex filter
-type Filters struct {
+// Matcher is a collection of Filter that implement a complex filter
+type Matcher struct {
 	Status      []Filter
 	Author      []Filter
 	Actor       []Filter
@@ -127,8 +123,35 @@ type Filters struct {
 	NoFilters   []Filter
 }
 
+// compileMatcher transform an ast.Filters into a specialized matcher
+// for the cache.
+func compileMatcher(filters ast.Filters) *Matcher {
+	result := &Matcher{}
+
+	for _, value := range filters.Status {
+		result.Status = append(result.Status, StatusFilter(value))
+	}
+	for _, value := range filters.Author {
+		result.Author = append(result.Author, AuthorFilter(value))
+	}
+	for _, value := range filters.Actor {
+		result.Actor = append(result.Actor, ActorFilter(value))
+	}
+	for _, value := range filters.Participant {
+		result.Participant = append(result.Participant, ParticipantFilter(value))
+	}
+	for _, value := range filters.Label {
+		result.Label = append(result.Label, LabelFilter(value))
+	}
+	for _, value := range filters.Title {
+		result.Title = append(result.Title, TitleFilter(value))
+	}
+
+	return result
+}
+
 // Match check if a bug match the set of filters
-func (f *Filters) Match(excerpt *BugExcerpt, resolver resolver) bool {
+func (f *Matcher) Match(excerpt *BugExcerpt, resolver resolver) bool {
 	if match := f.orMatch(f.Status, excerpt, resolver); !match {
 		return false
 	}
@@ -161,7 +184,7 @@ func (f *Filters) Match(excerpt *BugExcerpt, resolver resolver) bool {
 }
 
 // Check if any of the filters provided match the bug
-func (*Filters) orMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool {
+func (*Matcher) orMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool {
 	if len(filters) == 0 {
 		return true
 	}
@@ -175,7 +198,7 @@ func (*Filters) orMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver
 }
 
 // Check if all of the filters provided match the bug
-func (*Filters) andMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool {
+func (*Matcher) andMatch(filters []Filter, excerpt *BugExcerpt, resolver resolver) bool {
 	if len(filters) == 0 {
 		return true
 	}

cache/query.go 🔗

@@ -1,172 +0,0 @@
-package cache
-
-import (
-	"fmt"
-	"strings"
-	"unicode"
-)
-
-type Query struct {
-	Filters
-	OrderBy
-	OrderDirection
-}
-
-// Return an identity query with default sorting (creation-desc)
-func NewQuery() *Query {
-	return &Query{
-		OrderBy:        OrderByCreation,
-		OrderDirection: OrderDescending,
-	}
-}
-
-// ParseQuery parse a query DSL
-//
-// Ex: "status:open author:descartes sort:edit-asc"
-//
-// Supported filter qualifiers and syntax are described in docs/queries.md
-func ParseQuery(query string) (*Query, error) {
-	fields := splitQuery(query)
-
-	result := &Query{
-		OrderBy:        OrderByCreation,
-		OrderDirection: OrderDescending,
-	}
-
-	sortingDone := false
-
-	for _, field := range fields {
-		split := strings.Split(field, ":")
-		if len(split) != 2 {
-			return nil, fmt.Errorf("can't parse \"%s\"", field)
-		}
-
-		qualifierName := split[0]
-		qualifierQuery := removeQuote(split[1])
-
-		switch qualifierName {
-		case "status", "state":
-			f, err := StatusFilter(qualifierQuery)
-			if err != nil {
-				return nil, err
-			}
-			result.Status = append(result.Status, f)
-
-		case "author":
-			f := AuthorFilter(qualifierQuery)
-			result.Author = append(result.Author, f)
-
-		case "actor":
-			f := ActorFilter(qualifierQuery)
-			result.Actor = append(result.Actor, f)
-
-		case "participant":
-			f := ParticipantFilter(qualifierQuery)
-			result.Participant = append(result.Participant, f)
-
-		case "label":
-			f := LabelFilter(qualifierQuery)
-			result.Label = append(result.Label, f)
-
-		case "title":
-			f := TitleFilter(qualifierQuery)
-			result.Title = append(result.Title, f)
-
-		case "no":
-			err := result.parseNoFilter(qualifierQuery)
-			if err != nil {
-				return nil, err
-			}
-
-		case "sort":
-			if sortingDone {
-				return nil, fmt.Errorf("multiple sorting")
-			}
-
-			err := result.parseSorting(qualifierQuery)
-			if err != nil {
-				return nil, err
-			}
-
-			sortingDone = true
-
-		default:
-			return nil, fmt.Errorf("unknown qualifier name %s", qualifierName)
-		}
-	}
-
-	return result, 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
-}
-
-func (q *Query) parseNoFilter(query string) error {
-	switch query {
-	case "label":
-		q.NoFilters = append(q.NoFilters, NoLabelFilter())
-	default:
-		return fmt.Errorf("unknown \"no\" filter %s", query)
-	}
-
-	return nil
-}
-
-func (q *Query) parseSorting(query string) error {
-	switch query {
-	// default ASC
-	case "id-desc":
-		q.OrderBy = OrderById
-		q.OrderDirection = OrderDescending
-	case "id", "id-asc":
-		q.OrderBy = OrderById
-		q.OrderDirection = OrderAscending
-
-	// default DESC
-	case "creation", "creation-desc":
-		q.OrderBy = OrderByCreation
-		q.OrderDirection = OrderDescending
-	case "creation-asc":
-		q.OrderBy = OrderByCreation
-		q.OrderDirection = OrderAscending
-
-	// default DESC
-	case "edit", "edit-desc":
-		q.OrderBy = OrderByEdit
-		q.OrderDirection = OrderDescending
-	case "edit-asc":
-		q.OrderBy = OrderByEdit
-		q.OrderDirection = OrderAscending
-
-	default:
-		return fmt.Errorf("unknown sorting %s", query)
-	}
-
-	return nil
-}

cache/query_test.go 🔗

@@ -1,41 +0,0 @@
-package cache
-
-import "testing"
-
-func TestQueryParse(t *testing.T) {
-
-	var tests = []struct {
-		input string
-		ok    bool
-	}{
-		{"gibberish", false},
-
-		{"status:", false},
-
-		{"status:open", true},
-		{"status:closed", true},
-		{"status:unknown", false},
-
-		{"author:rene", true},
-		{`author:"René Descartes"`, true},
-
-		{"actor:bernhard", true},
-		{"participant:leonhard", true},
-
-		{"label:hello", true},
-		{`label:"Good first issue"`, true},
-
-		{"title:titleOne", true},
-		{`title:"Bug titleTwo"`, true},
-
-		{"sort:edit", true},
-		{"sort:unknown", false},
-	}
-
-	for _, test := range tests {
-		_, err := ParseQuery(test.input)
-		if (err == nil) != test.ok {
-			t.Fatalf("Unexpected parse result, expected: %v, err: %v", test.ok, err)
-		}
-	}
-}

cache/repo_cache.go 🔗

@@ -18,6 +18,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/query/ast"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/git"
 	"github.com/MichaelMure/git-bug/util/process"
@@ -525,7 +526,7 @@ func (c *RepoCache) resolveBugMatcher(f func(*BugExcerpt) bool) (entity.Id, erro
 }
 
 // QueryBugs return the id of all Bug matching the given Query
-func (c *RepoCache) QueryBugs(query *Query) []entity.Id {
+func (c *RepoCache) QueryBugs(query *ast.Query) []entity.Id {
 	c.muBug.RLock()
 	defer c.muBug.RUnlock()
 
@@ -533,10 +534,12 @@ func (c *RepoCache) QueryBugs(query *Query) []entity.Id {
 		return c.AllBugsIds()
 	}
 
+	matcher := compileMatcher(query.Filters)
+
 	var filtered []*BugExcerpt
 
 	for _, excerpt := range c.bugExcerpts {
-		if query.Match(excerpt, c) {
+		if matcher.Match(excerpt, c) {
 			filtered = append(filtered, excerpt)
 		}
 	}
@@ -544,18 +547,23 @@ func (c *RepoCache) QueryBugs(query *Query) []entity.Id {
 	var sorter sort.Interface
 
 	switch query.OrderBy {
-	case OrderById:
+	case ast.OrderById:
 		sorter = BugsById(filtered)
-	case OrderByCreation:
+	case ast.OrderByCreation:
 		sorter = BugsByCreationTime(filtered)
-	case OrderByEdit:
+	case ast.OrderByEdit:
 		sorter = BugsByEditTime(filtered)
 	default:
 		panic("missing sort type")
 	}
 
-	if query.OrderDirection == OrderDescending {
+	switch query.OrderDirection {
+	case ast.OrderAscending:
+		// Nothing to do
+	case ast.OrderDescending:
 		sorter = sort.Reverse(sorter)
+	default:
+		panic("missing sort direction")
 	}
 
 	sort.Sort(sorter)

cache/repo_cache_test.go 🔗

@@ -5,6 +5,7 @@ import (
 
 	"github.com/stretchr/testify/require"
 
+	"github.com/MichaelMure/git-bug/query"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
@@ -68,9 +69,9 @@ func TestCache(t *testing.T) {
 	require.NoError(t, err)
 
 	// Querying
-	query, err := ParseQuery("status:open author:descartes sort:edit-asc")
+	q, err := query.Parse("status:open author:descartes sort:edit-asc")
 	require.NoError(t, err)
-	require.Len(t, cache.QueryBugs(query), 2)
+	require.Len(t, cache.QueryBugs(q), 2)
 
 	// Close
 	require.NoError(t, cache.Close())

cache/sorting.go 🔗

@@ -1,18 +0,0 @@
-package cache
-
-type OrderBy int
-
-const (
-	_ OrderBy = iota
-	OrderById
-	OrderByCreation
-	OrderByEdit
-)
-
-type OrderDirection int
-
-const (
-	_ OrderDirection = iota
-	OrderAscending
-	OrderDescending
-)

commands/ls.go 🔗

@@ -7,7 +7,10 @@ import (
 	text "github.com/MichaelMure/go-term-text"
 	"github.com/spf13/cobra"
 
+	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/query"
+	"github.com/MichaelMure/git-bug/query/ast"
 	"github.com/MichaelMure/git-bug/util/colors"
 	"github.com/MichaelMure/git-bug/util/interrupt"
 )
@@ -32,21 +35,21 @@ func runLsBug(cmd *cobra.Command, args []string) error {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	var query *cache.Query
+	var q *ast.Query
 	if len(args) >= 1 {
-		query, err = cache.ParseQuery(strings.Join(args, " "))
+		q, err = query.Parse(strings.Join(args, " "))
 
 		if err != nil {
 			return err
 		}
 	} else {
-		query, err = lsQueryFromFlags()
+		q, err = lsQueryFromFlags()
 		if err != nil {
 			return err
 		}
 	}
 
-	allIds := backend.QueryBugs(query)
+	allIds := backend.QueryBugs(q)
 
 	for _, id := range allIds {
 		b, err := backend.ResolveBugExcerpt(id)
@@ -96,47 +99,37 @@ func runLsBug(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
-// Transform the command flags into a query
-func lsQueryFromFlags() (*cache.Query, error) {
-	query := cache.NewQuery()
+// Transform the command flags into an ast.Query
+func lsQueryFromFlags() (*ast.Query, error) {
+	q := ast.NewQuery()
 
-	for _, status := range lsStatusQuery {
-		f, err := cache.StatusFilter(status)
+	for _, str := range lsStatusQuery {
+		status, err := bug.StatusFromString(str)
 		if err != nil {
 			return nil, err
 		}
-		query.Status = append(query.Status, f)
+		q.Status = append(q.Status, status)
 	}
-
 	for _, title := range lsTitleQuery {
-		f := cache.TitleFilter(title)
-		query.Title = append(query.Title, f)
+		q.Title = append(q.Title, title)
 	}
-
 	for _, author := range lsAuthorQuery {
-		f := cache.AuthorFilter(author)
-		query.Author = append(query.Author, f)
+		q.Author = append(q.Author, author)
 	}
-
 	for _, actor := range lsActorQuery {
-		f := cache.ActorFilter(actor)
-		query.Actor = append(query.Actor, f)
+		q.Actor = append(q.Actor, actor)
 	}
-
 	for _, participant := range lsParticipantQuery {
-		f := cache.ParticipantFilter(participant)
-		query.Participant = append(query.Participant, f)
+		q.Participant = append(q.Participant, participant)
 	}
-
 	for _, label := range lsLabelQuery {
-		f := cache.LabelFilter(label)
-		query.Label = append(query.Label, f)
+		q.Label = append(q.Label, label)
 	}
 
 	for _, no := range lsNoQuery {
 		switch no {
 		case "label":
-			query.NoFilters = append(query.NoFilters, cache.NoLabelFilter())
+			q.NoLabel = true
 		default:
 			return nil, fmt.Errorf("unknown \"no\" filter %s", no)
 		}
@@ -144,25 +137,25 @@ func lsQueryFromFlags() (*cache.Query, error) {
 
 	switch lsSortBy {
 	case "id":
-		query.OrderBy = cache.OrderById
+		q.OrderBy = ast.OrderById
 	case "creation":
-		query.OrderBy = cache.OrderByCreation
+		q.OrderBy = ast.OrderByCreation
 	case "edit":
-		query.OrderBy = cache.OrderByEdit
+		q.OrderBy = ast.OrderByEdit
 	default:
 		return nil, fmt.Errorf("unknown sort flag %s", lsSortBy)
 	}
 
 	switch lsSortDirection {
 	case "asc":
-		query.OrderDirection = cache.OrderAscending
+		q.OrderDirection = ast.OrderAscending
 	case "desc":
-		query.OrderDirection = cache.OrderDescending
+		q.OrderDirection = ast.OrderDescending
 	default:
 		return nil, fmt.Errorf("unknown sort direction %s", lsSortDirection)
 	}
 
-	return query, nil
+	return q, nil
 }
 
 var lsCmd = &cobra.Command{

graphql/resolvers/repo.go 🔗

@@ -4,11 +4,12 @@ import (
 	"context"
 
 	"github.com/MichaelMure/git-bug/bug"
-	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/graphql/connections"
 	"github.com/MichaelMure/git-bug/graphql/graph"
 	"github.com/MichaelMure/git-bug/graphql/models"
+	"github.com/MichaelMure/git-bug/query"
+	"github.com/MichaelMure/git-bug/query/ast"
 )
 
 var _ graph.RepositoryResolver = &repoResolver{}
@@ -28,19 +29,19 @@ func (repoResolver) AllBugs(_ context.Context, obj *models.Repository, after *st
 		Last:   last,
 	}
 
-	var query *cache.Query
+	var q *ast.Query
 	if queryStr != nil {
-		query2, err := cache.ParseQuery(*queryStr)
+		query2, err := query.Parse(*queryStr)
 		if err != nil {
 			return nil, err
 		}
-		query = query2
+		q = query2
 	} else {
-		query = cache.NewQuery()
+		q = ast.NewQuery()
 	}
 
 	// Simply pass a []string with the ids to the pagination algorithm
-	source := obj.Repo.QueryBugs(query)
+	source := obj.Repo.QueryBugs(q)
 
 	// The edger create a custom edge holding just the id
 	edger := func(id entity.Id, offset int) connections.Edge {

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
+)

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
+}

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)
+		}
+	}
+}

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
+}

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)
+			}
+		})
+	}
+}

termui/bug_table.go 🔗

@@ -12,6 +12,8 @@ import (
 
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
+	"github.com/MichaelMure/git-bug/query"
+	"github.com/MichaelMure/git-bug/query/ast"
 	"github.com/MichaelMure/git-bug/util/colors"
 )
 
@@ -26,7 +28,7 @@ const defaultQuery = "status:open"
 type bugTable struct {
 	repo         *cache.RepoCache
 	queryStr     string
-	query        *cache.Query
+	query        *ast.Query
 	allIds       []entity.Id
 	excerpts     []*cache.BugExcerpt
 	pageCursor   int
@@ -34,14 +36,14 @@ type bugTable struct {
 }
 
 func newBugTable(c *cache.RepoCache) *bugTable {
-	query, err := cache.ParseQuery(defaultQuery)
+	q, err := query.Parse(defaultQuery)
 	if err != nil {
 		panic(err)
 	}
 
 	return &bugTable{
 		repo:         c,
-		query:        query,
+		query:        q,
 		queryStr:     defaultQuery,
 		pageCursor:   0,
 		selectCursor: 0,

termui/termui.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/input"
+	"github.com/MichaelMure/git-bug/query"
 )
 
 var errTerminateMainloop = errors.New("terminate gocui mainloop")
@@ -336,12 +337,12 @@ func editQueryWithEditor(bt *bugTable) error {
 
 	bt.queryStr = queryStr
 
-	query, err := cache.ParseQuery(queryStr)
+	q, err := query.Parse(queryStr)
 
 	if err != nil {
 		ui.msgPopup.Activate(msgPopupErrorTitle, err.Error())
 	} else {
-		bt.query = query
+		bt.query = q
 	}
 
 	initGui(nil)