v0.1.0 initial commit

This commit is contained in:
2026-08-06 10:18:22 -04:00
commit 28e42523e1
31 changed files with 5055 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
package filter
import "github.com/lixenwraith/vif-log/internal/logfile"
func init() {
Register(Desc{Kind: "fields", Help: "regexp over the parsed fields, smart-case", New: newFields})
}
// Fields applies a regex to the fields column. This forces disk reads for
// surviving rows to evaluate the parsed JSON, so it evaluates last.
type Fields struct {
Query string
pat Pattern
}
func newFields(arg string) (Filter, error) {
p, err := NewPattern(arg)
if err != nil {
return nil, err
}
return &Fields{Query: arg, pat: p}, nil
}
func (f *Fields) Kind() string { return "fields" }
func (f *Fields) Needs() Need {
if f.pat.Empty() {
return 0
}
return NeedFields
}
func (f *Fields) Match(c *Ctx) bool {
if f.pat.Empty() {
return true
}
// Scopes the regex exactly to the rendered "key=value" string,
// excluding the discriminator (msg).
return f.pat.MatchBytes(c.Record().ColumnBytes(logfile.ColFields))
}
func (f *Fields) Label() string {
if f.pat.Empty() {
return ""
}
return "fields:" + f.Query
}
+198
View File
@@ -0,0 +1,198 @@
package filter
import (
"fmt"
"sort"
"github.com/lixenwraith/vif-log/internal/logfile"
)
// Need declares what a filter must touch beyond the index row.
type Need uint8
const (
NeedRaw Need = 1 << iota // raw line bytes
NeedFields // parsed fields
)
// Ctx is the per-record evaluation context. Raw bytes and parsed fields are
// fetched lazily, so index-only predicates never touch the file.
type Ctx struct {
Idx *logfile.Index
Meta logfile.Meta
I int
rd *logfile.Reader
raw []byte
rec logfile.Record
rawOK bool
recOK bool
}
// Bind attaches the index and the line reader used for raw access.
func (c *Ctx) Bind(idx *logfile.Index, rd *logfile.Reader) { c.Idx, c.rd = idx, rd }
// Reset points the context at record i.
func (c *Ctx) Reset(i int, m logfile.Meta) {
c.I, c.Meta = i, m
c.raw, c.rawOK, c.recOK = nil, false, false
}
// Raw returns the record's line bytes, nil when unavailable.
func (c *Ctx) Raw() []byte {
if !c.rawOK {
c.rawOK = true
if c.rd != nil {
c.raw, _ = c.rd.Line(c.Meta)
}
}
return c.raw
}
// Record returns the parsed record.
func (c *Ctx) Record() *logfile.Record {
if !c.recOK {
c.recOK = true
c.rec.Parse(c.Meta, c.Raw())
}
return &c.rec
}
// Filter is one predicate in the stack.
type Filter interface {
Kind() string
Label() string
Needs() Need
Match(*Ctx) bool
}
// Entry wraps a filter with the stack-level toggles, so negation and disabling
// need no per-filter code.
type Entry struct {
F Filter
Enabled bool
Negate bool
}
// Stack is the composed filter chain, evaluated cheapest-first.
type Stack struct {
Entries []Entry
order []int
needs Need
}
// Compile recomputes the evaluation order; call after mutating Entries.
func (s *Stack) Compile() {
s.order = s.order[:0]
s.needs = 0
for i, e := range s.Entries {
if !e.Enabled {
continue
}
s.order = append(s.order, i)
s.needs |= e.F.Needs()
}
// Index-only predicates first: raw readers then only see survivors.
sort.SliceStable(s.order, func(a, b int) bool {
return s.Entries[s.order[a]].F.Needs() < s.Entries[s.order[b]].F.Needs()
})
}
// Needs reports the union of the enabled filters' requirements.
func (s *Stack) Needs() Need { return s.needs }
// Match evaluates the enabled filters in cost order.
func (s *Stack) Match(c *Ctx) bool {
for _, i := range s.order {
e := s.Entries[i]
if e.F.Match(c) == e.Negate {
return false
}
}
return true
}
// Add appends an enabled filter and recompiles.
func (s *Stack) Add(f Filter) {
s.Entries = append(s.Entries, Entry{F: f, Enabled: true})
s.Compile()
}
// Find returns the first entry of the given kind.
func (s *Stack) Find(kind string) (*Entry, bool) {
for i := range s.Entries {
if s.Entries[i].F.Kind() == kind {
return &s.Entries[i], true
}
}
return nil, false
}
// Set replaces the entry of f's kind, appending when absent.
func (s *Stack) Set(f Filter) {
for i := range s.Entries {
if s.Entries[i].F.Kind() == f.Kind() {
s.Entries[i].F, s.Entries[i].Enabled = f, true
s.Compile()
return
}
}
s.Add(f)
}
// Remove drops the entry of the given kind.
func (s *Stack) Remove(kind string) bool {
for i := range s.Entries {
if s.Entries[i].F.Kind() == kind {
s.Entries = append(s.Entries[:i], s.Entries[i+1:]...)
s.Compile()
return true
}
}
return false
}
// Summary renders the stack for the status bar: (disabled) and !negated.
func (s *Stack) Summary() []string {
out := make([]string, 0, len(s.Entries))
for _, e := range s.Entries {
l := e.F.Label()
if l == "" {
continue // filter is inert; the header strip or nothing reports it
}
if e.Negate {
l = "!" + l
}
if !e.Enabled {
l = "(" + l + ")"
}
out = append(out, l)
}
return out
}
// Desc describes a registered filter kind for menus and help.
type Desc struct {
Kind string
Help string
New func(arg string) (Filter, error)
}
var registry []Desc
// Register makes a filter kind constructible by name. A new kind is a new file
// plus one Register call; no render-path switch changes.
func Register(d Desc) { registry = append(registry, d) }
// Kinds returns the registered filter kinds.
func Kinds() []Desc { return registry }
// New constructs a registered filter.
func New(kind, arg string) (Filter, error) {
for _, d := range registry {
if d.Kind == kind {
return d.New(arg)
}
}
return nil, fmt.Errorf("filter: unknown kind %q", kind)
}
+83
View File
@@ -0,0 +1,83 @@
package filter
import (
"strconv"
"github.com/lixenwraith/vif-log/internal/logfile"
)
func init() {
Register(Desc{Kind: "find", Help: "regexp over the focused column, smart-case", New: newFind})
}
// Find keeps records whose focused column matches the pattern. Time, tick, sub
// and msg resolve from the index row, so only fields and all read the file.
type Find struct {
Query string
Col logfile.Column
pat Pattern
scratch []byte
}
// NewFind returns an inert find filter.
func NewFind() *Find { return &Find{} }
func newFind(arg string) (Filter, error) {
f := NewFind()
if err := f.Set(arg, logfile.ColAll); err != nil {
return nil, err
}
return f, nil
}
// Set replaces the pattern and its column scope.
func (f *Find) Set(q string, col logfile.Column) error {
p, err := NewPattern(q)
if err != nil {
return err
}
f.Query, f.Col, f.pat = q, col, p
return nil
}
// Active reports whether the filter constrains anything.
func (f *Find) Active() bool { return !f.pat.Empty() }
func (f *Find) Kind() string { return "find" }
func (f *Find) Needs() Need {
if !f.Active() {
return 0
}
switch f.Col {
case logfile.ColFields, logfile.ColAll:
return NeedFields
}
return 0
}
func (f *Find) Match(c *Ctx) bool {
if !f.Active() {
return true
}
switch f.Col {
case logfile.ColTime:
f.scratch = append(f.scratch[:0], logfile.StampText(c.Meta)...)
case logfile.ColTick:
f.scratch = strconv.AppendUint(f.scratch[:0], uint64(c.Meta.Tick), 10)
case logfile.ColSub:
f.scratch = append(f.scratch[:0], c.Idx.SubName(c.Meta.Sub)...)
case logfile.ColMsg:
f.scratch = append(f.scratch[:0], c.Idx.MsgName(c.Meta.Msg)...)
default:
return f.pat.MatchBytes(c.Record().ColumnBytes(f.Col))
}
return f.pat.MatchBytes(f.scratch)
}
func (f *Find) Label() string {
if !f.Active() {
return ""
}
return "/" + f.Col.String() + ":" + f.Query
}
+166
View File
@@ -0,0 +1,166 @@
package filter
import (
"fmt"
"strings"
"github.com/lixenwraith/vif-log/internal/logfile"
)
func init() {
Register(Desc{Kind: "level", Help: "level set (IWE) or threshold (>=WARN)", New: newLevel})
}
// Level filters by a bitmask over logfile.Level. Threshold mode is a mutation
// of the mask, not a second representation.
type Level struct{ Mask uint16 }
// NewLevel returns a level filter with every level enabled.
func NewLevel() *Level { return &Level{Mask: (1 << uint(logfile.LevelCount)) - 1} }
func newLevel(arg string) (Filter, error) {
f := NewLevel()
arg = strings.TrimSpace(arg)
if arg == "" {
return f, nil
}
if t, ok := strings.CutPrefix(arg, ">="); ok {
l, found := levelByName(strings.TrimSpace(t))
if !found {
return nil, fmt.Errorf("filter/level: unknown level %q", t)
}
f.Threshold(l)
return f, nil
}
f.Mask = 0
for i := 0; i < len(arg); i++ {
l, ok := logfile.LevelByInitial(arg[i] &^ 0x20)
if !ok {
return nil, fmt.Errorf("filter/level: unknown level initial %q", arg[i])
}
f.Mask |= 1 << uint(l)
}
return f, nil
}
func levelByName(s string) (logfile.Level, bool) {
s = strings.ToUpper(s)
for i := logfile.Level(0); i < logfile.LevelCount; i++ {
if i.String() == s {
return i, true
}
}
return logfile.LevelBad, false
}
func (f *Level) Kind() string { return "level" }
func (f *Level) Needs() Need { return 0 }
func (f *Level) Match(c *Ctx) bool { return f.Mask&(1<<uint(c.Meta.Lvl)) != 0 }
// Has reports whether level l passes.
func (f *Level) Has(l logfile.Level) bool { return f.Mask&(1<<uint(l)) != 0 }
// Toggle flips one level.
func (f *Level) Toggle(l logfile.Level) { f.Mask ^= 1 << uint(l) }
// SetAll enables or disables every level.
func (f *Level) SetAll(on bool) {
if on {
f.Mask = (1 << uint(logfile.LevelCount)) - 1
} else {
f.Mask = 0
}
}
// Threshold enables the ordered levels at or above l, leaving PROC and BAD alone.
func (f *Level) Threshold(l logfile.Level) {
for i := range logfile.Level(logfile.OrderedCount) {
if i >= l {
f.Mask |= 1 << uint(i)
} else {
f.Mask &^= 1 << uint(i)
}
}
}
// ThresholdToggle applies the threshold at l, or restores every ordered level
// when the mask already has exactly that shape. Digit keys use this: 2 hides
// TRACE, 2 again brings it back.
func (f *Level) ThresholdToggle(l logfile.Level) {
if f.isThreshold(l) {
for i := range logfile.Level(logfile.OrderedCount) {
f.Mask |= 1 << uint(i)
}
return
}
f.Threshold(l)
}
// isThreshold reports whether the ordered levels are exactly those at or above l.
func (f *Level) isThreshold(l logfile.Level) bool {
for i := range logfile.Level(logfile.OrderedCount) {
if f.Has(i) != (i >= l) {
return false
}
}
return true
}
// AllOn reports whether no level is filtered out.
func (f *Level) AllOn() bool {
return f.Mask&((1<<uint(logfile.LevelCount))-1) == (1<<uint(logfile.LevelCount))-1
}
// Shift moves the threshold by d, clamped to the ordered range.
func (f *Level) Shift(d int) {
t := int(f.LowestOrdered())
t += d
if t < 0 {
t = 0
}
if t >= logfile.OrderedCount {
t = logfile.OrderedCount - 1
}
f.Threshold(logfile.Level(t))
}
// LowestOrdered returns the lowest enabled ordered level, ERROR if none.
func (f *Level) LowestOrdered() logfile.Level {
for i := logfile.Level(0); i < logfile.Level(logfile.OrderedCount); i++ {
if f.Has(i) {
return i
}
}
return logfile.LevelError
}
// Label reports the level filter only when it hides something; the header
// strip shows the per-level state.
func (f *Level) Label() string {
if f.AllOn() {
return ""
}
if l, ok := f.thresholdShape(); ok {
return "lvl>=" + l.String()
}
var b strings.Builder
b.WriteString("lvl:")
for i := range logfile.LevelCount {
if f.Has(i) {
b.WriteByte(i.Initial())
}
}
return b.String()
}
// thresholdShape reports whether the mask is "ordered >= t, plus PROC and BAD".
func (f *Level) thresholdShape() (logfile.Level, bool) {
if !f.Has(logfile.LevelProc) || !f.Has(logfile.LevelBad) {
return 0, false
}
t := f.LowestOrdered()
if t == 0 || !f.isThreshold(t) {
return 0, false
}
return t, true
}
+110
View File
@@ -0,0 +1,110 @@
package filter
import (
"bytes"
"regexp"
"strings"
)
// Pattern is a smart-case matcher: an all-lowercase pattern matches
// case-insensitively, any upper-case character makes it case-sensitive.
// A pattern free of regexp metacharacters takes an allocation-free
// substring path; everything else compiles to stdlib RE2.
type Pattern struct {
Src string
re *regexp.Regexp
lit []byte
fold bool
}
// NewPattern compiles s; an empty s yields an inert pattern.
func NewPattern(s string) (Pattern, error) {
p := Pattern{Src: s, fold: s == strings.ToLower(s)}
if s == "" {
return p, nil
}
if regexp.QuoteMeta(s) == s {
if p.fold {
p.lit = []byte(strings.ToLower(s))
} else {
p.lit = []byte(s)
}
return p, nil
}
expr := s
if p.fold {
expr = "(?i)" + s
}
re, err := regexp.Compile(expr)
if err != nil {
return Pattern{}, err
}
p.re = re
return p, nil
}
// Empty reports whether the pattern constrains nothing.
func (p Pattern) Empty() bool { return p.Src == "" }
// MatchBytes reports whether b contains a match.
func (p Pattern) MatchBytes(b []byte) bool {
switch {
case p.Src == "":
return true
case p.lit != nil && p.fold:
return foldContains(b, p.lit)
case p.lit != nil:
return bytes.Contains(b, p.lit)
default:
return p.re.Match(b)
}
}
// MatchString reports whether s contains a match.
func (p Pattern) MatchString(s string) bool {
switch {
case p.Src == "":
return true
case p.lit != nil && p.fold:
return foldContains([]byte(s), p.lit)
case p.lit != nil:
return strings.Contains(s, string(p.lit))
default:
return p.re.MatchString(s)
}
}
func lowerASCII(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + 'a' - 'A'
}
return c
}
// foldContains reports whether hay contains needle, ASCII case-insensitively.
// needle must already be lowercased.
func foldContains(hay, needle []byte) bool {
if len(needle) == 0 {
return true
}
if len(hay) < len(needle) {
return false
}
first := needle[0]
for i := 0; i+len(needle) <= len(hay); i++ {
if lowerASCII(hay[i]) != first {
continue
}
match := true
for j := 1; j < len(needle); j++ {
if lowerASCII(hay[i+j]) != needle[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}
+64
View File
@@ -0,0 +1,64 @@
package filter
import "slices"
// PinSet is the pin buffer: record indices the user marked. It is owned by the
// app and outlives every filter change, which is the point — pins are
// assembled across successive filters. Not registered: it has no meaningful
// construction from a string argument.
type PinSet struct{ m map[int32]struct{} }
// NewPinSet returns an empty pin buffer.
func NewPinSet() *PinSet { return &PinSet{m: make(map[int32]struct{})} }
// Has reports whether record i is pinned.
func (p *PinSet) Has(i int32) bool { _, ok := p.m[i]; return ok }
// Toggle flips record i, returning its new state.
func (p *PinSet) Toggle(i int32) bool {
if _, ok := p.m[i]; ok {
delete(p.m, i)
return false
}
p.m[i] = struct{}{}
return true
}
// Clear empties the buffer.
func (p *PinSet) Clear() { clear(p.m) }
// Len returns the pin count.
func (p *PinSet) Len() int { return len(p.m) }
// Sorted returns the pinned record indices in file order.
func (p *PinSet) Sorted() []int32 {
out := make([]int32, 0, len(p.m))
for i := range p.m {
out = append(out, i)
}
slices.Sort(out)
return out
}
// Pinned restricts the view to the pin buffer.
type Pinned struct {
Set *PinSet
On bool
}
// NewPinned wraps a pin buffer as a filter.
func NewPinned(s *PinSet) *Pinned { return &Pinned{Set: s} }
func (f *Pinned) Kind() string { return "pin" }
func (f *Pinned) Needs() Need { return 0 }
func (f *Pinned) Match(c *Ctx) bool {
return !f.On || f.Set.Has(int32(c.I))
}
func (f *Pinned) Label() string {
if !f.On {
return ""
}
return "pinned-only"
}
+64
View File
@@ -0,0 +1,64 @@
package filter
import (
"strings"
"github.com/lixenwraith/vif-log/internal/logfile"
)
func init() {
Register(Desc{Kind: "snap", Help: "collapse stat snapshots to one line (arg: off)", New: newCollapse})
}
// Collapse hides stat-snapshot members, turning a ~40-record snapshot into one
// navigable line. On is the global state; exc holds the per-group exceptions
// toggled with Enter. Index-only, so it costs nothing to evaluate.
type Collapse struct {
On bool
exc map[uint32]bool
}
// NewCollapse returns a collapse filter in the given global state.
func NewCollapse(on bool) *Collapse {
return &Collapse{On: on, exc: make(map[uint32]bool)}
}
func newCollapse(arg string) (Filter, error) {
return NewCollapse(strings.TrimSpace(arg) != "off"), nil
}
func (f *Collapse) Kind() string { return "snap" }
func (f *Collapse) Needs() Need { return 0 }
func (f *Collapse) Match(c *Ctx) bool {
if c.Meta.Snap == 0 || c.Meta.Flags&logfile.FlagSnapHead != 0 {
return true
}
return f.Expanded(c.Meta.Snap)
}
// Expanded reports whether group id shows its members.
func (f *Collapse) Expanded(id uint32) bool { return f.On == f.exc[id] }
// ToggleGroup flips one group against the global state.
func (f *Collapse) ToggleGroup(id uint32) { f.exc[id] = !f.exc[id] }
// ResetGroups drops the per-group exceptions; group ids are file-scoped.
func (f *Collapse) ResetGroups() { clear(f.exc) }
// Toggle flips the global state and drops all per-group exceptions.
func (f *Collapse) Toggle() {
f.On = !f.On
clear(f.exc)
}
func (f *Collapse) Label() string {
s := "snap:expanded"
if f.On {
s = "snap:collapsed"
}
if len(f.exc) > 0 {
s += "*"
}
return s
}
+80
View File
@@ -0,0 +1,80 @@
package filter
import (
"fmt"
"strconv"
"strings"
)
func init() {
Register(Desc{Kind: "tick", Help: "tick range: N | N-M | N- | -M", New: newTickSpan})
Register(Desc{Kind: "run", Help: "run range: N | N-M | N- | -M", New: newRunSpan})
}
// Span is an inclusive numeric range over an index-resident counter.
type Span struct {
field string
Lo, Hi uint32
Query string
}
func newTickSpan(arg string) (Filter, error) { return newSpan("tick", arg) }
func newRunSpan(arg string) (Filter, error) { return newSpan("run", arg) }
func newSpan(field, arg string) (Filter, error) {
lo, hi, err := parseSpan(arg)
if err != nil {
return nil, fmt.Errorf("filter/%s: %w", field, err)
}
return &Span{field: field, Lo: lo, Hi: hi, Query: arg}, nil
}
// parseSpan accepts N, N-M, N- and -M; an empty spec is the full range.
func parseSpan(arg string) (uint32, uint32, error) {
arg = strings.TrimSpace(arg)
if arg == "" {
return 0, ^uint32(0), nil
}
lo, hi := arg, arg
if i := strings.IndexByte(arg, '-'); i >= 0 {
lo, hi = arg[:i], arg[i+1:]
}
var l uint32
h := ^uint32(0)
if lo != "" {
v, err := strconv.ParseUint(lo, 10, 32)
if err != nil {
return 0, 0, err
}
l = uint32(v)
}
if hi != "" {
v, err := strconv.ParseUint(hi, 10, 32)
if err != nil {
return 0, 0, err
}
h = uint32(v)
}
if l > h {
l, h = h, l
}
return l, h, nil
}
func (f *Span) Kind() string { return f.field }
func (f *Span) Needs() Need { return 0 }
func (f *Span) Match(c *Ctx) bool {
v := c.Meta.Tick
if f.field == "run" {
v = c.Meta.Run
}
return v >= f.Lo && v <= f.Hi
}
func (f *Span) Label() string {
if f.Lo == 0 && f.Hi == ^uint32(0) {
return ""
}
return f.field + ":" + f.Query
}
+86
View File
@@ -0,0 +1,86 @@
package filter
import "github.com/lixenwraith/vif-log/internal/logfile"
func init() {
Register(Desc{Kind: "sub", Help: "subsystem regexp, e.g. sub:^(fsm|rec)$", New: newSub})
Register(Desc{Kind: "msg", Help: "message regexp, e.g. msg:transition", New: newMsg})
}
// Text matches one of the interned vocabulary columns. The pattern is applied
// once per interned id into a bitset, so the per-record cost is a bit probe
// and no line is ever read.
type Text struct {
kind string
Query string
pat Pattern
mask []uint64
n int
}
func newSub(arg string) (Filter, error) { return newText("sub", arg) }
func newMsg(arg string) (Filter, error) { return newText("msg", arg) }
func newText(kind, arg string) (Filter, error) {
p, err := NewPattern(arg)
if err != nil {
return nil, err
}
return &Text{kind: kind, Query: arg, pat: p}, nil
}
func (f *Text) Kind() string { return f.kind }
func (f *Text) Needs() Need { return 0 }
func (f *Text) Match(c *Ctx) bool {
if f.pat.Empty() {
return true
}
var id int
var tab []string
if f.kind == "sub" {
id, tab = int(c.Meta.Sub), c.Idx.Subs()
} else {
id, tab = int(c.Meta.Msg), c.Idx.Msgs()
}
f.ensure(tab)
if id < 0 || id >= f.n {
return false
}
return f.mask[id>>6]&(1<<uint(id&63)) != 0
}
// ensure rebuilds the bitset when the interned table has grown.
func (f *Text) ensure(tab []string) {
if f.mask != nil && f.n == len(tab) {
return
}
need := (len(tab) + 63) / 64
if cap(f.mask) < need {
f.mask = make([]uint64, need)
} else {
f.mask = f.mask[:need]
clear(f.mask)
}
for i, s := range tab {
if f.pat.MatchString(s) {
f.mask[i>>6] |= 1 << uint(i&63)
}
}
f.n = len(tab)
}
func (f *Text) Label() string {
if f.pat.Empty() {
return ""
}
return f.kind + ":" + f.Query
}
// SubOf resolves the column a text filter binds to; used by the help overlay.
func (f *Text) Column() logfile.Column {
if f.kind == "sub" {
return logfile.ColSub
}
return logfile.ColMsg
}