Add filter query language
Lexer, recursive-descent parser (AND/OR/NOT, parentheses, implicit AND, IN lists, size/age/date units) and an evaluator that computes filtered subtree sizes in two linear passes without double counting.
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result holds per-node filtered sizes: the bytes of matching content beneath
|
||||||
|
// each node. A matching directory contributes its whole subtree (once, even
|
||||||
|
// if descendants match too). Matched marks every node satisfying the query. Nodes added
|
||||||
|
// after the result was computed (live scan) have no entry and are treated as
|
||||||
|
// not matching until the filter is re-applied.
|
||||||
|
type Result struct {
|
||||||
|
Query *Query
|
||||||
|
Mode inventory.SizeMode
|
||||||
|
Sizes []int64 // per NodeID; len = tree size at evaluation time
|
||||||
|
Matched []bool // node itself matched
|
||||||
|
Count int64 // number of matching nodes
|
||||||
|
Total int64
|
||||||
|
Elapsed time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns the filtered size of id (0 if out of range).
|
||||||
|
func (r *Result) Size(id inventory.NodeID) int64 {
|
||||||
|
if int(id) < len(r.Sizes) {
|
||||||
|
return r.Sizes[id]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMatch reports whether id itself matched.
|
||||||
|
func (r *Result) IsMatch(id inventory.NodeID) bool {
|
||||||
|
return int(id) < len(r.Matched) && r.Matched[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply evaluates q over the whole tree. The tree must be read-locked by the
|
||||||
|
// caller. It relies on parents having smaller IDs than their children.
|
||||||
|
func Apply(ctx context.Context, t *inventory.Tree, q *Query, m inventory.SizeMode) (*Result, error) {
|
||||||
|
start := time.Now()
|
||||||
|
n := t.Len()
|
||||||
|
r := &Result{Query: q, Mode: m, Sizes: make([]int64, n), Matched: make([]bool, n)}
|
||||||
|
covered := make([]bool, n)
|
||||||
|
c := &Ctx{Tree: t, Now: time.Now().UnixNano()}
|
||||||
|
for i := range n {
|
||||||
|
if i&0xffff == 0 && ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
id := inventory.NodeID(i)
|
||||||
|
nd := t.Node(id)
|
||||||
|
c.ID, c.Node = id, nd
|
||||||
|
if i > 0 && q.Match(c) {
|
||||||
|
r.Matched[i] = true
|
||||||
|
covered[i] = true
|
||||||
|
r.Count++
|
||||||
|
}
|
||||||
|
if i > 0 && covered[nd.Parent] {
|
||||||
|
covered[i] = true
|
||||||
|
}
|
||||||
|
if covered[i] && nd.Flags&inventory.FlagHardlinkDup == 0 {
|
||||||
|
r.Sizes[i] = nd.Own(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := n - 1; i > 0; i-- {
|
||||||
|
r.Sizes[t.Node(inventory.NodeID(i)).Parent] += r.Sizes[i]
|
||||||
|
}
|
||||||
|
r.Total = r.Sizes[0]
|
||||||
|
r.Elapsed = time.Since(start)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ctx is the evaluation context for one node. The tree must be read-locked.
|
||||||
|
type Ctx struct {
|
||||||
|
Tree *inventory.Tree
|
||||||
|
ID inventory.NodeID
|
||||||
|
Node *inventory.Node
|
||||||
|
Now int64 // unix nanoseconds
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Ctx) str(f Field) string {
|
||||||
|
n := c.Node
|
||||||
|
switch f {
|
||||||
|
case FName:
|
||||||
|
return strings.ToLower(n.Name)
|
||||||
|
case FPath:
|
||||||
|
if c.path == "" {
|
||||||
|
c.path = strings.ToLower(c.Tree.Path(c.ID))
|
||||||
|
}
|
||||||
|
return c.path
|
||||||
|
case FExt:
|
||||||
|
return c.Tree.ExtName(n)
|
||||||
|
case FType:
|
||||||
|
return strings.ToLower(n.Kind.String())
|
||||||
|
case FCategory:
|
||||||
|
if n.IsDir() {
|
||||||
|
return "directory"
|
||||||
|
}
|
||||||
|
return strings.ToLower(n.Cat.String())
|
||||||
|
case FOwner:
|
||||||
|
return strings.ToLower(platform.UserName(n.UID))
|
||||||
|
case FGroup:
|
||||||
|
return strings.ToLower(platform.GroupName(n.GID))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Ctx) num(f Field) int64 {
|
||||||
|
n := c.Node
|
||||||
|
switch f {
|
||||||
|
case FSize:
|
||||||
|
return n.TotSize
|
||||||
|
case FAllocated:
|
||||||
|
return n.TotAlloc
|
||||||
|
case FAge:
|
||||||
|
return c.Now - n.MTime
|
||||||
|
case FModified:
|
||||||
|
return n.MTime
|
||||||
|
case FFiles:
|
||||||
|
return int64(n.Files)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type node interface{ eval(*Ctx) bool }
|
||||||
|
|
||||||
|
type andNode struct{ l, r node }
|
||||||
|
type orNode struct{ l, r node }
|
||||||
|
type notNode struct{ n node }
|
||||||
|
|
||||||
|
func (n andNode) eval(c *Ctx) bool { return n.l.eval(c) && n.r.eval(c) }
|
||||||
|
func (n orNode) eval(c *Ctx) bool { return n.l.eval(c) || n.r.eval(c) }
|
||||||
|
func (n notNode) eval(c *Ctx) bool { return !n.n.eval(c) }
|
||||||
|
|
||||||
|
type containsNode struct {
|
||||||
|
field Field
|
||||||
|
sub string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n containsNode) eval(c *Ctx) bool { return strings.Contains(c.str(n.field), n.sub) }
|
||||||
|
|
||||||
|
type globNode struct {
|
||||||
|
field Field
|
||||||
|
pat string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n globNode) eval(c *Ctx) bool {
|
||||||
|
ok, _ := filepath.Match(n.pat, c.str(n.field))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type reNode struct {
|
||||||
|
field Field
|
||||||
|
re *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n reNode) eval(c *Ctx) bool { return n.re.MatchString(c.str(n.field)) }
|
||||||
|
|
||||||
|
type eqNode struct {
|
||||||
|
field Field
|
||||||
|
set []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n eqNode) eval(c *Ctx) bool {
|
||||||
|
if n.field == FFlag {
|
||||||
|
for _, s := range n.set {
|
||||||
|
if c.Node.Flags&flagNames[s] != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v := c.str(n.field)
|
||||||
|
for _, s := range n.set {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type numNode struct {
|
||||||
|
field Field
|
||||||
|
op string
|
||||||
|
vals []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n numNode) eval(c *Ctx) bool {
|
||||||
|
if (n.field == FAge || n.field == FModified) && c.Node.MTime == 0 {
|
||||||
|
return false // unknown time never matches
|
||||||
|
}
|
||||||
|
v := c.num(n.field)
|
||||||
|
for _, x := range n.vals {
|
||||||
|
var ok bool
|
||||||
|
switch n.op {
|
||||||
|
case "=":
|
||||||
|
ok = v == x
|
||||||
|
case "!=":
|
||||||
|
ok = v != x
|
||||||
|
case ">":
|
||||||
|
ok = v > x
|
||||||
|
case ">=":
|
||||||
|
ok = v >= x
|
||||||
|
case "<":
|
||||||
|
ok = v < x
|
||||||
|
case "<=":
|
||||||
|
ok = v <= x
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match evaluates the query against one node.
|
||||||
|
func (q *Query) Match(c *Ctx) bool {
|
||||||
|
c.path = ""
|
||||||
|
return q.root.eval(c)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tree() *inventory.Tree {
|
||||||
|
t := inventory.New("/srv", inventory.KindDir)
|
||||||
|
vms := t.Add(0, "vms", inventory.KindDir)
|
||||||
|
cache := t.Add(0, "cache", inventory.KindDir)
|
||||||
|
add := func(p inventory.NodeID, name string, size int64, age time.Duration) inventory.NodeID {
|
||||||
|
id := t.Add(p, name, inventory.KindFile)
|
||||||
|
n := t.Node(id)
|
||||||
|
n.Size, n.Alloc = size, size
|
||||||
|
n.MTime = time.Now().Add(-age).UnixNano()
|
||||||
|
n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
add(vms, "ubuntu.qcow2", 80<<30, 400*24*time.Hour)
|
||||||
|
add(vms, "win.vmdk", 40<<30, time.Hour)
|
||||||
|
add(vms, "notes.txt", 1000, time.Hour)
|
||||||
|
add(cache, "blob.iso", 700<<20, 10*24*time.Hour)
|
||||||
|
add(cache, "x.tmp", 10, time.Hour)
|
||||||
|
t.Recompute()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(t *testing.T, tr *inventory.Tree, q string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
pq, err := Parse(q, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(%q): %v", q, err)
|
||||||
|
}
|
||||||
|
r, err := Apply(context.Background(), tr, pq, inventory.SizeLogical)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := map[string]bool{}
|
||||||
|
for i := range tr.Len() {
|
||||||
|
if r.IsMatch(inventory.NodeID(i)) {
|
||||||
|
out[tr.Node(inventory.NodeID(i)).Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueries(t *testing.T) {
|
||||||
|
tr := tree()
|
||||||
|
cases := map[string][]string{
|
||||||
|
"ubuntu": {"ubuntu.qcow2"},
|
||||||
|
"*.iso": {"blob.iso"},
|
||||||
|
"size > 1GB": {"vms", "ubuntu.qcow2", "win.vmdk"},
|
||||||
|
"size > 1GB AND type = file": {"ubuntu.qcow2", "win.vmdk"},
|
||||||
|
"ext = qcow2": {"ubuntu.qcow2"},
|
||||||
|
"size > 500MB AND ext IN (iso,qcow2,vmdk)": {"ubuntu.qcow2", "win.vmdk", "blob.iso"},
|
||||||
|
"path contains cache": {"cache", "blob.iso", "x.tmp"},
|
||||||
|
"age > 365d": {"ubuntu.qcow2"},
|
||||||
|
"NOT type = dir AND size < 1k": {"notes.txt", "x.tmp"},
|
||||||
|
"(ext = iso OR ext = tmp) type=file": {"blob.iso", "x.tmp"},
|
||||||
|
"category = vm": {"ubuntu.qcow2", "win.vmdk", "blob.iso"},
|
||||||
|
"name matches '^w.*k$'": {"win.vmdk"},
|
||||||
|
"ext != qcow2 && type = file && size > 1GB": {"win.vmdk"},
|
||||||
|
"name = 'notes.txt'": {"notes.txt"},
|
||||||
|
}
|
||||||
|
for q, want := range cases {
|
||||||
|
got := names(t, tr, q)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("%q: got %v want %v", q, got, want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range want {
|
||||||
|
if !got[w] {
|
||||||
|
t.Errorf("%q: got %v want %v", q, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilteredSizesNoDoubleCount(t *testing.T) {
|
||||||
|
tr := tree()
|
||||||
|
q, _ := Parse("name = vms OR ext = qcow2", time.Now())
|
||||||
|
r, _ := Apply(context.Background(), tr, q, inventory.SizeLogical)
|
||||||
|
if want := tr.Node(1).TotSize; r.Total != want {
|
||||||
|
t.Fatalf("total %d want %d (matching dir must not double count matching child)", r.Total, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseErrors(t *testing.T) {
|
||||||
|
for _, q := range []string{"", "size >", "size > banana", "(ext = iso", "ext = iso)", "size contains 5",
|
||||||
|
"name matches '['", "type = blob", "'unterminated", "a & b", "category = nope"} {
|
||||||
|
if _, err := Parse(q, time.Now()); err == nil {
|
||||||
|
t.Errorf("Parse(%q) should fail", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSize(t *testing.T) {
|
||||||
|
cases := map[string]int64{"1GB": 1e9, "1GiB": 1 << 30, "1.5k": 1536, "500MB": 5e8, "10": 10}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got, err := ParseSize(in); err != nil || got != want {
|
||||||
|
t.Errorf("ParseSize(%q)=%d,%v want %d", in, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzParse(f *testing.F) {
|
||||||
|
for _, s := range []string{"size > 1GB", "(a OR b) AND NOT c", "ext IN (a,b)", "'x", "name matches '.*'"} {
|
||||||
|
f.Add(s)
|
||||||
|
}
|
||||||
|
tr := tree()
|
||||||
|
f.Fuzz(func(t *testing.T, s string) {
|
||||||
|
q, err := Parse(s, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Apply(context.Background(), tr, q, inventory.SizeAllocated)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package filter implements the search/filter query language:
|
||||||
|
//
|
||||||
|
// query := or
|
||||||
|
// or := and { OR and }
|
||||||
|
// and := unary { [AND] unary } (juxtaposition means AND)
|
||||||
|
// unary := NOT unary | "(" query ")" | cmp | word
|
||||||
|
// cmp := field op value
|
||||||
|
// op := = | != | > | >= | < | <= | contains | matches | in | ~
|
||||||
|
// value := word | "quoted string" | "(" value { "," value } ")"
|
||||||
|
//
|
||||||
|
// A bare word matches the name: substring (case-insensitive), or a glob if it
|
||||||
|
// contains * ? or [. Parsing is independent of the UI and fully testable.
|
||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tokKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
tEOF tokKind = iota
|
||||||
|
tWord
|
||||||
|
tString
|
||||||
|
tOp
|
||||||
|
tLParen
|
||||||
|
tRParen
|
||||||
|
tComma
|
||||||
|
)
|
||||||
|
|
||||||
|
type token struct {
|
||||||
|
kind tokKind
|
||||||
|
val string
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func lex(s string) ([]token, error) {
|
||||||
|
var out []token
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
|
||||||
|
i++
|
||||||
|
case c == '(':
|
||||||
|
out = append(out, token{tLParen, "(", i})
|
||||||
|
i++
|
||||||
|
case c == ')':
|
||||||
|
out = append(out, token{tRParen, ")", i})
|
||||||
|
i++
|
||||||
|
case c == ',':
|
||||||
|
out = append(out, token{tComma, ",", i})
|
||||||
|
i++
|
||||||
|
case c == '"' || c == '\'':
|
||||||
|
q := c
|
||||||
|
start := i
|
||||||
|
i++
|
||||||
|
var b strings.Builder
|
||||||
|
for i < len(s) && s[i] != q {
|
||||||
|
if s[i] == '\\' && i+1 < len(s) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i >= len(s) {
|
||||||
|
return nil, fmt.Errorf("unterminated string at %d", start+1)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
out = append(out, token{tString, b.String(), start})
|
||||||
|
case strings.ContainsRune("=!<>~&|", rune(c)):
|
||||||
|
start := i
|
||||||
|
op := string(c)
|
||||||
|
if i+1 < len(s) && (s[i+1] == '=' || (c == '&' && s[i+1] == '&') || (c == '|' && s[i+1] == '|')) {
|
||||||
|
op += string(s[i+1])
|
||||||
|
}
|
||||||
|
i += len(op)
|
||||||
|
switch op {
|
||||||
|
case "&&":
|
||||||
|
out = append(out, token{tWord, "AND", start})
|
||||||
|
case "||":
|
||||||
|
out = append(out, token{tWord, "OR", start})
|
||||||
|
case "!":
|
||||||
|
out = append(out, token{tWord, "NOT", start})
|
||||||
|
case "&", "|":
|
||||||
|
return nil, fmt.Errorf("unexpected %q at %d", op, start+1)
|
||||||
|
case "==":
|
||||||
|
out = append(out, token{tOp, "=", start})
|
||||||
|
default:
|
||||||
|
out = append(out, token{tOp, op, start})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
start := i
|
||||||
|
for i < len(s) && !unicode.IsSpace(rune(s[i])) && !strings.ContainsRune("()=!<>~,\"'&|", rune(s[i])) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
out = append(out, token{tWord, s[start:i], start})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(out, token{tEOF, "", len(s)}), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field is a filterable attribute.
|
||||||
|
type Field int
|
||||||
|
|
||||||
|
const (
|
||||||
|
FName Field = iota
|
||||||
|
FPath
|
||||||
|
FExt
|
||||||
|
FType
|
||||||
|
FCategory
|
||||||
|
FSize
|
||||||
|
FAllocated
|
||||||
|
FAge
|
||||||
|
FModified
|
||||||
|
FOwner
|
||||||
|
FGroup
|
||||||
|
FFiles
|
||||||
|
FFlag
|
||||||
|
)
|
||||||
|
|
||||||
|
var fieldNames = map[string]Field{
|
||||||
|
"name": FName, "path": FPath, "ext": FExt, "extension": FExt, "type": FType, "kind": FType,
|
||||||
|
"category": FCategory, "cat": FCategory, "size": FSize, "allocated": FAllocated, "alloc": FAllocated,
|
||||||
|
"age": FAge, "modified": FModified, "mtime": FModified, "owner": FOwner, "user": FOwner, "group": FGroup,
|
||||||
|
"files": FFiles, "flag": FFlag, "is": FFlag,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields lists the field names for help text.
|
||||||
|
const Fields = "name path ext type category size allocated age modified owner group files flag"
|
||||||
|
|
||||||
|
// Query is a compiled filter.
|
||||||
|
type Query struct {
|
||||||
|
src string
|
||||||
|
root node
|
||||||
|
UsesPath bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Query) String() string { return q.src }
|
||||||
|
|
||||||
|
type parser struct {
|
||||||
|
toks []token
|
||||||
|
i int
|
||||||
|
q *Query
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse compiles a query. now anchors relative ages (pass time.Now()).
|
||||||
|
func Parse(s string, now time.Time) (*Query, error) {
|
||||||
|
toks, err := lex(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := &Query{src: s}
|
||||||
|
p := &parser{toks: toks, q: q, now: now}
|
||||||
|
if p.peek().kind == tEOF {
|
||||||
|
return nil, fmt.Errorf("empty query")
|
||||||
|
}
|
||||||
|
q.root, err = p.or()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if t := p.peek(); t.kind != tEOF {
|
||||||
|
return nil, fmt.Errorf("unexpected %q at %d", t.val, t.pos+1)
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) peek() token { return p.toks[p.i] }
|
||||||
|
func (p *parser) next() token {
|
||||||
|
t := p.toks[p.i]
|
||||||
|
if t.kind != tEOF {
|
||||||
|
p.i++
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKw(t token, kw string) bool { return t.kind == tWord && strings.EqualFold(t.val, kw) }
|
||||||
|
|
||||||
|
func (p *parser) or() (node, error) {
|
||||||
|
l, err := p.and()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for isKw(p.peek(), "OR") {
|
||||||
|
p.next()
|
||||||
|
r, err := p.and()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
l = orNode{l, r}
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) and() (node, error) {
|
||||||
|
l, err := p.unary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
t := p.peek()
|
||||||
|
if isKw(t, "AND") {
|
||||||
|
p.next()
|
||||||
|
} else if t.kind == tEOF || t.kind == tRParen || isKw(t, "OR") {
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
r, err := p.unary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
l = andNode{l, r}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) unary() (node, error) {
|
||||||
|
t := p.peek()
|
||||||
|
switch {
|
||||||
|
case isKw(t, "NOT"):
|
||||||
|
p.next()
|
||||||
|
n, err := p.unary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return notNode{n}, nil
|
||||||
|
case t.kind == tLParen:
|
||||||
|
p.next()
|
||||||
|
n, err := p.or()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.next().kind != tRParen {
|
||||||
|
return nil, fmt.Errorf("missing ) for ( at %d", t.pos+1)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
case t.kind == tWord || t.kind == tString:
|
||||||
|
p.next()
|
||||||
|
if f, ok := fieldNames[strings.ToLower(t.val)]; ok && t.kind == tWord && p.isOp(p.peek()) {
|
||||||
|
return p.cmp(f, p.next())
|
||||||
|
}
|
||||||
|
return nameMatch(t.val), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unexpected %q at %d", t.val, t.pos+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) isOp(t token) bool {
|
||||||
|
return t.kind == tOp || isKw(t, "contains") || isKw(t, "matches") || isKw(t, "in")
|
||||||
|
}
|
||||||
|
|
||||||
|
func nameMatch(v string) node {
|
||||||
|
lv := strings.ToLower(v)
|
||||||
|
if strings.ContainsAny(v, "*?[") {
|
||||||
|
return globNode{field: FName, pat: lv}
|
||||||
|
}
|
||||||
|
return containsNode{field: FName, sub: lv}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) values() ([]token, error) {
|
||||||
|
if p.peek().kind != tLParen {
|
||||||
|
t := p.next()
|
||||||
|
if t.kind != tWord && t.kind != tString {
|
||||||
|
return nil, fmt.Errorf("expected value at %d", t.pos+1)
|
||||||
|
}
|
||||||
|
return []token{t}, nil
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
var out []token
|
||||||
|
for {
|
||||||
|
t := p.next()
|
||||||
|
if t.kind != tWord && t.kind != tString {
|
||||||
|
return nil, fmt.Errorf("expected value at %d", t.pos+1)
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
switch n := p.next(); n.kind {
|
||||||
|
case tComma:
|
||||||
|
case tRParen:
|
||||||
|
return out, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("expected , or ) at %d", n.pos+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) cmp(f Field, opTok token) (node, error) {
|
||||||
|
op := strings.ToLower(opTok.val)
|
||||||
|
if op == "~" {
|
||||||
|
op = "matches"
|
||||||
|
}
|
||||||
|
vals, err := p.values()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if op != "in" && len(vals) != 1 {
|
||||||
|
return nil, fmt.Errorf("operator %s takes one value", op)
|
||||||
|
}
|
||||||
|
if f == FPath {
|
||||||
|
p.q.UsesPath = true
|
||||||
|
}
|
||||||
|
switch f {
|
||||||
|
case FSize, FAllocated, FAge, FModified, FFiles:
|
||||||
|
if op == "contains" || op == "matches" {
|
||||||
|
return nil, fmt.Errorf("%s cannot be used with numeric field", op)
|
||||||
|
}
|
||||||
|
var nums []int64
|
||||||
|
for _, v := range vals {
|
||||||
|
n, err := p.number(f, v.val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
if op == "in" {
|
||||||
|
op = "="
|
||||||
|
}
|
||||||
|
return numNode{field: f, op: op, vals: nums}, nil
|
||||||
|
}
|
||||||
|
// String-like fields.
|
||||||
|
switch op {
|
||||||
|
case "contains":
|
||||||
|
return containsNode{field: f, sub: strings.ToLower(vals[0].val)}, nil
|
||||||
|
case "matches":
|
||||||
|
re, err := regexp.Compile("(?i)" + vals[0].val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bad regular expression: %v", err)
|
||||||
|
}
|
||||||
|
return reNode{field: f, re: re}, nil
|
||||||
|
case "=", "!=", "in":
|
||||||
|
var n node
|
||||||
|
var set []string
|
||||||
|
for _, v := range vals {
|
||||||
|
s := strings.ToLower(v.val)
|
||||||
|
if f == FExt {
|
||||||
|
s = strings.TrimPrefix(s, ".")
|
||||||
|
}
|
||||||
|
if f == FCategory {
|
||||||
|
c, ok := inventory.ParseCategory(s)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown category %q", v.val)
|
||||||
|
}
|
||||||
|
s = strings.ToLower(c.String())
|
||||||
|
}
|
||||||
|
if f == FType {
|
||||||
|
s, err = normType(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f == FFlag {
|
||||||
|
if _, ok := flagNames[s]; !ok {
|
||||||
|
return nil, fmt.Errorf("unknown flag %q (sparse, hardlink, error, mount, incomplete, followed, broken)", v.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set = append(set, s)
|
||||||
|
}
|
||||||
|
if len(set) == 1 && strings.ContainsAny(set[0], "*?[") {
|
||||||
|
if _, err := filepath.Match(set[0], ""); err != nil {
|
||||||
|
return nil, fmt.Errorf("bad pattern %q", vals[0].val)
|
||||||
|
}
|
||||||
|
n = globNode{field: f, pat: set[0]}
|
||||||
|
} else {
|
||||||
|
n = eqNode{field: f, set: set}
|
||||||
|
}
|
||||||
|
if op == "!=" {
|
||||||
|
n = notNode{n}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("operator %s not valid for %s", op, opTok.val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normType(s string) (string, error) {
|
||||||
|
switch s {
|
||||||
|
case "f", "file":
|
||||||
|
return "file", nil
|
||||||
|
case "d", "dir", "directory":
|
||||||
|
return "directory", nil
|
||||||
|
case "l", "link", "symlink":
|
||||||
|
return "symlink", nil
|
||||||
|
case "other", "special":
|
||||||
|
return "special", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unknown type %q (file, dir, symlink, other)", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
var flagNames = map[string]inventory.Flags{
|
||||||
|
"sparse": inventory.FlagSparse, "hardlink": inventory.FlagHardlinked, "hardlinked": inventory.FlagHardlinked,
|
||||||
|
"error": inventory.FlagError, "mount": inventory.FlagMountPoint, "incomplete": inventory.FlagIncomplete,
|
||||||
|
"followed": inventory.FlagFollowed, "broken": inventory.FlagBrokenLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
// number parses sizes ("1.5GiB", "500MB", "10k"), ages ("365d", "2w") and
|
||||||
|
// dates ("2024-01-31"). Ages and dates are converted to modification times
|
||||||
|
// (unix nanoseconds) relative to p.now.
|
||||||
|
func (p *parser) number(f Field, s string) (int64, error) {
|
||||||
|
switch f {
|
||||||
|
case FSize, FAllocated:
|
||||||
|
return ParseSize(s)
|
||||||
|
case FFiles:
|
||||||
|
return strconv.ParseInt(s, 10, 64)
|
||||||
|
case FAge:
|
||||||
|
d, err := ParseAge(s)
|
||||||
|
return int64(d), err
|
||||||
|
case FModified:
|
||||||
|
for _, layout := range []string{"2006-01-02", "2006-01-02T15:04", "2006-01-02 15:04", time.RFC3339} {
|
||||||
|
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
||||||
|
return t.UnixNano(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d, err := ParseAge(s); err == nil {
|
||||||
|
return p.now.Add(-d).UnixNano(), nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("bad date %q (use YYYY-MM-DD or an age like 30d)", s)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("not numeric")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSize parses a size with optional unit. kB/MB/GB/TB are SI (powers of
|
||||||
|
// 1000); KiB/MiB/GiB/TiB are IEC (1024). Bare K/M/G/T follow the display
|
||||||
|
// setting (textutil.SI).
|
||||||
|
func ParseSize(s string) (int64, error) {
|
||||||
|
i := 0
|
||||||
|
for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return 0, fmt.Errorf("bad size %q", s)
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(s[:i], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("bad size %q", s)
|
||||||
|
}
|
||||||
|
unit := strings.ToLower(strings.TrimSpace(s[i:]))
|
||||||
|
bare := 1024.0
|
||||||
|
if textutil.SI {
|
||||||
|
bare = 1000
|
||||||
|
}
|
||||||
|
mult := map[string]float64{"": 1, "b": 1,
|
||||||
|
"k": bare, "m": bare * bare, "g": bare * bare * bare, "t": bare * bare * bare * bare, "p": bare * bare * bare * bare * bare,
|
||||||
|
"kb": 1e3, "mb": 1e6, "gb": 1e9, "tb": 1e12, "pb": 1e15,
|
||||||
|
"kib": 1 << 10, "mib": 1 << 20, "gib": 1 << 30, "tib": 1 << 40, "pib": 1 << 50}
|
||||||
|
m, ok := mult[unit]
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("unknown size unit %q", s[i:])
|
||||||
|
}
|
||||||
|
r := v * m
|
||||||
|
if r > 9e18 {
|
||||||
|
return 0, fmt.Errorf("size %q too large", s)
|
||||||
|
}
|
||||||
|
return int64(r), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAge parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y.
|
||||||
|
func ParseAge(s string) (time.Duration, error) {
|
||||||
|
i := 0
|
||||||
|
for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(s[:i], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("bad age %q", s)
|
||||||
|
}
|
||||||
|
day := 24 * time.Hour
|
||||||
|
units := map[string]time.Duration{"s": time.Second, "sec": time.Second, "min": time.Minute,
|
||||||
|
"h": time.Hour, "d": day, "": day, "w": 7 * day, "m": 30 * day, "mo": 30 * day, "y": 365 * day}
|
||||||
|
u, ok := units[strings.ToLower(s[i:])]
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("unknown age unit %q (s, min, h, d, w, mo, y)", s[i:])
|
||||||
|
}
|
||||||
|
return time.Duration(v * float64(u)), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user