Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions internal/filter/diagnostics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package filter

import (
"strings"
"unicode/utf8"
)

// Diagnostic is one CEL compile error tied to a byte range in one expression.
// Line is 1-based; Column and EndColumn are 0-based byte columns. CEL reports
// an error point rather than a span, so Diagnose expands that point to the
// smallest surrounding token that can usefully be highlighted by an editor.
type Diagnostic struct {
Expression int
Line int
Column int
EndColumn int
Message string
}

// Diagnose compiles every expression and returns every parse/type-check error
// instead of stopping at the first invalid filter. An error is reserved for an
// environment construction failure; invalid CEL is represented by diagnostics.
func Diagnose(exprs []string) ([]Diagnostic, error) {
env, err := newEnvironment()
if err != nil {
return nil, err
}

var diagnostics []Diagnostic
seen := make(map[[4]int]struct{})
for expressionIndex, expr := range exprs {
_, issues := env.Compile(expr)
for _, issue := range issues.Errors() {
line := issue.Location.Line()
column := issue.Location.Column()
if line < 1 {
line = 1
}
if column < 0 {
column = 0
}
start, end := diagnosticTokenRange(expr, line, column)
key := [4]int{expressionIndex, line, start, end}
if _, duplicate := seen[key]; duplicate {
continue
}
seen[key] = struct{}{}
diagnostics = append(diagnostics, Diagnostic{
Expression: expressionIndex,
Line: line,
Column: start,
EndColumn: end,
Message: issue.Message,
})
}
}
return diagnostics, nil
}

func diagnosticTokenRange(expr string, line, column int) (int, int) {
lines := strings.Split(expr, "\n")
if line < 1 || line > len(lines) {
return 0, firstRuneEnd(expr, 0)
}
text := lines[line-1]
if text == "" {
return 0, 0
}
if column > len(text) {
column = len(text)
}

// EOF diagnostics have no character at their reported point. Highlight
// the preceding token (usually a dangling operator) instead.
if column == len(text) {
end := column
for end > 0 && asciiSpace(text[end-1]) {
end--
}
if end == 0 {
return 0, firstRuneEnd(text, 0)
}
start := previousTokenStart(text, end)
return start, end
}

start := column
for start < len(text) && asciiSpace(text[start]) {
start++
}
if start == len(text) {
end := start
for end > 0 && asciiSpace(text[end-1]) {
end--
}
return previousTokenStart(text, end), end
}
return start, nextTokenEnd(text, start)
}

func previousTokenStart(text string, end int) int {
start := end - 1
kind := tokenKind(text[start])
if kind == tokenOther {
_, size := utf8.DecodeLastRuneInString(text[:end])
return end - size
}
for start > 0 && tokenKind(text[start-1]) == kind {
start--
}
return start
}

func nextTokenEnd(text string, start int) int {
kind := tokenKind(text[start])
if kind == tokenOther {
return firstRuneEnd(text, start)
}
end := start + 1
for end < len(text) && (tokenKind(text[end]) == kind || kind == tokenWord && asciiDigit(text[end])) {
end++
}
return end
}

func firstRuneEnd(text string, start int) int {
if start >= len(text) {
return start
}
_, size := utf8.DecodeRuneInString(text[start:])
return start + size
}

type diagnosticTokenKind uint8

const (
tokenOther diagnosticTokenKind = iota
tokenWord
tokenNumber
tokenOperator
)

func tokenKind(b byte) diagnosticTokenKind {
switch {
case b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z'):
return tokenWord
case (b >= '0' && b <= '9') || b == '.':
return tokenNumber
case strings.ContainsRune("=!<>&|+-*/%?", rune(b)):
return tokenOperator
default:
return tokenOther
}
}

func asciiSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\r'
}

func asciiDigit(b byte) bool {
return b >= '0' && b <= '9'
}
6 changes: 5 additions & 1 deletion internal/filter/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type Chain struct {
}

func Compile(exprs []string) (*Chain, error) {
env, err := cel.NewEnv(cel.Variable("msg", cel.MapType(cel.StringType, cel.DynType)))
env, err := newEnvironment()
if err != nil {
return nil, err
}
Expand All @@ -38,6 +38,10 @@ func Compile(exprs []string) (*Chain, error) {
return c, nil
}

func newEnvironment() (*cel.Env, error) {
return cel.NewEnv(cel.Variable("msg", cel.MapType(cel.StringType, cel.DynType)))
}

// Match evaluates all expressions (AND). Returns an error if any
// expression errors at eval time; callers drop the message and count it.
func (c *Chain) Match(e *msg.Envelope) (bool, error) {
Expand Down
24 changes: 24 additions & 0 deletions internal/filter/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,27 @@ func TestEmptyChainMatchesAll(t *testing.T) {
t.Fatal("empty chain must pass everything")
}
}

func TestDiagnoseReturnsOffendingTokenRanges(t *testing.T) {
exprs := []string{"msg.pgn == 127250", "msg.source == @", "msg.priority ==", "unknown2 == true"}
diagnostics, err := Diagnose(exprs)
if err != nil {
t.Fatal(err)
}
if len(diagnostics) != 3 {
t.Fatalf("diagnostics = %+v, want three errors", diagnostics)
}

at := diagnostics[0]
if at.Expression != 1 || at.Line != 1 || exprs[1][at.Column:at.EndColumn] != "@" {
t.Fatalf("unexpected-character diagnostic = %+v, range %q", at, exprs[1][at.Column:at.EndColumn])
}
dangling := diagnostics[1]
if dangling.Expression != 2 || exprs[2][dangling.Column:dangling.EndColumn] != "==" {
t.Fatalf("EOF diagnostic = %+v, range %q; want dangling operator", dangling, exprs[2][dangling.Column:dangling.EndColumn])
}
unknown := diagnostics[2]
if unknown.Expression != 3 || exprs[3][unknown.Column:unknown.EndColumn] != "unknown2" {
t.Fatalf("unknown-identifier diagnostic = %+v, range %q", unknown, exprs[3][unknown.Column:unknown.EndColumn])
}
}
11 changes: 11 additions & 0 deletions internal/ui/assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ Used for progressive-enhancement navigation (`hx-boost`) and form fragments.
Plain links and server-rendered pages still work without it; htmx only avoids
full page reloads and handles the fragment swaps.

## app.js

Hand-authored progressive enhancement for the connector form's CEL filter
textarea. It supplies accessible, keyboard-driven typeahead for envelope
fields, CEL helpers, PGN numbers, and decoded payload keys. The schema-backed
items are fetched from `/ui/cel-completions`; a small built-in fallback keeps
the core interaction useful if that request fails. It also debounces CEL
compilation while the operator types and overlays compiler error ranges as red
wavy underlines. No configuration write depends on this script, and filters
are still validated server-side on Save.

## app.css

`app.css` is hand-authored source, not a compiled Tailwind/daisyUI artifact.
Expand Down
Loading
Loading