Skip to content

[00123] Implement the Rusty Filter Query Grammar Parser and AST Crate - #113

Merged
rorychatt merged 6 commits into
mainfrom
tendril/00123-ImplementTheRustyFilterQueryGrammarParserAndASTCrate
Aug 2, 2026
Merged

[00123] Implement the Rusty Filter Query Grammar Parser and AST Crate#113
rorychatt merged 6 commits into
mainfrom
tendril/00123-ImplementTheRustyFilterQueryGrammarParserAndASTCrate

Conversation

@rorychatt

Copy link
Copy Markdown
Contributor

rusty-filter, a new workspace member: a hand-written lexer and
recursive-descent parser for the Filters.g4 filter-query grammar, its AST, a
type-aware validator, an evaluator, and a canonical printer. Wired into
DataTable, the query cache and the prelude, and documented.

5972 insertions across 20 files, 6 commits, 208 new tests. Every CI gate exit 0.

Commits

SHA What
786324d the crate: lexer, parser, AST, validator, evaluator, printer
7149f0e differential and round-trip tests
fda2fdb wiring into DataTable, the query cache and the prelude; docs
93a4611 merge main — #[derive(Widget)] migration, rusty-desktop
a332547 merge main — rusty-ivyml, plus main's repairs to two inherited gates
64f4189 merge main — the last two inherited failures fixed upstream

What was built

rusty-filter (~5200 lines including tests)

Module Lines Role
parser.rs 1617 recursive descent with ANTLR-compatible error recovery
eval.rs 846 row evaluation for all five column types
lexer.rs 792 tokenizer: bracketed column refs, string/number/date/bool literals, symbolic and word operators, the multi-word spellings
validate.rs 603 operator/type compatibility, unknown columns, literal parsing
print.rs 526 canonical query strings and cache keys
ast.rs 304 FilterGroup/Filter/Condition with wire-compatible serde
column.rs 166 ColumnDef, ColumnType, name normalization
lib.rs 92 the public surface

The AST serializes to the same JSON the browser's editor produces, down to key
presence: skip_serializing_if = "Option::is_none" keeps negate absent on
comparisons and false on text ops, because that is what the reference does.
JavaScript's single number type is reproduced too (0077, 1.0001).

Wiring

  • DataTable::apply_filter(query)Result<Self, Vec<ParseError>>, and
    filter_columns(), which excludes non-filterable and hidden columns.
  • From<ColType> for ColumnType — eight cell types onto the validator's five,
    matching the normalization the browser applies to backend type names.
  • QueryCache::filtered_key(base, filter) — appends the canonical filter so
    equivalent spellings share one cache entry and different filters do not.
  • The prelude re-exports 19 items plus the crate itself.

Docs

02_concepts/07_filters.md (new, 179 lines): the grammar, an operator-spellings
table, per-type compatibility, literals, precedence, evaluation, cache keys,
frontend compatibility, and a "Not implemented" list. Plus a "Server-side
filtering" section on the DataTable page and a "Filters" section in the prelude
reference. Both code samples were compiled and run before committing, via
throwaway examples that were then deleted.

Verification — all Pass

Name Result
RustBuild Pass — exit 0, seven members
RustTest Pass — 734 passed, 0 failed
RustClippy Pass — exit 0
RustFmt Pass — exit 0
CheckResult Pass — eleven CI steps, all exit 0
RustyFrontendLint / Build / Test Skipped — no file under src/frontend/ changed

Including the steps outside the four named gates: test inventory ("intact: 533 at
1d58a57, 741 in the working tree"), both shell harnesses ("ALL 13 CASES PASS",
"ALL 6 CASES PASS"), the e2e job's harness-parse and widget_harness build, and
the desktop-shell job's two steps. Details in Verification/.

What the plan got wrong

