diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c570497..cc322c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: ci on: push: - branches: ["main"] + branches: ["main", "v2"] pull_request: jobs: @@ -14,7 +14,35 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.26.1" + go-version-file: go.mod - name: Test run: go test -v ./... + + rust: + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + + # Pinned: the trybuild compile-fail tests compare compiler messages. + - name: Set up Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.97.1 + components: clippy, rustfmt + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + + - name: Format + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: Test + run: cargo test --workspace --locked diff --git a/.gitignore b/.gitignore index 0c8d865..e43b0f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ -y.output -*.bak .DS_Store diff --git a/2026-05-15-v2-plan.md b/2026-05-15-v2-plan.md deleted file mode 100644 index 0cc92a8..0000000 --- a/2026-05-15-v2-plan.md +++ /dev/null @@ -1,671 +0,0 @@ -# Expression Language V2 Plan - -This note proposes a v2 implementation strategy for the expression language: replace the current parser with an explicit lexer/parser/AST architecture, preserve v1 behavior first, then add editor-oriented features such as bracket indexing, round-tripping, formatting, and optional source preservation. - -## Current Parser Rationale - -The current Ragel + goyacc implementation is a good compact evaluator-oriented parser, but it is not a good long-term fit for a hybrid freeform + GUI expression editor. - -The current implementation: - -- uses Ragel to recognize rich tokens such as strings, IPs, CIDRs, regexes, comments, operators, fields, and functions -- uses goyacc grammar actions to build runtime evaluator nodes directly -- skips whitespace and comments during lexing -- does not attach source spans or token ranges to expression nodes -- uses the same node types for parsing, evaluation, and compact `String()` output - -That design works for parsing and evaluating rules, but it makes AST-to-expression round-tripping, localized GUI edits, trivia preservation, and source-aware diagnostics harder than they need to be. - -The v2 implementation should separate these concerns: - -```text -source text - -> lossless token stream with spans/trivia - -> explicit editable AST - -> formatter / source rewriter - -> evaluator or optional compiled runtime plan -``` - -The main goal is not just replacing goyacc with Pratt parsing. The main goal is to make the AST the stable source of truth for both freeform text and GUI editing. - -## Motivation - -Dot-separated field paths are convenient for common cases: - -```text -request.headers.user_agent == "curl" -destination.ip in 192.168.0.0/16 -``` - -They are less flexible when map keys contain dots, hyphens, spaces, slashes, reserved words, or other punctuation: - -```text -labels["app.kubernetes.io/name"] == "api" -request.headers["user-agent"] == "curl" -payload["tls.version"] == "1.3" -``` - -Bracket indexing gives users an explicit escape hatch without weakening the identifier grammar. - -## Proposed Syntax - -Keep the existing array literal syntax: - -```text -[1, "str", true] -field in [1, "str", 3] -``` - -Add bracket indexing as a postfix path operation: - -```text -request.headers["user-agent"] -labels["app.kubernetes.io/name"] -items[0].name -``` - -Support root-level bracket paths for keys that cannot be written as identifiers: - -```text -["top.level.key"] == 1 -["weird key"] contains "value" -``` - -## Grammar Sketch - -```text -primary = literal | field_path | call | array | "(" expr ")" -array = "[" [expr {"," expr}] "]" -field_path = path_start {path_part} -path_start = identifier | bracket_key -path_part = "." identifier | bracket_key -bracket_key = "[" (quoted_string | unsigned_integer) "]" -``` - -In a Pratt parser, `[` at the start of a primary remains an array literal. `[` after a field/path primary is parsed as postfix indexing. - -## Parser Architecture - -Use a Pike-style state-function lexer for tokenization, then feed those tokens into a Pratt parser for expression parsing. - -```text -input string - -> state-function lexer - -> token stream - -> Pratt parser - -> AST - -> semantic validation / compile step - -> evaluator -``` - -The lexer should handle raw input concerns: - -- whitespace -- `--` line comments -- `/* ... */` block comments -- quoted strings -- regex literals such as `/.../` and `|...|` -- operators and punctuation -- unquoted atoms - -The Pratt parser should handle expression structure: - -- operator precedence for `or`, `and`, comparisons, and prefix `not` -- grouping with parentheses -- array literals -- function and macro calls -- postfix bracket indexing - -Prefer a pull-style lexer API such as `Next()` over a goroutine/channel lexer. The state-function pattern is still useful, but a pull API keeps parser control flow simple and avoids unnecessary channel overhead for short rule expressions. - -## Migration Strategy - -Rewrite the existing v1 feature set using the new lexer/parser/AST architecture before adding new language features. - -This keeps parser architecture risk separate from language-design risk. The existing behavior should become the migration contract, and new syntax should only be added after the new implementation has strong parity with the current parser. - -Suggested sequence: - -1. Implement lexer parity for the current token set and accepted syntax. -2. Implement Pratt parser parity for existing expressions: literals, fields, arrays, calls, `not`, `and`, `or`, comparisons, `matches`, and `in`. -3. Build the explicit AST for the v1 grammar only. -4. Evaluate the AST directly or lower it to a current-style runtime representation. -5. Match current compact `String()` output where tests and public behavior depend on it. -6. Add parity tests that compare old and new parser behavior for parse success, compact output, eval result, missing-field behavior, and representative syntax errors. -7. Switch the existing `Parse()` API to the new parser once parity is strong. -8. Add v2 language/editor features after the replacement parser is stable. - -Do not require exact yacc parse-error wording during the migration. Preserve useful line/column diagnostics and clear messages, but avoid coupling the new parser to generated-parser phrasing. - -## First Milestone Non-Goals - -The first milestone is a replacement parser for the v1 feature set. Keep new language and editor features out of that milestone. - -Non-goals for the first milestone: - -- bracket indexing -- dynamic bracket expressions -- exact whitespace/comment preservation -- compact vs multiline formatter options -- exact yacc parse-error message compatibility -- evaluator optimization or compiled runtime planning -- new natural-language negated operators such as `not in`, `not contains`, or `not matches` - -It is fine for the v1 replacement parser to record source spans and token trivia early, but behaviorally visible preservation and formatting should wait until parser/evaluator parity is solid. - -## AST and Round-Tripping - -The AST should be designed as the shared model for both freeform text editing and a GUI expression editor. That means it should support both directions: - -```text -expression -> lexer/parser -> AST -AST -> printer -> expression -``` - -The minimum invariant should be semantic round-tripping: - -```text -Parse(Print(Parse(expr))) == Parse(expr) -``` - -Exact whitespace/comment preservation is a separate, stricter goal. If the editor needs to preserve comments and hand-formatted text, keep token trivia or a lightweight concrete syntax tree alongside the AST. If canonical formatting is acceptable after GUI edits, a normal AST plus a deterministic printer is enough. - -AST nodes should carry: - -- node kind -- source span for diagnostics and editor selection -- child nodes / operands -- normalized operator enum -- original operator spelling when useful for round-tripping or display -- literal value plus original raw token when useful - -Prefer a small, explicit AST over evaluator-specific nodes. The evaluator can consume the AST directly by default. Performance-sensitive applications can add an optional second layer that compiles the AST into a runtime plan with precomputed lookups, compiled regexes, normalized operators, and other evaluation shortcuts. The editor should not have to reason about that optimized runtime representation. - -## Printing and Formatting - -Keep printing and formatting as explicit layers: - -```text -Parse(expr) -> AST -AST.String() -> compact canonical expression -Format(AST, options) -> formatted expression -Rewrite(original, AST edits) -> preserve unchanged whitespace/comments -``` - -`String()` can continue to act as a compact canonical formatter for tests, logs, `EvaluatedRule`, snapshots, and debugging. - -Add a formatter as an optional AST printer for editor and user-facing display: - -- compact format: single-line, stable canonical expression -- multiline format: line-broken, indented expression for larger rules - -The hybrid GUI/freeform editor should support three output modes: - -- preserve: keep original whitespace/comments for unchanged source ranges and print only changed subtrees -- compact: canonical single-line output -- multiline: canonical formatted output with indentation - -Formatting should be an explicit action or option. GUI edits should not automatically rewrite a user's whole expression unless that is the selected output mode. - -For negated natural-language operators such as: - -```text -field not contains "x" -domain not matches /example/ -ip not in 10.0.0.0/8 -``` - -represent them in the editable AST as a comparison with a negation flag or a distinct operator enum. A later semantic lowering step can convert them to `not (field contains "x")` if that is simpler for evaluation. This keeps GUI editing and expression printing straightforward. - -## Initial Scope - -Start with a deliberately small feature: - -- quoted string keys for map/object access -- unsigned integer indexes for arrays/slices -- no dynamic expressions inside brackets - -Do not initially support: - -```text -headers[some_field] -headers[starts_with(name, "x")] -headers[1 + 2] -``` - -Dynamic indexing can be added later if there is a clear need, but the static form covers the main usability problem while keeping parsing, validation, and missing-field behavior straightforward. - -## Semantics - -In v2, dot syntax should be explicit object/path traversal. It should not fall back to matching exact keys that contain dots. - -For example: - -```json -{ - "data": { - "field.name": true - } -} -``` - -should be accessed as: - -```text -data["field.name"] -``` - -and not as: - -```text -data.field.name -``` - -Nested objects remain accessible with dot traversal: - -```json -{ - "data": { - "field": { - "name": true - } - } -} -``` - -```text -data.field.name -``` - -This is a v2 behavior change from the current fallback behavior. If legacy compatibility is needed, keep it behind an explicit v1/legacy lookup mode. - -Quoted bracket keys are exact map-key segments: - -```text -obj["1"] -``` - -means the string key `"1"`, while: - -```text -obj[1] -``` - -means numeric index `1` into an array or slice. - -Existing dot-path behavior should remain compatible. If current lookup supports both flat keys like `"destination.ip"` and nested maps like `destination: {ip: ...}`, bracket indexing should act as the explicit form for exact path segments: - -```text -destination.ip -destination["ip"] -["destination.ip"] -``` - -The last form should refer to the exact top-level key `"destination.ip"`. - -## Input Normalization - -Skip schema support initially. Keep input dynamic and easy to provide. - -Support two portable JSON-oriented input styles: - -- plain JSON key/value documents -- fully typed JSON values - -Plain JSON may optionally enable annotated key suffixes for convenient type hints: - -```json -{ - "name": "hello", - "src.$ip": "1.2.3.4", - "dst.$cidr": "10.0.0.0/8", - "payload.$bytes_hex": "474554", - "max.$uint64": "18446744073709551615" -} -``` - -When annotated-key decoding is enabled, the annotation suffix is input metadata and is not part of the field path. For example, `src.$ip` normalizes to field `src` with Rulekit type `ip`. - -Potential initial suffixes: - -- `.$ip` -- `.$cidr` -- `.$mac` -- `.$bytes_hex` -- `.$bytes_base64` -- `.$int64` -- `.$uint64` -- `.$float64` - -Annotated-key decoding must be opt-in because JSON object keys can contain arbitrary strings. If a caller needs an exact key that collides with an annotation suffix, they can disable annotated-key decoding or use fully typed JSON. - -Fully typed JSON is the collision-free exact mode: - -```json -{ - "src": { "$type": "ip", "value": "1.2.3.4" }, - "payload": { "$type": "bytes", "encoding": "hex", "value": "474554" }, - "u64": { "$type": "uint64", "value": "18446744073709551615" } -} -``` - -The evaluator should consume a normalized Rulekit value model rather than host-language-specific values. Host-specific inputs such as Go `KV`, plain JSON, annotated-key JSON, TypeScript values, and Rust values should be adapters into that model. - -## Reliability Notes - -- Keep tokenization and semantic validation separate. -- Preserve source spans for useful syntax errors. -- Reject malformed brackets early: missing closing bracket, empty key, unsupported index expression. -- Add parser tests that distinguish array literals from postfix indexing. -- Add evaluation tests for flat keys, nested maps, string keys with dots, and numeric indexes. - -## Go-Only Implementation Phases - -The immediate roadmap should stay focused on the Go implementation before adding other host-language adapters or editor integrations. - -### Phase 1: Parser Replacement and V2 Path Semantics - -Status: complete. - -Scope: - -- replace Ragel/goyacc with the hand-written lexer and Pratt parser -- preserve the root `Parse()` API and current evaluator behavior where v2 semantics do not intentionally differ -- add bracket path indexing for quoted keys and unsigned numeric indexes -- add root bracket paths such as `["destination.ip"]` -- change dot paths to explicit nested traversal with no flat dotted-key fallback -- add opt-in JSON input normalization for annotated keys and fully typed values -- keep dynamic bracket expressions out of scope - -### Phase 2: Real AST Boundary - -Add an internal AST boundary while keeping public `Parse()` returning `Rule`. - -Scope: - -- define explicit AST node types for binary expressions, unary expressions, literals, paths, arrays, and calls -- include normalized operator enum, raw operator spelling, and source spans -- make the parser produce AST first -- add lowering from AST to the current evaluator `Rule` nodes -- keep existing `String()` behavior unchanged through the current evaluator nodes - -This gives the Go implementation an editor/compiler architecture without another runtime behavior change. - -### Phase 3: Canonical AST Printer - -Add semantic round-tripping from AST. - -Scope: - -- implement compact canonical AST printing -- add invariant tests for `Parse(Print(Parse(expr))) == Parse(expr)` -- start moving root string expectations toward the AST printer where safe -- preserve current evaluated-rule formatting unless intentionally changed - -### Phase 4: Public Go AST API - -Expose AST deliberately for Go callers. - -Scope: - -- add a public `ParseAST`-style API -- add a `Compile` API that lowers AST to `Rule` -- keep `Parse(expr)` as `Compile(ParseAST(expr))` -- document AST mutability and editing expectations - -This lets Go tools build GUI/freeform editors without depending on evaluator internals. - -### Phase 5: Formatter - -Add explicit formatting modes. - -Scope: - -- add `Format` with compact and multiline modes -- support indentation options -- add deterministic formatting tests -- do not preserve exact original whitespace or comments yet - -Canonical formatting should land before source preservation because it is simpler and gives changed subtrees a stable printer. - -### Phase 6: Trivia-Aware Tokens - -Keep comments and whitespace in the token stream. - -Scope: - -- have the lexer emit or store leading/trailing trivia -- have AST nodes carry token spans/ranges -- keep parser semantics trivia-insensitive -- use node spans for better diagnostics where possible - -This is the foundation for source-aware editing and later preservation. - -### Phase 7: Source-Preserving Rewrite - -Preserve unchanged source while printing edited subtrees. - -Scope: - -- represent edits against AST nodes -- reuse original text for unchanged source ranges -- print only changed subtrees -- preserve comments and whitespace around unchanged regions - -This is the editor-grade round-trip layer and should come after AST and formatter stability. - -### Phase 8: Optional Runtime Compile Plan (Removed) - -Optimize evaluation without changing the AST contract. - -Scope: - -- compile AST to an optimized runtime plan -- precompute path lookups, regexes, normalized operators, and other stable evaluation details -- keep current evaluator behavior as the semantic contract -- add benchmarks before and after optimizer work - -This phase was removed from v2 because the temporary `Plan` wrapper did not provide a distinct runtime representation; it only delegated to `Rule`. Future optimizer work should add a real evaluator representation only when profiles justify it. - -## Follow-Up V2 Design Phases - -The first eight phases establish the parser, AST, printer, formatter, rewrite, and initial compile-plan boundaries. The next phases should refine the public v2 API around explanation, input, printing, functions, and performance. Backward compatibility with the current v1-style API is not a design constraint for these phases. - -### Phase 9: Rule Print API - -Replace loose formatter options with a typed print API. - -Scope: - -- make `String()` return compact canonical output -- add `Print(mode)` with typed modes rather than a broad options bag -- support exact original source through a source print mode -- support compact canonical output through a compact print mode -- support multiline canonical output through a multiline print mode with indentation attached only to multiline mode -- keep source-preserving rewrite as a separate operation, not as `String()` behavior - -Sketch: - -```go -type PrintMode interface{ printMode() } - -func Source() PrintMode -func Compact() PrintMode -func Multiline(indent string) PrintMode - -type Rule interface { - String() string // compact canonical - Print(PrintMode) string -} -``` - -`Print` should not return an error for parser-produced rules. Invalid manually constructed ASTs can be handled by construction-time validation instead of print-time errors. - -### Phase 10: Trace-Based Evaluation Explanation - -Replace `EvaluatedRule` as the primary explanation mechanism with an explicit trace tree. - -Scope: - -- keep short-circuit visibility as the primary goal -- attach evaluated values and errors to AST/evaluation branches -- mark branches as active, pruned, passed, failed, or missing -- make trace output suitable for GUI visualization -- treat progressive residual-rule evaluation as out of scope unless a concrete need returns -- stop using runtime evaluator-node rewriting as the explanation model - -Sketch: - -```go -type Trace struct { - Node ASTNode - Expr string - Value any - Error error - MissingFields []string - Status TraceStatus - Active bool - Pruned bool - Children []*Trace -} - -type Result struct { - Value any - Error error - MissingFields []string - Trace *Trace -} -``` - -The old `EvaluatedRule` behavior was useful for v1, but v2 should favor explicit explanation data over serialized evaluator-node fragments. - -### Phase 11: Context-Aware Input API - -Rename the evaluation data model around a single `Input` abstraction and make eval context-aware. - -Scope: - -- replace `Ctx` as the primary eval argument with `Input` -- make eval accept `context.Context` -- keep trace/functions/macros in explicit eval options rather than in `Input` -- avoid a design with both `KV` and `Resolver` fields competing as parallel input sources -- allow `KV` to be adapted into `Input` -- allow lazy leaf values inside `KV` -- allow nested `Input` values inside `KV` so a subtree can take over path resolution -- allow custom path-based `Input` implementations for callers that want to avoid building nested objects - -Sketch: - -```go -type Input interface { - Get(context.Context, []PathSegment) (any, bool, error) -} - -func FromKV(KV) Input -func FromFunc(func([]PathSegment) (any, bool, error)) Input -func FromContextFunc(func(context.Context, []PathSegment) (any, bool, error)) Input - -type LazyValue func() (any, error) -type LazyContextValue func(context.Context) (any, error) - -type Opts struct { - Trace bool - Functions map[string]*Function - Macros MacroSet -} - -type Rule interface { - Eval(context.Context, Input, Opts) Result -} -``` - -Resolution policy for `FromKV`: - -- traverse explicit path segments -- if a resolved value is `LazyValue`, call it and memoize it for that eval/input instance -- if a resolved value is `LazyContextValue`, call it with the eval context and memoize it -- if a resolved value implements `Input`, delegate the remaining path segments to it -- populate `Result.MissingFields` only after the active input source cannot resolve the path - -This supports both simple Go `KV` use and expensive lazy materialization for large per-request input contexts. - -### Phase 12: Macro Contract - -Define macros as zero-argument inline expansion of named rule expressions. - -Scope: - -- macros are reusable rule-language snippets, not Go code -- macros are serializable as source/AST -- macros remain zero-argument unless a strong need for parameterized macros appears -- functions remain the mechanism for runtime Go behavior -- GUI/editor tooling should be able to show macro source, docs, and expanded trace context - -Sketch: - -```go -type Macro struct { - Source string - AST *AST - Rule Rule - Doc string -} -``` - -Macro expansion should preserve clear trace information so users can see both the macro call and the expression that made it pass or fail. - -### Phase 13: Function API Overhaul - -Redesign functions around explicit schemas and ergonomic Go registration. - -Scope: - -- keep function arguments positional in expression syntax -- name positional arguments for UX, docs, validation, and editor forms -- make return type optional initially, but supported for editor validation and docs -- rename call argument access to `FunctionArgs` -- support both named and indexed argument access -- add reflection-based registration helpers for one-time setup ergonomics -- keep a manual non-reflective registration path for callers that want full control - -Sketch: - -```go -type Function struct { - Args []ArgSpec - Return Type - Doc string - Eval func(context.Context, FunctionArgs) (any, error) -} - -type FunctionArgs struct { - // implementation-owned positional values and name indexes -} - -func (a FunctionArgs) Index[T any](int) (T, error) -func (a FunctionArgs) Named[T any](string) (T, error) - -func Func(name string, args []ArgSpec, impl any) Function -``` - -The reflection-based `Func` builder may inspect the implementation signature once at registration time. Runtime eval should still use the compiled schema and avoid reflection in hot paths where possible. - -### Phase 14: Eval Performance Hardening - -Optimize hot-path eval after the new input and trace APIs are shaped. - -Scope: - -- keep reflection out of runtime eval paths -- compile paths once and avoid per-eval path parsing or splitting -- avoid debug `defer` costs when debug output is disabled -- benchmark happy-path eval, missing-field eval, path lookup, lazy input, and trace-enabled eval separately -- optimize trace collection so callers can choose whether to pay for it -- keep missing-field allocation improvements medium priority unless profiles show real production impact - -Known findings: - -- per-eval `strings.Split` in top-level field lookup caused avoidable allocation and slowdown -- unconditional debug `defer` closures in comparison functions were a major happy-path cost -- missing-field paths allocate sets and path strings, but this is acceptable unless missing-field eval is hot -- source/editor features should not impose cost on eval when trace/source preservation is not requested diff --git a/2026-05-16-demo-app-plan.md b/2026-05-16-demo-app-plan.md deleted file mode 100644 index c9d0b64..0000000 --- a/2026-05-16-demo-app-plan.md +++ /dev/null @@ -1,280 +0,0 @@ -# Rulekit V2 Demo App Plan - -This plan describes a Vue demo app that showcases Rulekit v2 as a hybrid text and visual rule editor. The demo should use the Go implementation through WASM so the browser experience exercises the same parser, formatter, rewriter, evaluator, and trace model as the Go package. - -## Goals - -- Show v2's editor-oriented architecture: text source, AST, formatting, source-preserving rewrite, and evaluation trace. -- Provide a two-pane editor where the text expression and visual AST stay synchronized. -- Preserve the retro CRT/pixel aesthetic from the `rulekit-js` branch and the shared screenshots. -- Keep all demo-specific WASM build code, generated WASM artifacts, JS glue, assets, and app code contained inside the demo directory. -- Build a focused initial GUI editor: rewrite and delete existing rule parts, but do not support adding new nodes yet. -- Render evaluation results as an annotated visual AST near the result card, color-coded per branch status. - -## Non-Goals For Initial Demo - -- No macro UI or macro examples. -- No add-node workflow in the visual editor. -- No full schema-aware field picker. -- No syntax highlighting until the base editor and rewrite loop work. -- No production WASM packaging story outside the demo directory. -- No attempt to port the old `rulekit-js` app directly; reuse its styling/assets as inspiration and source material. - -## Directory Shape - -Use a new demo directory, tentatively: - -```text -demo/vue/ - README.md - package.json - vite.config.ts - tsconfig.json - index.html - wasm/ - main.go - build.sh - wasm_exec.js - public/ - rulekit.wasm - wasm_exec.js - assets copied/adapted from rulekit-js - src/ - main.ts - App.vue - components/ - lib/ - styles/ -``` - -All WASM-specific files should live under `demo/vue/wasm` or `demo/vue/public`. Do not add root-level `cmd/wasm`, root `Makefile` targets, or root WASM docs unless we later decide the WASM API is part of the product. - -## Visual Direction - -Reuse the visual language from `rulekit-js`: - -- dark blue/black CRT background -- scanline/noise texture -- pixel/terminal font, especially `DepartureMono-Regular.woff2` -- layered beveled panels -- blue headers for neutral editor panels -- green result panels for pass -- red result panels for fail -- yellow/orange for missing input -- purple/red for errors -- dim gray for pruned branches -- optional bird/retro sprite assets if they do not distract from the editor - -The old visual rule tree prototype is not binding. Keep the core idea of rule expressions as structured visual tokens, but redesign freely for clarity. - -## Initial Layout - -```text -┌─────────────────────────────┬─────────────────────────────┐ -│ Rule Text │ Visual Rule Editor │ -│ [Format Compact] [Format ML]│ editable AST representation │ -└─────────────────────────────┴─────────────────────────────┘ - -┌─────────────────────────────┬─────────────────────────────┐ -│ Input JSON │ Result + Annotated Trace │ -│ example data buttons │ read-only colored AST tree │ -└─────────────────────────────┴─────────────────────────────┘ -``` - -For milestone 1, the bottom evaluation row can be stubbed or hidden. The first priority is the text/visual editor sync. - -## WASM API Surface - -Expose a small browser API from Go. Keep it demo-local. - -```ts -parse(source: string): ParseResponse -format(source: string, mode: "compact" | "multiline"): SourceResponse -rewrite(source: string, edit: EditRequest): ParseResponse -deleteNode(source: string, target: NodeRef): ParseResponse -evalRule(source: string, inputJSON: string): EvalResponse -``` - -Use source text as the unit of exchange initially. Avoid rule handles for the first version. Reparse on each operation; this keeps lifecycle and memory management simple and is fast enough for a demo. - -Example DTOs: - -```ts -type ParseResponse = { - ok: boolean - source?: string - compact?: string - multiline?: string - ast?: AstNode - tokens?: Token[] - error?: string -} - -type EvalResponse = { - ok: boolean - value?: unknown - error?: string - missingFields?: string[] - trace?: TraceNode -} -``` - -Node references should use parser-produced spans or stable path indexes in the returned AST. Prefer source spans for text selection and rewrite targeting. - -## Editor Sync Model - -Text edit flow: - -1. User edits source text. -2. Debounce parse. -3. If parse succeeds, update visual AST and clear parse error. -4. If parse fails, keep the last valid visual AST and show parse error near the text editor. - -Visual edit flow: - -1. User selects a visual AST node. -2. User changes an editable field/operator/literal or deletes a safe node. -3. Browser sends `rewrite` or `deleteNode` request to WASM with the current source and target node reference. -4. WASM returns updated source and AST. -5. Text editor updates from returned source. -6. Visual editor updates from returned AST. - -Formatting flow: - -1. User clicks `Format Compact` or `Format Multiline`. -2. Browser calls `format`. -3. Text editor updates from returned source. -4. Visual editor reparses or uses returned AST if provided. - -## Selection Sync - -This is central to the demo. - -- Clicking a visual node highlights its source range in the text editor. -- Selecting text highlights the smallest AST node whose span contains that selection. -- Delete applies to the selected visual node. -- Format should preserve selection where practical, but this can be a later polish item. - -## Visual Editor Scope - -Initial editable operations: - -- Rewrite a field/path string. -- Rewrite a literal value. -- Rewrite a comparison/operator from a constrained list. -- Rewrite an array literal element value. -- Delete safe nodes. - -Initial delete semantics: - -- Deleting one side of `and` / `or` collapses the expression to the remaining side. -- Deleting a predicate from a larger same-operator chain preserves the remaining chain. -- Deleting an array element removes only that element. -- Deleting the only element of an array is allowed only if empty arrays are valid for that expression; otherwise disable delete. -- Do not allow deleting just a field/operator/value from a comparison. Require rewrite instead. -- Do not allow deleting function names or partial function syntax in the first version. - -No add-node workflow yet. - -## Evaluation And Trace UI - -After the editor loop works, add evaluation: - -- JSON input editor with pass/fail preset buttons. -- Evaluate current valid source against JSON input. -- Show result state: - - pass - - fail - - missing - - error -- Render a separate read-only annotated visual AST from `Result.Trace`. - -Trace tree styling: - -- `passed`: green -- `failed`: red -- `missing`: yellow/orange -- `error`: purple/red -- `pruned`: dim gray -- `unknown`: neutral - -Trace nodes should use the same visual grammar as the editor tree, but annotated with status, value, missing fields, and pruning state. - -## Example Rules - -Use examples that show v2 capabilities: - -```text --- exact key with punctuation -request.headers["user-agent"] contains "curl" -``` - -```text --- exact top-level dotted key -["destination.ip"] == 192.168.1.1 -``` - -```text --- multiline boolean tree -( - dst.port in [3306, 5432, 27017, 6379] - and src.pod.namespace in ["api", "backend"] -) -or dst.domain in ["registry.k8s.io", "docker.io"] -``` - -Include comments in examples so source-preserving rewrite is visible. - -## Milestones - -### Milestone 1: WASM Skeleton - -- Create demo directory and Vite/Vue skeleton. -- Copy/adapt font, base CSS, and useful visual assets from `rulekit-js`. -- Build demo-local WASM bridge. -- Implement `parse` and `format` WASM functions. -- Render parse errors and canonical source output. - -### Milestone 2: Text + Visual AST Sync - -- Render AST as a sideways visual tree. -- Keep text and visual tree synced on valid text edits. -- Keep last valid tree visible on parse errors. -- Implement visual node selection and source-range highlight. - -### Milestone 3: Visual Rewrite/Delete - -- Implement rewrite for fields, literals, operators, and array elements. -- Implement safe delete semantics. -- Call Go `Rewrite` through WASM and update text from returned source. -- Add compact/multiline format buttons. - -### Milestone 4: Evaluation + Annotated Trace - -- Add JSON input panel and preset data buttons. -- Add `evalRule` WASM function. -- Render result card. -- Render annotated trace tree as read-only visual AST. - -### Milestone 5: Syntax Highlighting - -- Add CodeMirror rule syntax highlighting. -- Keep highlighting separate from parser correctness. -- Highlight parse errors and selected AST ranges. - -## Open Questions - -- Should the first version use CodeMirror, Monaco, or a lightweight textarea? -- Should visual AST nodes be rendered as nested rows, a true sideways graph, or a form-like tree similar to the prototype? -- What exact edit request format should identify a target: source span, AST path, or generated node ID? -- Should JSON input support annotated/typed modes in the first evaluation milestone, or plain JSON only? -- Should demo-local WASM expose raw AST JSON, UI-specific AST JSON, or both? - -## Success Criteria - -- A user can type a rule and see the visual AST update live. -- A user can edit/delete existing visual nodes and see source update through Go rewrite logic. -- Formatting uses the Go formatter, not a TypeScript formatter. -- Invalid text does not corrupt the visual editor state. -- Evaluation shows a result and a color-coded trace tree. -- The app visibly demonstrates v2 capabilities that v1 did not support well: source spans, formatting, rewrite, bracket paths, and trace explanation. diff --git a/Makefile b/Makefile index e1dd33e..830a30f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,3 @@ -.PHONY: default -default: - ./gen.sh - .PHONY: test test: go test ./... diff --git a/README.md b/README.md index 33a53c1..ce22849 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ A field on its own (without an operator) will check if the field contains a non- ## Usage Example ```go -import "github.com/qpoint-io/rulekit" +import "github.com/qpoint-io/rulekit/v2" // ... @@ -89,7 +89,7 @@ The Result also provides additional helper methods: | ---------- | ------ | -------------------------------------------------------------------- | | `or` | `\|\|` | Logical OR | | `and` | `&&` | Logical AND | -| `not` | `!` | Logical NOT | +| `not` | `!` | Logical NOT. Binds looser than comparisons, so `not a == 1` means `not (a == 1)` | | `()` | | Parentheses for grouping | | `==` | `eq` | Equal to | | `!=` | `ne` | Not equal to | @@ -97,9 +97,10 @@ The Result also provides additional helper methods: | `>=` | `ge` | Greater than or equal to | | `<` | `lt` | Less than | | `<=` | `le` | Less than or equal to | -| `contains` | | Check if a value contains another value | -| `in` | | Check if a value is contained within an array or an IP within a CIDR. If the left side is an array, the check passes when ANY of its elements matches (same as `==` and `contains`) | -| `matches` | | Match against a regular expression | +| `contains` | | A string contains a substring, an array contains an element, or a CIDR contains an IP | +| `in` | | A value is an element of an array, or an IP is within a CIDR. If the left side is an array, the check passes when ANY of its elements matches (same as `==` and `contains`) | +| `matches` | `=~` | Match against a regular expression | +| `not contains`, `not in`, `not matches` | `not =~` | The negation of `contains`, `in`, or `matches`. `a not in [1, 2]` means `not (a in [1, 2])` | ## Supported Types @@ -107,19 +108,35 @@ The Result also provides additional helper methods: | Type | Used As | Example | Description | | ---------------------- | ------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **bool** | VALUE, FIELD | `true` | Valid values: `true`, `false` | -| **number** | VALUE, FIELD | `8080` | Integer or float. Parsed as either int64 or uint64 if out of range for int64, or float64 if float. | -| **string** | VALUE, FIELD | `"domain.com"` | A double-quoted string. Quotes may be escaped with a backslash: `"a string \"with\" quotes"`. Any quoted value is parsed as a string. | +| **bool** | VALUE, FIELD | `true` | Valid values: `true`, `false` (any letter case) | +| **number** | VALUE, FIELD | `8080`, `0x1f`, `1_000`, `1.5` | Integer or float. Integers may be decimal (leading zeros are still decimal: `010` is ten) or use a `0x`, `0o`, or `0b` prefix, with optional `_` separators. Parsed as int64, or uint64 if out of range for int64, or float64 if float. | +| **string** | VALUE, FIELD | `"domain.com"`, `'domain.com'` | A double- or single-quoted string. Both accept backslash escapes: `"a \"quoted\" word"`, `'it\'s'`. Must be valid UTF-8; use `x"..."` for arbitrary bytes. A quoted value is always a string, even if it looks like an IP address or URL. Comparing a string with an IP address, CIDR, MAC address, or URL compares against that value's text form (e.g. `"cafe::"` for the IPv6 address `CAFE::`). | | **IP address** | VALUE, FIELD | `192.168.1.1`, `2001:db8:3333:4444:cccc:dddd:eeee:ffff` | An IPv4, IPv6, or an IPv6 dual address. Maps to Go type: `net.IP` | | **CIDR** | VALUE | `192.168.1.0/24`, `2001:db8:3333:4444:cccc:dddd:eeee:ffff/64` | An IPv4 or IPv6 CIDR block. Maps to Go type: `*net.IPNet` | -| **Hexadecimal string** | VALUE, FIELD | `12:34:56:78:ab` (MAC address), `504f5354` (hex string "POST") | A hexadecimal string, optionally separated by colons. | -| **Regex** | VALUE | `/example\.com$/` | A Go-style regular expression. Must be surrounded by forward slashes. May not be quoted with double quotes (otherwise it will be parsed as a string). Maps to Go type: `*regexp.Regexp` | +| **MAC address** | FIELD | `01:23:45:67:89:ab` | A MAC address from the input. Compares as bytes with hexadecimal values such as `01:23:45:67:89:ab`, and with strings by its lowercase colon text. Maps to Go type: `net.HardwareAddr` | +| **Hexadecimal string** | VALUE, FIELD | `50:4f:53:54`, `x"504f5354"`, `x"0a"` | Bytes, written either as two or more colon-separated hex pairs or as hex digits in `x"..."` (`X` and single quotes also work). Equals a string with the same bytes (`x"504f5354" == "POST"`) or a MAC address with the same value. Eight colon-separated pairs read as an IPv6 address; use `x"..."` for 8-byte values. | +| **URL** | FIELD | | A URL from the input: an RFC 3986 URL in ASCII (see Text Forms). Compares with strings by its text. Maps to Go type: `rulekit.URL` (build one with `rulekit.ParseURL`). A `*url.URL` also works, compared by its `String()` form with a lowercase host | +| **Regex** | VALUE | `/example\.com$/`, `/curl/i`, `\|a/b\|` | A regular expression in [RE2 syntax](https://github.com/google/re2/wiki/Syntax), surrounded by forward slashes or by `\|` (handy when the pattern contains `/`). May not be quoted with double quotes (otherwise it will be parsed as a string). Lowercase flags may follow the closing delimiter: `i` (ignore case), `m` (`^` and `$` match at line breaks), `s` (`.` matches newlines). `\d`, `\w`, and `\b` match ASCII only, and `\s` matches space, `\t`, `\n`, `\f`, and `\r`. Repetition counts go up to 1000. Not supported: `\Q...\E`, `\<` and `\>`, numeric escapes such as `\0` (use `\x{...}`), `{,n}` (use `{0,n}`), a `{` that does not start a repetition (use `\{`), `\p{^...}` (use `\P{...}`), nested classes, and `&&`, `--`, or `~~` inside a class. Unicode classes accept general categories (`\pL`, `\p{Lu}`, `\p{Letter}`), scripts (`\p{Greek}`), `Any`, `ASCII`, and `Assigned`. Also not supported: duplicate or digit-first capture group names, duplicate flags, `(?)`, a repetition right after a flag group such as `a(?i)*`, counts with leading zeros such as `{01}`, a class starting with `]-`, surrogate code points such as `\x{D800}`, and more than 50 nested groups. | + +### Text Forms + +When an IP address, CIDR, MAC address, or URL is compared with a string (with `==`, `!=`, `contains`, `matches`, or `in` a list of strings), it is compared by this text form: + +| Type | Text form | Example | +| ---- | --------- | ------- | +| IPv4 address | Dotted decimal | `10.0.0.1` | +| IPv6 address | Lowercase, with the longest run of zero groups shortened to `::`. IPv4-mapped addresses (`::ffff:1.2.3.4`) print as IPv4 | `2001:db8::1`, `1.2.3.4` | +| CIDR | Network address, `/`, prefix length | `10.0.0.0/8` | +| MAC address | Lowercase hex pairs separated by `:` | `aa:bb:cc:dd:ee:ff` | +| URL | As written, with the scheme and host in lowercase; nothing is added, removed, or re-encoded | `https://example.com:443/a%20b?q=1` | + +To compare by value instead, use an unquoted literal: `ip == 2001:DB8::1` matches however the address is written. ### Constructs | Type | Used As | Example | Description | | ------------ | ------- | ------------------------------ | --------------------------------------------------------------------------------------------- | -| **Array** | VALUE | `[1, "string", true]` | An array of mixed value types. Can be used with most operators including `in` and `contains`. | +| **Array** | VALUE | `[1, "string", true]` | An array of mixed value types. Use it on the right of `in`, `==` (any element is equal), or `!=` (no element is equal), or on the left of `contains`. | | **Function** | VALUE | `starts_with(url, "https://")` | A function call with optional arguments. Can be built-in or custom. | | **Macro** | VALUE | `isValidRequest()` | A zero-argument function that encapsulates a predefined rule. | @@ -131,7 +148,7 @@ Dot syntax traverses nested maps and objects: destination.ip == 192.168.1.1 ``` -Use bracket syntax for exact map keys that contain dots, spaces, slashes, reserved words, or other punctuation: +Field names are ASCII: a letter or `_`, then letters, digits, `_`, `.`, or `-`. Use bracket syntax for exact map keys that contain other characters, such as spaces, slashes, non-ASCII letters, reserved words, or other punctuation: ```perl labels["app.kubernetes.io/name"] == "api" @@ -142,6 +159,35 @@ items[0].name == "first" Plain dotted fields do not fall back to flat keys. If the input contains a top-level key named `destination.ip`, use `["destination.ip"]`. +### Value Fields + +IP addresses, CIDRs, MAC addresses, and URLs expose read-only fields using the same path syntax: + +```perl +request.url.scheme == "https" and request.url.host == "api.example.com" +request.url.query["tag"] == "beta" +source.ip.version == "v6" +destination.net.prefix >= 24 +device.mac.oui == 00:1a:2b +``` + +| Type | Field | Value | +| ---- | ----- | ----- | +| URL | `scheme` | Lowercase scheme, e.g. `"https"` | +| URL | `host` | Lowercase host name without the port | +| URL | `port` | Port number, if the URL has one | +| URL | `path` | Path as written, with percent escapes kept, e.g. `"/api/v1"` or `"/a%20b"` (`""` if empty) | +| URL | `query["name"]` or `query.name` | Query parameter value, decoded like an HTML form: pairs are separated by `&`, `+` is a space, and `%XX` escapes are decoded. A parameter given more than once is a list. `query` on its own is the raw query text | +| URL | `fragment` | Text after `#`, with `%XX` escapes decoded | +| URL | `user` | User name, with `%XX` escapes decoded | +| IP address | `version` | `"v4"` or `"v6"` | +| CIDR | `network` | Network address, e.g. `10.0.0.0` | +| CIDR | `prefix` | Prefix length, e.g. `16` | +| CIDR | `version` | `"v4"` or `"v6"` | +| MAC address | `oui` | First three bytes, e.g. `00:1a:2b` | + +A field that the value doesn't have, such as `port` on `https://example.com`, is missing, so the rule result is unknown rather than false. Fields only apply to typed values: a map with a `host` key is read as a map, and a string is never treated as a URL. + ### JSON Input Helpers `DecodeJSON` converts JSON documents into `rulekit.KV`. Plain JSON decodes dynamically by default. Annotated key suffixes are opt-in and are intended for values that JSON cannot represent natively: @@ -150,7 +196,7 @@ Plain dotted fields do not fall back to flat keys. If the input contains a top-l kv, err := rulekit.DecodeJSON(data, rulekit.JSONOptions{AnnotatedKeys: true}) ``` -Supported suffixes include `.$ip`, `.$cidr`, `.$mac`, `.$hex`, `.$base64`, `.$bytes_hex`, `.$bytes_base64`, `.$string`, `.$bool`, `.$int64`, `.$uint64`, and `.$float64`. +Supported suffixes include `.$ip`, `.$cidr`, `.$mac`, `.$url`, `.$hex`, `.$base64`, `.$bytes_hex`, `.$bytes_base64`, `.$string`, `.$bool`, `.$int64`, `.$uint64`, and `.$float64`. Fully typed documents are a separate mode. In this mode, every field value must be a typed object and annotated keys are rejected: @@ -182,6 +228,51 @@ The public AST view is read-only. Build edited expressions by parsing replacemen `AST.Tokens()` returns the token stream with byte spans plus leading and trailing whitespace/comment trivia for source-aware tools. +`json.Marshal(ast)` encodes the AST as JSON with the source, the node tree, and the tokens (excluding EOF). `ast.JSON()` returns the same document as Go values. + +```go +ast, err := rulekit.ParseAST(`age >= 18`) +if err != nil { /* ... */ } +data, err := json.Marshal(ast) +``` + +```json +{ + "source": "age >= 18", + "root": { + "id": "root", + "kind": "binary", + "text": "age >= 18", + "operator": "ge", + "raw": ">=", + "span": {"start": 0, "end": 9, "startLine": 1, "startColumn": 1, "endLine": 1, "endColumn": 10}, + "children": [ + { + "id": "root.0", + "kind": "path", + "text": "age", + "path": "age", + "span": {"start": 0, "end": 3, "startLine": 1, "startColumn": 1, "endLine": 1, "endColumn": 4} + }, + { + "id": "root.1", + "kind": "literal", + "text": "18", + "raw": "18", + "span": {"start": 7, "end": 9, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 10} + } + ] + }, + "tokens": [ + {"kind": "FIELD", "role": "id", "raw": "age", "span": {"start": 0, "end": 3, "startLine": 1, "startColumn": 1, "endLine": 1, "endColumn": 4}}, + {"kind": "GE", "role": "kw", "raw": ">=", "span": {"start": 4, "end": 6, "startLine": 1, "startColumn": 5, "endLine": 1, "endColumn": 7}}, + {"kind": "INT", "role": "num", "raw": "18", "span": {"start": 7, "end": 9, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 10}} + ] +} +``` + +Node IDs are tree positions: `root`, then `.`. `text` is the node's compact expression. Unary and binary nodes carry the normalized `operator` and its source spelling in `raw`; `not contains`, `not matches`, and `not in` carry the operator being negated plus `"negated": true`. Literals carry their source token in `raw`, calls their function name, and paths their rendered `path`. Spans are byte offsets with 1-based lines and byte columns. Token `role` is `id`, `str`, `num`, `kw`, or `pun`. + Use `Print` and `Format` for explicit output modes: ```go @@ -255,53 +346,60 @@ Rulekit comes with a built-in standard library of functions: | Function | Description | Example | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| `starts_with(value, prefix)` | Checks if a value starts with the given prefix. Works with strings, numbers, and other types by converting them to strings. | `starts_with(url, "https://")` | +| `starts_with(value, prefix)` | Checks if a value starts with the given prefix. Both arguments must be strings or values with a text form (IP address, CIDR, MAC address, URL); other types are an error. | `starts_with(url, "https://")` | ### Custom Functions -Custom functions may be used to extend Rulekit with additional functionality. Note that functions only have access to their arguments and do not have access to the context KV map. Rulekit will validate the function's arguments per the provided spec before executing the handler. +Define a custom function with `rulekit.Func`: an argument struct whose fields are the parameters, in call order, and a handler that receives the evaluation context and the arguments. ```go -// define a custom function -customFuncs := map[string]*rulekit.Function{ - "randomInt": { - Args: []rulekit.FunctionArg{ - {Name: "min"}, - {Name: "max"}, - }, - Eval: func(args map[string]any) rulekit.Result { - // use the rulekit.IndexFuncArg helper to retrieve args and validate types. - // rulekit.IndexFuncArg[any] will skip type validation. - min, err := rulekit.IndexFuncArg[int64](args, "min") - if err != nil { - return rulekit.Result{Error: err} - } - - max, err := rulekit.IndexFuncArg[int64](args, "max") - if err != nil { - return rulekit.Result{Error: err} - } - - num := rand.IntN(max-min) + min - return rulekit.Result{ - Value: num, - } - }, - }, +type clampArgs struct { + N int64 + Max int64 `rulekit:"limit"` // parameter name; default is the field name in snake_case ("max") } -// call the function in a rule -rule, err := rulekit.Parse(`randomInt(10, 20) == 15`) -if err != nil { /* ... */ } +// Package-level, so a bad definition panics at startup. +var clamp = rulekit.Func(rulekit.FuncSchema{Name: "clamp", Doc: "Clamp n to limit."}, + func(ctx context.Context, a clampArgs) (int64, error) { + return min(a.N, a.Max), nil + }) -result := rule.Eval(context.Background(), nil, rulekit.Opts{Functions: customFuncs}) -if result.Error != nil { /* ... */ } +var functions = rulekit.NewFunctionSet(clamp) -if result.Pass() { - // the random number is 15! -} +rule, err := rulekit.Parse(`clamp(request.size, 1024) == 1024`) +if err != nil { /* ... */ } + +result := rule.Eval(ctx, input, rulekit.Opts{Functions: functions}) ``` +Parameter (field) and result types, and the rule values they accept: + +| Go type | Rule value | +| ------------------ | --------------------------------------------- | +| `bool` | bool | +| `int64` | int64 | +| `uint64` | uint64 | +| `float64` | float64 | +| `string` | string | +| `[]byte` | bytes | +| `net.IP` | ip | +| `*net.IPNet` | cidr | +| `net.HardwareAddr` | mac | +| `rulekit.URL` | url | +| `*regexp.Regexp` | regex | +| `[]any` | array | +| `map[string]any` | object | +| `any` | any value, unconverted | + +Conversions are exact: an `int64` parameter does not accept a `uint64` or `float64` argument. A named type over `bool`, `int64`, `uint64`, `float64`, `string`, `[]byte`, `[]any`, or `map[string]any` (such as `type Port uint64`) works like its underlying type, both as a parameter and as a result. The last field may have type `rulekit.Rest` to accept any number of further arguments, unconverted. + +- An argument of the wrong type, or the wrong number of arguments, makes the result an error (`*rulekit.ErrInvalidFunctionArg` for a type), without calling the handler. +- If an argument is missing from the input, the result is unknown and the handler is not called. +- A handler error makes the result an error wrapping it. Return `rulekit.ErrMissing("field", ...)` instead to make the result unknown with those missing fields. +- `rulekit.Func` panics on an argument struct or result type it does not support, like `regexp.MustCompile`. + +Functions receive only their arguments and the context passed to `Eval`, not the rule input: pass input values as arguments. Macros see the input but take no arguments. + ## License [MIT](./LICENSE) diff --git a/ast.go b/ast.go index 333f61a..45c2954 100644 --- a/ast.go +++ b/ast.go @@ -183,6 +183,9 @@ type astBinary struct { op astOperator rawOp string right astNode + // negated marks `not contains`, `not matches`, and `not in`: the base + // operator with a negation applied on top. + negated bool } func (n *astBinary) astSpan() astSpan { return n.span } @@ -251,20 +254,29 @@ func lowerAST(node astNode) (Rule, error) { if err != nil { return nil, err } - switch n.op { + op := n.op + var r Rule + switch op { case astOpAnd: - return withTrace(n, &nodeAnd{left: left, right: right}), nil + r = &nodeAnd{left: left, right: right} case astOpOr: - return withTrace(n, &nodeOr{left: left, right: right}), nil + r = &nodeOr{left: left, right: right} case astOpEQ, astOpNE, astOpContains, astOpGT, astOpGE, astOpLT, astOpLE: - return withTrace(n, &nodeCompare{lv: left, op: tokenKindFromASTOperator(n.op), rv: right}), nil + r = &nodeCompare{lv: left, op: tokenKindFromASTOperator(op), rv: right} case astOpMatches: - return withTrace(n, &nodeMatch{lv: left, rv: right}), nil + r = &nodeMatch{lv: left, rv: right} case astOpIn: if literalIs[*net.IPNet](right) { - return withTrace(n, &nodeCompare{lv: left, op: op_EQ, rv: right}), nil + r = &nodeCompare{lv: left, op: op_EQ, rv: right} + } else { + r = &nodeIn{lv: left, rv: right} + } + } + if r != nil { + if n.negated { + r = &nodeNot{right: r} } - return withTrace(n, &nodeIn{lv: left, rv: right}), nil + return withTrace(n, r), nil } } return nil, &astLowerError{span: node.astSpan(), msg: "unsupported AST node"} @@ -317,8 +329,17 @@ func printASTWithParent(node astNode, parentPrec int, rightChild bool) string { right = "(" + printAST(n.right) + ")" } out = "not " + right + // not binds looser than comparisons, so it needs parentheses as a + // comparison operand. + if parentPrec >= astPrecedence(&astBinary{op: astOpEQ}) { + return "(" + out + ")" + } case *astBinary: - out = printASTWithParent(n.left, prec, false) + " " + astOperatorString(n.op) + " " + printASTWithParent(n.right, prec, true) + operator := astOperatorString(n.op) + if n.negated { + operator = "not " + operator + } + out = printASTWithParent(n.left, prec, false) + " " + operator + " " + printASTWithParent(n.right, prec, true) } if prec > 0 && (prec < parentPrec || (rightChild && prec == parentPrec)) { diff --git a/ast_api.go b/ast_api.go index 231e7f2..fea2d5b 100644 --- a/ast_api.go +++ b/ast_api.go @@ -147,6 +147,13 @@ func NodeRawOperator(node ASTNode) string { } } +// NodeNegated reports whether a binary node is negated, as in `not contains`, +// `not matches`, or `not in`. NodeOperator returns the operator being negated. +func NodeNegated(node ASTNode) bool { + n, ok := node.(*astBinary) + return ok && n.negated +} + // NodeLiteral returns the raw literal token for literal nodes. func NodeLiteral(node ASTNode) (raw string, ok bool) { lit, ok := node.(*astLiteral) @@ -265,8 +272,38 @@ func tokenKindString(kind int) string { return "MATCHES" case op_IN: return "IN" + case token_FIELD: + return "FIELD" + case token_STRING: + return "STRING" + case token_INT: + return "INT" + case token_FLOAT: + return "FLOAT" + case token_BOOL: + return "BOOL" + case token_IP: + return "IP" + case token_IP_CIDR: + return "IP_CIDR" + case token_HEX_STRING: + return "HEX_STRING" + case token_REGEX: + return "REGEX" + case token_LPAREN: + return "LPAREN" + case token_RPAREN: + return "RPAREN" + case token_LBRACKET: + return "LBRACKET" + case token_RBRACKET: + return "RBRACKET" + case token_DOT: + return "DOT" + case token_COMMA: + return "COMMA" default: - return valueTokenString(kind) + return "UNKNOWN" } } diff --git a/ast_json.go b/ast_json.go new file mode 100644 index 0000000..ba662fd --- /dev/null +++ b/ast_json.go @@ -0,0 +1,207 @@ +package rulekit + +import ( + "encoding/json" + "sort" + "strconv" +) + +// JSONAST is the JSON form of a parsed AST. Marshal an *AST to produce it. +type JSONAST struct { + Source string `json:"source"` + Root *JSONNode `json:"root"` + Tokens []JSONToken `json:"tokens"` +} + +// JSONNode is the JSON form of one AST node. +// +// ID is the node's position in the tree: "root" for the root node and +// "." for each child. Text is the compact canonical +// expression for the node. Operator and Raw are set for unary and binary nodes +// (normalized and source spelling); Negated is true for `not contains`, +// `not matches`, and `not in`, whose Operator is the operator being negated. +// Raw is the literal token for literal nodes and the function name for call +// nodes. Path is set for path nodes. +type JSONNode struct { + ID string `json:"id"` + Kind string `json:"kind"` + Text string `json:"text"` + Operator string `json:"operator,omitempty"` + Negated bool `json:"negated,omitempty"` + Raw string `json:"raw,omitempty"` + Path string `json:"path,omitempty"` + Span JSONSpan `json:"span"` + Children []JSONNode `json:"children,omitempty"` +} + +// JSONToken is the JSON form of one source token. Role groups token kinds for +// syntax highlighting: "id" (fields), "str" (string-like literals), "num" +// (numbers), "kw" (operators), or "pun" (punctuation). +type JSONToken struct { + Kind string `json:"kind"` + Role string `json:"role"` + Raw string `json:"raw"` + Span JSONSpan `json:"span"` +} + +// JSONSpan is a byte span with 1-based line and column positions. Columns count +// bytes from the start of the line. +type JSONSpan struct { + Start int `json:"start"` + End int `json:"end"` + StartLine int `json:"startLine"` + StartColumn int `json:"startColumn"` + EndLine int `json:"endLine"` + EndColumn int `json:"endColumn"` +} + +// MarshalJSON encodes the AST as a JSONAST document. +func (a *AST) MarshalJSON() ([]byte, error) { + return json.Marshal(a.JSON()) +} + +// JSON returns the JSON form of the AST. Tokens exclude the EOF token. +func (a *AST) JSON() JSONAST { + if a == nil { + return JSONAST{} + } + lines := newLineIndex(a.source) + out := JSONAST{Source: a.source, Root: jsonNode(lines, a.Root(), "root"), Tokens: make([]JSONToken, 0, len(a.tokens))} + for _, tok := range a.tokens { + if tok.Kind == "EOF" { + continue + } + out.Tokens = append(out.Tokens, JSONToken{Kind: tok.Kind, Role: tokenRole(tok.Kind), Raw: tok.Raw, Span: lines.span(tok.Span)}) + } + return out +} + +// JSONSpan returns the JSON form of a span in the AST source. +func (a *AST) JSONSpan(span Span) JSONSpan { + return newLineIndex(a.Source()).span(span) +} + +// String returns the JSON kind name of an AST node kind. +func (k ASTKind) String() string { + switch k { + case ASTLiteral: + return "literal" + case ASTPath: + return "path" + case ASTArray: + return "array" + case ASTCall: + return "call" + case ASTUnary: + return "unary" + case ASTBinary: + return "binary" + default: + return "unknown" + } +} + +// String returns the operator's machine name, as used in JSON and +// diagnostics. Printed rules use the human spelling (==, =~, ...) instead. +func (o Operator) String() string { + switch o { + case OperatorNot: + return "not" + case OperatorAnd: + return "and" + case OperatorOr: + return "or" + case OperatorEQ: + return "eq" + case OperatorNE: + return "ne" + case OperatorGT: + return "gt" + case OperatorGE: + return "ge" + case OperatorLT: + return "lt" + case OperatorLE: + return "le" + case OperatorContains: + return "contains" + case OperatorMatches: + return "matches" + case OperatorIn: + return "in" + default: + return "unknown" + } +} + +func jsonNode(lines lineIndex, node ASTNode, id string) *JSONNode { + if node == nil { + return nil + } + out := &JSONNode{ID: id, Kind: node.Kind().String(), Text: node.String(), Span: lines.span(node.Span())} + switch n := node.(type) { + case *astUnary: + out.Operator, out.Raw = publicOperator(n.op).String(), n.rawOp + case *astBinary: + out.Operator, out.Negated, out.Raw = publicOperator(n.op).String(), n.negated, n.rawOp + case *astLiteral: + out.Raw = n.raw + case *astPath: + out.Path = (&PathValue{segments: n.segments}).String() + case *astCall: + out.Raw = n.name + } + children := node.Children() + if len(children) > 0 { + out.Children = make([]JSONNode, 0, len(children)) + } + for i, child := range children { + out.Children = append(out.Children, *jsonNode(lines, child, id+"."+strconv.Itoa(i))) + } + return out +} + +func tokenRole(kind string) string { + switch kind { + case "FIELD": + return "id" + case "STRING", "REGEX", "IP", "IP_CIDR", "HEX_STRING", "BOOL": + return "str" + case "INT", "FLOAT": + return "num" + case "AND", "OR", "NOT", "EQ", "NE", "GT", "GE", "LT", "LE", "CONTAINS", "MATCHES", "IN": + return "kw" + default: + return "pun" + } +} + +// lineIndex maps byte offsets in a source to line and column positions. +type lineIndex struct { + starts []int // byte offset where each line starts + size int // source length in bytes +} + +func newLineIndex(source string) lineIndex { + starts := []int{0} + for i := range len(source) { + if source[i] == '\n' { + starts = append(starts, i+1) + } + } + return lineIndex{starts: starts, size: len(source)} +} + +func (l lineIndex) span(span Span) JSONSpan { + startLine, startColumn := l.position(span.Start) + endLine, endColumn := l.position(span.End) + return JSONSpan{Start: span.Start, End: span.End, StartLine: startLine, StartColumn: startColumn, EndLine: endLine, EndColumn: endColumn} +} + +// position returns the 1-based line and byte column of offset, clamped to the +// source bounds. +func (l lineIndex) position(offset int) (int, int) { + offset = min(max(offset, 0), l.size) + line := sort.Search(len(l.starts), func(i int) bool { return l.starts[i] > offset }) - 1 + return line + 1, offset - l.starts[line] + 1 +} diff --git a/ast_test.go b/ast_test.go index b5139ef..6e39a0f 100644 --- a/ast_test.go +++ b/ast_test.go @@ -1,62 +1,11 @@ package rulekit import ( - "net" "testing" "github.com/stretchr/testify/require" ) -func TestPrintASTCompact(t *testing.T) { - tests := map[string]string{ - `field == "value" and other != 1`: `field == "value" and other != 1`, - `domain matches /example\.com$/ OR tags == "x"`: `domain =~ /example\.com$/ or tags == "x"`, - `a or (b and c)`: `a or b and c`, - `(a or b) and c`: `(a or b) and c`, - `request.headers["user-agent"] == "curl"`: `request.headers["user-agent"] == "curl"`, - `items[0].name in ["first", "second"]`: `items[0].name in ["first", "second"]`, - `starts_with(path, "/api")`: `starts_with(path, "/api")`, - } - - for input, want := range tests { - t.Run(input, func(t *testing.T) { - ast, err := parseAST(input) - require.NoError(t, err) - require.Equal(t, want, printAST(ast)) - }) - } -} - -func TestPrintASTSemanticRoundTrip(t *testing.T) { - expressions := []string{ - `field == "value" and other != 1`, - `domain matches /example\.com$/ OR tags == "x"`, - `(a or b) and c`, - `not (a == 1 or b == 2)`, - `request.headers["user-agent"] == "curl"`, - `items[0].name in ["first", "second"]`, - `ip in 192.168.0.0/16`, - `starts_with(path, "/api")`, - } - - for _, input := range expressions { - t.Run(input, func(t *testing.T) { - ast, err := parseAST(input) - require.NoError(t, err) - - printed := printAST(ast) - roundTripped, err := parseAST(printed) - require.NoError(t, err) - - originalRule, err := lowerAST(ast) - require.NoError(t, err) - printedRule, err := lowerAST(roundTripped) - require.NoError(t, err) - require.Equal(t, originalRule.String(), printedRule.String()) - }) - } -} - func TestPublicASTAPI(t *testing.T) { ast, err := ParseAST(`request.headers["user-agent"] == "curl"`) require.NoError(t, err) @@ -82,43 +31,6 @@ func TestPublicASTAPI(t *testing.T) { require.Equal(t, `request.headers["user-agent"] == "curl"`, rule.String()) } -func TestFormat(t *testing.T) { - ast, err := ParseAST(`(a == 1 or b == 2) and c == 3 and request.headers["user-agent"] == "curl"`) - require.NoError(t, err) - - compact := Format(ast, Compact()) - require.Equal(t, `(a == 1 or b == 2) and c == 3 and request.headers["user-agent"] == "curl"`, compact) - require.Equal(t, `(a == 1 or b == 2) and c == 3 and request.headers["user-agent"] == "curl"`, MustParse(ast.Source()).Print(Compact())) - require.Equal(t, ast.Source(), MustParse(ast.Source()).Print(Source())) - - multiline := Format(ast, Multiline(" ")) - require.Equal(t, `( - a == 1 - or b == 2 -) -and c == 3 -and request.headers["user-agent"] == "curl"`, multiline) - - roundTrip, err := ParseAST(multiline) - require.NoError(t, err) - require.Equal(t, compact, roundTrip.String()) -} - -func TestFormatPreservesComments(t *testing.T) { - ast, err := ParseAST("-- top\na==1 -- explain\n and b matches /x/") - require.NoError(t, err) - - compact := Format(ast, Compact()) - require.Equal(t, "-- top\na == 1 -- explain\nand b =~ /x/", compact) - - multiline := Format(ast, Multiline(" ")) - require.Equal(t, "-- top\na == 1 -- explain\nand b =~ /x/", multiline) - - roundTrip, err := ParseAST(compact) - require.NoError(t, err) - require.Equal(t, `a == 1 and b =~ /x/`, roundTrip.String()) -} - func TestASTTokensIncludeTrivia(t *testing.T) { ast, err := ParseAST("field == 1 -- explain\n and other == 2") require.NoError(t, err) @@ -151,135 +63,3 @@ func TestRewritePreservesUnchangedSource(t *testing.T) { require.NoError(t, err) require.Equal(t, `field == 3 and other == 2`, roundTrip.String()) } - -func TestEvalTraceShortCircuit(t *testing.T) { - rule := MustParse(`a == 1 or b == 2`) - input := FromKV(KV{"a": int64(1)}) - - withoutTrace := rule.Eval(nil, input, Opts{}) - require.Nil(t, withoutTrace.Trace) - - result := rule.Eval(nil, input, Opts{Trace: true}) - require.NoError(t, result.Error) - require.True(t, result.Pass()) - require.NotNil(t, result.Trace) - require.Equal(t, ASTBinary, result.Trace.Node.Kind()) - require.Equal(t, `a == 1 or b == 2`, result.Trace.Expr) - require.Len(t, result.Trace.Children, 2) - require.Equal(t, TracePassed, result.Trace.Status) - require.Equal(t, TracePassed, result.Trace.Children[0].Status) - require.Equal(t, TracePruned, result.Trace.Children[1].Status) - require.True(t, result.Trace.Children[0].Active) - require.False(t, result.Trace.Children[0].Pruned) - require.Equal(t, true, result.Trace.Children[0].Value) - require.False(t, result.Trace.Children[1].Active) - require.True(t, result.Trace.Children[1].Pruned) - require.Equal(t, `b == 2`, result.Trace.Children[1].Expr) -} - -func TestEvalTraceMissingFields(t *testing.T) { - rule := MustParse(`a == 1 or b == 2`) - - result := rule.Eval(nil, FromKV(KV{}), Opts{Trace: true}) - require.NoError(t, result.Error) - require.True(t, result.Unknown()) - require.ElementsMatch(t, []string{"a", "b"}, result.MissingFields) - require.NotNil(t, result.Trace) - require.Equal(t, TraceMissing, result.Trace.Status) - require.ElementsMatch(t, []string{"a", "b"}, result.Trace.MissingFields) - require.Len(t, result.Trace.Children, 2) - require.Equal(t, TraceMissing, result.Trace.Children[0].Status) - require.Equal(t, TraceMissing, result.Trace.Children[1].Status) -} - -func TestEvalTraceComparisonDiagnostics(t *testing.T) { - tcs := []struct { - name string - rule string - input KV - code DiagnosticCode - leftType string - operator string - rightType string - wantStatus TraceStatus - }{ - { - name: "incomparable types", - rule: `port == "443"`, - input: KV{"port": int64(443)}, - code: DiagnosticComparisonIncomparable, - leftType: "int64", - operator: "==", - rightType: "string", - wantStatus: TraceFailed, - }, - { - name: "unsupported operator", - rule: `port contains 443`, - input: KV{"port": int64(443)}, - code: DiagnosticComparisonUnsupportedOperator, - leftType: "int64", - operator: "contains", - rightType: "int64", - wantStatus: TraceFailed, - }, - { - name: "invalid shape", - rule: `port contains [443]`, - input: KV{"port": int64(443)}, - code: DiagnosticComparisonInvalidShape, - leftType: "int64", - operator: "contains", - rightType: "[]interface {}", - wantStatus: TraceFailed, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - rule := MustParse(tc.rule) - - withoutTrace := rule.Eval(nil, FromKV(tc.input), Opts{}) - require.NoError(t, withoutTrace.Error) - require.True(t, withoutTrace.Fail()) - require.Nil(t, withoutTrace.Trace) - - result := rule.Eval(nil, FromKV(tc.input), Opts{Trace: true}) - require.NoError(t, result.Error) - require.True(t, result.Fail()) - require.NotNil(t, result.Trace) - require.Equal(t, tc.wantStatus, result.Trace.Status) - require.Len(t, result.Trace.Diagnostics, 1) - - diagnostic := result.Trace.Diagnostics[0] - require.Equal(t, tc.code, diagnostic.Code) - require.Equal(t, tc.leftType, diagnostic.LeftType) - require.Equal(t, tc.operator, diagnostic.Operator) - require.Equal(t, tc.rightType, diagnostic.RightType) - require.NotEmpty(t, diagnostic.Message) - }) - } -} - -func TestEvalTraceMacroExpansion(t *testing.T) { - rule := MustParse(`is_internal() and user != "root"`) - macros := MacroSet{} - require.NoError(t, macros.Register("is_internal", `ip in 172.16.0.0/16 or host matches /svc\.cluster\.local$/`)) - - result := rule.Eval(nil, FromKV(KV{ - "ip": net.ParseIP("172.16.0.1"), - "user": "api", - }), Opts{Trace: true, Macros: macros}) - require.NoError(t, result.Error) - require.True(t, result.Pass()) - require.NotNil(t, result.Trace) - require.Equal(t, TracePassed, result.Trace.Status) - require.Len(t, result.Trace.Children, 2) - - macroCall := result.Trace.Children[0] - require.Equal(t, `is_internal()`, macroCall.Expr) - require.Equal(t, TracePassed, macroCall.Status) - require.Len(t, macroCall.Children, 1) - require.Equal(t, `ip in 172.16.0.0/16 or host =~ /svc\.cluster\.local$/`, macroCall.Children[0].Expr) - require.Equal(t, TracePassed, macroCall.Children[0].Status) -} diff --git a/compare.go b/compare.go index 640ea4f..6c95c54 100644 --- a/compare.go +++ b/compare.go @@ -3,6 +3,8 @@ package rulekit import ( "fmt" "net" + "net/url" + "regexp" ) type compareDiagnostic uint8 @@ -19,10 +21,6 @@ type compareOutcome struct { diagnostic compareDiagnostic } -func compare(left any, op int, right any) (ret bool) { - return compareDetailed(left, op, right).pass -} - func compareDetailed(left any, op int, right any) compareOutcome { // any ? []any // -> run the comparison for each element in the right array. @@ -36,6 +34,14 @@ func compareDetailed(left any, op int, right any) compareOutcome { return compareDetailed(left, op, rv) }) } + if rightStrs, ok := right.([]string); ok { + if op == op_CONTAINS { + return invalidShape() + } + return compareSliceDetailed(rightStrs, op, func(rv string, op int) compareOutcome { + return compareDetailed(left, op, rv) + }) + } // the left value type determines the comparison logic switch lv := left.(type) { @@ -43,6 +49,10 @@ func compareDetailed(left any, op int, right any) compareOutcome { // string ? any return compareString(lv, op, right) + case urlQuery: + // query ? any + return compareString(string(lv), op, right) + case []string: // []string ? any return compareStringSlice(lv, op, right) @@ -112,6 +122,22 @@ func compareDetailed(left any, op int, right any) compareOutcome { // mac ? any return compareMac(lv, op, right) + case *url.URL: + // url ? any + return compareURL(urlText(lv), op, right) + + case URL: + // url ? any + return compareURL(lv.text, op, right) + + case []byte: + // bytes ? any + return compareBytes(lv, op, right) + + case HexString: + // hex ? any + return compareBytes(lv.Bytes, op, right) + case []any: // []any ? any return compareSliceDetailed(lv, op, func(lv any, op int) compareOutcome { @@ -169,6 +195,7 @@ func newComparisonDiagnostic(code compareDiagnostic, left any, op int, right any leftType := diagnosticType(left) rightType := diagnosticType(right) operator := operatorToString(op) + name := publicOperator(astOperatorFromToken(op)).String() switch code { case compareDiagnosticIncomparable: @@ -176,7 +203,7 @@ func newComparisonDiagnostic(code compareDiagnostic, left any, op int, right any Code: DiagnosticComparisonIncomparable, Message: fmt.Sprintf("cannot compare %s %s %s", leftType, operator, rightType), LeftType: leftType, - Operator: operator, + Operator: name, RightType: rightType, } case compareDiagnosticInvalidShape: @@ -184,7 +211,7 @@ func newComparisonDiagnostic(code compareDiagnostic, left any, op int, right any Code: DiagnosticComparisonInvalidShape, Message: fmt.Sprintf("invalid comparison shape for %s %s %s", leftType, operator, rightType), LeftType: leftType, - Operator: operator, + Operator: name, RightType: rightType, } case compareDiagnosticUnsupportedOperator: @@ -192,7 +219,7 @@ func newComparisonDiagnostic(code compareDiagnostic, left any, op int, right any Code: DiagnosticComparisonUnsupportedOperator, Message: fmt.Sprintf("operator %s is not supported for %s and %s", operator, leftType, rightType), LeftType: leftType, - Operator: operator, + Operator: name, RightType: rightType, } default: @@ -200,16 +227,42 @@ func newComparisonDiagnostic(code compareDiagnostic, left any, op int, right any } } +// diagnosticType names a value's type using the same vocabulary as typed +// JSON input ($type), so diagnostics read the same in every implementation. func diagnosticType(value any) string { - if value == nil { - return "" + switch value.(type) { + case nil: + return "null" + case bool: + return "bool" + case int, int64: + return "int64" + case uint, uint64: + return "uint64" + case float32, float64: + return "float64" + case string: + return "string" + case HexString, []byte: + return "bytes" + case net.IP: + return "ip" + case *net.IPNet: + return "cidr" + case net.HardwareAddr: + return "mac" + case *url.URL, URL: + return "url" + case *regexp.Regexp: + return "regex" + case []any, []string, []int, []int64, []uint, []uint64, []float32, []float64, []net.IP: + return "array" + case urlQuery: + return "string" + case map[string]any: + return "object" } - return fmt.Sprintf("%T", value) -} -func compareSlice[T any](slice []T, op int, fn func(el T, op int) bool) bool { - return compareSliceDetailed(slice, op, func(el T, op int) compareOutcome { - return comparePass(fn(el, op)) - }).pass + return "unknown" } func compareSliceDetailed[T any](slice []T, op int, fn func(el T, op int) compareOutcome) compareOutcome { diff --git a/compare_bytes.go b/compare_bytes.go index 27432b0..8d5f8a8 100644 --- a/compare_bytes.go +++ b/compare_bytes.go @@ -1,6 +1,31 @@ package rulekit -import "bytes" +import ( + "bytes" + "net" +) + +// compareBytes compares a byte sequence (bytes input or a hex literal) by +// value against bytes, hex, strings, and MAC addresses. +func compareBytes(left []byte, op int, right any) compareOutcome { + switch right := right.(type) { + case []byte: + return compareBytesBytes(left, op, right) + case HexString: + return compareBytesBytes(left, op, right.Bytes) + case net.HardwareAddr: + return compareBytesBytes(left, op, right) + case string: + switch op { + case op_EQ: + return comparePass(string(left) == right) + case op_NE: + return comparePass(string(left) != right) + } + return compareBytesBytes(left, op, []byte(right)) + } + return incomparable() +} func compareBytesBytes(left []byte, op int, right []byte) compareOutcome { switch op { diff --git a/compare_ip.go b/compare_ip.go index 867380a..be2c76f 100644 --- a/compare_ip.go +++ b/compare_ip.go @@ -1,6 +1,9 @@ package rulekit -import "net" +import ( + "net" + "regexp" +) func compareIP(left net.IP, op int, right any) compareOutcome { switch right := right.(type) { @@ -22,6 +25,9 @@ func compareIP(left net.IP, op int, right any) compareOutcome { return comparePass(!right.Contains(left)) } return unsupportedOperator() + case string, *regexp.Regexp, urlQuery: + // ip ? string: compare the IP's text form + return compareString(ipText(left), op, right) } return incomparable() } @@ -37,6 +43,9 @@ func compareIPNet(left *net.IPNet, op int, right any) compareOutcome { return comparePass(!left.Contains(right)) } return unsupportedOperator() + case string, *regexp.Regexp, urlQuery: + // ipnet ? string: compare the CIDR's text form + return compareString(cidrText(left), op, right) } return incomparable() } diff --git a/compare_mac.go b/compare_mac.go index 412116d..0e957bd 100644 --- a/compare_mac.go +++ b/compare_mac.go @@ -2,7 +2,7 @@ package rulekit import ( "net" - "strings" + "regexp" ) func compareMac(left net.HardwareAddr, op int, right any) compareOutcome { @@ -12,11 +12,13 @@ func compareMac(left net.HardwareAddr, op int, right any) compareOutcome { return compareBytesBytes(left, op, right) case HexString: // mac ? hex - // in this case, treat the hex string as a literal - return compareStringString(strings.ToLower(left.String()), op, strings.ToLower(right.String())) - case string: - // mac ? string - return compareStringString(strings.ToLower(left.String()), op, strings.ToLower(right)) + return compareBytesBytes(left, op, right.Bytes) + case []byte: + // mac ? bytes + return compareBytesBytes(left, op, right) + case string, *regexp.Regexp, urlQuery: + // mac ? string: compare the MAC's text form + return compareString(macText(left), op, right) } return incomparable() } diff --git a/compare_string.go b/compare_string.go index 1842381..4bd58d5 100644 --- a/compare_string.go +++ b/compare_string.go @@ -2,6 +2,7 @@ package rulekit import ( "net" + "net/url" "regexp" "strings" ) @@ -11,18 +12,33 @@ func compareString(left string, op int, right any) compareOutcome { case string: // string ? string return compareStringString(left, op, right) + case urlQuery: + // string ? query + return compareStringString(left, op, string(right)) case *regexp.Regexp: // string ? regexp return compareStringRegex(left, op, right) case net.IP: // string ? ip - return compareStringString(left, op, right.String()) + return compareStringString(left, op, ipText(right)) case *net.IPNet: // string ? ipnet - return compareStringString(left, op, right.String()) + return compareStringString(left, op, cidrText(right)) + case *url.URL: + // string ? url + return compareStringString(left, op, urlText(right)) + case URL: + // string ? url + return compareStringString(left, op, right.text) case HexString: // string ? hex return compareBytesBytes([]byte(left), op, right.Bytes) + case []byte: + // string ? bytes + return compareBytesBytes([]byte(left), op, right) + case net.HardwareAddr: + // string ? mac + return compareStringString(left, op, macText(right)) } return incomparable() } @@ -59,17 +75,8 @@ func compareStringSlice(left []string, op int, right any) compareOutcome { op = op_EQ } - switch right := right.(type) { - case string: - // []string{...} ? string - return compareSliceDetailed(left, op, func(fv string, op int) compareOutcome { - return compareString(fv, op, right) - }) - case *regexp.Regexp: - // []string{...} ? regexp - return compareSliceDetailed(left, op, func(fv string, op int) compareOutcome { - return compareStringRegex(fv, op, right) - }) - } - return incomparable() + // []string{...} ? any: compare each element as a string + return compareSliceDetailed(left, op, func(fv string, op int) compareOutcome { + return compareString(fv, op, right) + }) } diff --git a/compare_url.go b/compare_url.go new file mode 100644 index 0000000..22e4e0f --- /dev/null +++ b/compare_url.go @@ -0,0 +1,20 @@ +package rulekit + +import ( + "net/url" + "regexp" +) + +// compareURL compares a URL by its text form: with another URL, or with a +// string, regex, or query. +func compareURL(left string, op int, right any) compareOutcome { + switch right := right.(type) { + case *url.URL: + return compareStringString(left, op, urlText(right)) + case URL: + return compareStringString(left, op, right.text) + case string, *regexp.Regexp, urlQuery: + return compareString(left, op, right) + } + return incomparable() +} diff --git a/demo/go.mod b/demo/go.mod new file mode 100644 index 0000000..eaa3e76 --- /dev/null +++ b/demo/go.mod @@ -0,0 +1,12 @@ +module github.com/qpoint-io/rulekit/v2/demo + +go 1.27.1 + +require github.com/qpoint-io/rulekit/v2 v2.0.0-00010101000000-000000000000 + +require ( + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect +) + +replace github.com/qpoint-io/rulekit/v2 => ../ diff --git a/demo/go.sum b/demo/go.sum new file mode 100644 index 0000000..d34fb45 --- /dev/null +++ b/demo/go.sum @@ -0,0 +1,13 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/demo/src/App.vue b/demo/src/App.vue index 6263f67..b63baac 100644 --- a/demo/src/App.vue +++ b/demo/src/App.vue @@ -121,7 +121,7 @@ watch(selectedNode, (node) => { draft.value = '' return } - if (node.kind === 'binary') draft.value = node.operator || '' + if (node.kind === 'binary') draft.value = node.operator ? `${node.negated ? 'not_' : ''}${node.operator}` : '' else if (node.kind === 'path') draft.value = node.path || node.text else if (node.kind === 'literal') draft.value = node.raw || node.text else draft.value = node.text @@ -396,7 +396,7 @@ function valueText(value: unknown) {
{{ selectedNode?.text || 'click a node to edit or delete safe nodes' }}