Two of the plan's stated expectations were falsified by probing the reference
bundle (filter-query-editor 2.2.0, imported from src/frontend/node_modules by
absolute file:/// URL). The tests assert measured behaviour instead,
documented at each assertion:

  1. §4: an empty Or group evaluates false. It returns true. The crate
    matches the bundle.
  2. §7: a print/parse round trip preserves the AST for every valid query. It
    holds for 39 of 47. The other 8 are negated leaves — printing lifts the
    negation onto a NOT (...) group, so re-parsing attaches it to a wrapper. The
    bundle was probed at each of the 8 and reshapes identically.
    round_trip.rs pins them in NOT_AST_STABLE and fails if a ninth joins them
    or a listed one starts passing. Evaluation equivalence over a 7-row corpus
    is asserted separately for all 47, so a reshaped round trip is proved to still
    mean the same thing.

Three grammar quirks the plan did not mention were measured rather than guessed:
bare equal is rejected although not equal is accepted; the or equal tail is
singular, so greater than or equals fails; and an unknown string escape keeps
its backslash. The last took two attempts — a bash heredoc silently collapsed the
backslashes in the first probe, which lied, and the wrong answer had already been
drafted into a doc page before the corrected probe caught it.

Merging against a moving target

origin/main moved three times during execution — df4e685e6c4398
9b7c7641d58a57, bringing a #[derive(Widget)] migration and two entirely
new crates (rusty-desktop, rusty-ivyml). Each move was merged and every gate
re-run.

It also repaired every failure this plan had documented as inherited: two
widget_names test failures (9fb4213), two rusty-macros clippy errors
(5d81694), a rusty-macros rustfmt failure (67e4cd8), the types.rs:77
clippy error, and the pre-commit harness's three failures. Each had been proved
inherited at the time against a pristine origin/main probe extracted with git archive under its own CARGO_TARGET_DIR. Deferring them rather than repairing
them was the right call — one of the fixes was 253 lines of parser work this plan
would have duplicated.

Left for others

Artifacts/recommendations.md has three items, all follow-on work rather than
defects: wiring the rendered filter box to the parser (High), apply_filter's
consuming signature (Small), and an explicit decision on the negated-leaf
round-trip reshape (Small).


Commits

786324d [00123] Add rusty-filter: lexer, parser, AST, validator, evaluator, printer
7149f0e [00123] Add differential and round-trip tests for rusty-filter
fda2fdb [00123] Wire rusty-filter into DataTable and query cache; add docs
93a4611 [00123] Merge main (df4e685)
a332547 [00123] Merge main (9b7c764)
64f4189 [00123] Merge main (1d58a57)


Created using Ivy Tendril.

rorychatt and others added 6 commits August 2, 2026 12:06
…aluator and printer

A dependency-light Rust port of the filter query grammar the frontend's
filter-query-editor 2.2.0 bundle implements, so a query can be authored on
either side and mean the same thing. Only serde and serde_json; no ANTLR
runtime, no build script.

Every behaviour was cross-checked against the shipped bundle by replaying a
corpus through both implementations: 42 valid queries matched on both AST JSON
and formatQuery output, 46 error cases matched on first message and byte span,
and 29 filters over 6 rows matched on evaluateFilter and countMatches.

Divergences, all documented in the module docs: error spans are byte offsets
rather than UTF-16 code units, and a syntax error stops the parse instead of
recovering into an ANTLR error cascade.
Two integration suites, both measured against the shipped
filter-query-editor 2.2.0 bundle rather than written from the grammar.

frontend_ast_compat.rs pins 23 expectations that are the verbatim JSON
the bundle emits, so serde key *presence* is significant: a comparison
omits `negate`, a text operation emits `negate: false`, and existence
operations carry empty args. The module doc records the capture recipe
(import dist/index.js by absolute file:// URL, call parseQuery).

round_trip.rs asserts what a cache key needs: 39 of the 47 corpus
queries are exactly AST-stable across print/parse, printing is a fixed
point from the second pass on, equivalent spellings collapse onto one
key, and 14 differently-meaning queries produce 14 distinct keys.

The 8 remaining queries are negated leaves. Printing lifts the negation
onto a `NOT (...)` group, so re-parsing moves `negate` from the
condition filter to a wrapping group filter. The reference bundle was
probed at each of them and reshapes identically, so the plan's claim of
universal AST stability does not hold for either implementation. Rather
than drop the assertion, NOT_AST_STABLE lists the 8 exactly and the
test fails if a ninth query joins them or a listed one starts passing;
semantic equivalence over 7 rows is asserted for all 47.

Cross-check totals, all with a mutation probe confirming the comparator
discriminates: 42 valid queries with 0 AST or formatQuery mismatches,
46 error cases with 0 message or span mismatches, 29 filters x 6 rows
with 0 evaluateFilter or countMatches mismatches.

195 tests pass; cargo fmt and clippy --all-targets -D warnings clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lude

Additive only: nothing existing changes behaviour, and `to_json`,
`register_events` and every field are untouched.

`rusty/Cargo.toml` gains the path dependency. `data_table.rs` gains
`From<ColType> for ColumnType` collapsing eight cell types onto the
grammar's five, `filter_columns()` offering only `filterable && !hidden`
columns, and `apply_filter(query)` returning a table of the matching
rows or the parse and validation errors. Excluding non-filterable
columns is what `DataTableFilterOption.tsx` does before handing columns
to the browser's editor, so naming one gets the same `Column 'x' does
not exist` error on both sides instead of working on one.

`query_cache.rs` gains one associated function, `filtered_key`, keying
on the canonical spelling so `[age] > 1` and `[age] greater than 1`
share a cache entry. Associated rather than a method because it reads no
cache state, which keeps it clear of the module's rule that the cache
lock is never held across an `.await`. The SWR state machine is
untouched.

The prelude re-exports the flat list plus the crate itself.

Docs: a new 02_concepts/07_filters.md covering operators, type
compatibility, literals, precedence, evaluation and canonical keys; a
"Server-side filtering" section on the DataTable page; and a Filters
table in the prelude reference. Three grammar quirks the plan did not
mention were measured and documented rather than guessed: bare `equal`
is rejected though `not equal` is accepted, the `or equal` tail is
singular so `greater than or equals` fails, and an unknown string escape
keeps its backslash (`\t` is two characters, not a tab) — that last one
re-probed against the bundle, which agrees. Both doc code samples were
compiled and run, not just written.

The widget's "DataTableConnection is not ported" comment now names what
apply_filter does while saying plainly that the rendered filter box
still posts to a gRPC DataTableService that does not exist, so nothing
here implies typing in the browser reaches Rust.

Gate: build, test (663 passed, 0 failed), clippy --workspace
--all-targets -D warnings and fmt --check all exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two conflicts, both mechanical. `Cargo.toml` gained `rusty-desktop` on
main while this branch added `rusty-filter`; the member list keeps both.
`data_table.rs` was migrated to `#[derive(Widget)]` on main, deleting the
hand-written `impl WidgetData`; the derive is kept and this plan's
`filter_columns`, `apply_filter` and `From<ColType> for ColumnType` sit
alongside it untouched, since none of them went through `to_json`.

Main arrives red, independently of this plan: `cargo test --workspace` on
a clean `git archive` of origin/main e6c4398 fails
`shared::widget_names::every_widget_type_is_mapped` and
`widget_type_scan_finds_known_widgets` (410 passed, 2 failed). The scan
those tests use reads `"type": "..."` literals out of hand-written
`to_json` bodies, and the derive migration deleted them, so it now finds
26 widget types where it demands 38. The merged tree fails exactly those
two and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main added the rusty-ivyml crate (#110) and repaired the widget_names scan and
the rusty-macros clippy/rustfmt failures this branch had recorded as inherited.
All three conflicts are additive: the workspace members list and rusty's
dependency list each gain both crates, and Cargo.lock is regenerated rather than
hand-merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23 commits, no conflicts. main's #112 and #111 removed the two failures this
branch had documented as inherited: the empty-line-after-doc-comment clippy
error at rusty/src/shared/types.rs:77 (the doc comment is gone), and the three
pre-commit harness failures (.husky/pre-commit now guards and propagates both
helper calls).

Every reachable CI gate is exit 0 at this commit: build, test (734 passed, 0
failed), clippy, fmt, test inventory, both shell harnesses, the e2e job's
harness parse and widget_harness build, and the desktop-shell job's two steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rorychatt rorychatt self-assigned this Aug 2, 2026
@rorychatt
rorychatt merged commit c528820 into main Aug 2, 2026
@rorychatt
rorychatt deleted the tendril/00123-ImplementTheRustyFilterQueryGrammarParserAndASTCrate branch August 2, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant