Skip to content

Commit c4d4584

Browse files
committed
celfmt: fold filter into consuming comprehension during simplify
Add a peephole optimisation that rewrites filter-then-comprehension chains into a single comprehension with a filter argument: x.filter(v, cond).map(v, t) → x.map(v, cond, t) x.filter(v, cond).transformList(_, v, t) → x.transformList(_, v, cond, t) x.filter(v, cond).transformMap(_, v, t) → x.transformMap(_, v, cond, t) x.filter(v, cond).transformMapEntry(_, v, t) → x.transformMapEntry(_, v, cond, t) For two-variable comprehensions the merge is restricted to cases where the first iteration variable (index/key) is "_", since the filter alters element indices and merging would change semantics if the index is used.
1 parent 439ed68 commit c4d4584

3 files changed

Lines changed: 187 additions & 1 deletion

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
celfmt -s -i src.cel
2+
! stderr .
3+
cmp stdout want.txt
4+
5+
-- src.cel --
6+
state.items.filter(e, e > 0).map(f, f * 2)
7+
-- want.txt --
8+
state.items.map(f, f > 0, f * 2)

simplify.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ import (
3030
// - inline single-use .as() bindings
3131
// - eliminate boolean comparisons (x == true → x, x == false → !x)
3232
// - rewrite has(x.f) ? x.f : d and !has(x.f) ? d : x.f → x.?f.orValue(d)
33+
// - fold filter into comprehension: x.filter(v,c).map(v,t) → x.map(v,c,t)
3334
func Simplify(a *ast.AST, src common.Source) {
3435
inlineAs(a)
3536
elimBoolCmp(a)
3637
elimHasTernary(a, src)
38+
foldFilter(a)
3739
}
3840

3941
// inlineAs finds .as() macro calls where the bound variable is used at most
@@ -116,6 +118,25 @@ func countIdent(expr ast.Expr, ident string) int {
116118
return n
117119
}
118120

121+
// countIdentInMacroTree counts IdentKind nodes named ident, recurring into
122+
// nested macro call expressions reachable via the SourceInfo macro call map.
123+
func countIdentInMacroTree(info *ast.SourceInfo, expr ast.Expr, ident string) int {
124+
var n int
125+
var walk func(ast.Expr)
126+
walk = func(e ast.Expr) {
127+
ast.PreOrderVisit(e, ast.NewExprVisitor(func(node ast.Expr) {
128+
if node.Kind() == ast.IdentKind && node.AsIdent() == ident {
129+
n++
130+
}
131+
if mcall, ok := info.GetMacroCall(node.ID()); ok {
132+
walk(mcall)
133+
}
134+
}))
135+
}
136+
walk(expr)
137+
return n
138+
}
139+
119140
// substituteIdent replaces all IdentKind nodes named ident with replacement.
120141
// If info is non-nil and replacement has a macro call, the macro call is
121142
// copied to each substitution site so the formatter can find it by ID.
@@ -132,6 +153,23 @@ func substituteIdent(expr ast.Expr, ident string, replacement ast.Expr, info *as
132153
}))
133154
}
134155

156+
// substituteIdentInMacroTree replaces IdentKind nodes named ident with
157+
// replacement, recurring into nested macro call expressions.
158+
func substituteIdentInMacroTree(info *ast.SourceInfo, expr ast.Expr, ident string, replacement ast.Expr) {
159+
var walk func(ast.Expr)
160+
walk = func(e ast.Expr) {
161+
ast.PreOrderVisit(e, ast.NewExprVisitor(func(node ast.Expr) {
162+
if node.Kind() == ast.IdentKind && node.AsIdent() == ident {
163+
node.SetKindCase(replacement)
164+
}
165+
if mcall, ok := info.GetMacroCall(node.ID()); ok {
166+
walk(mcall)
167+
}
168+
}))
169+
}
170+
walk(expr)
171+
}
172+
135173
// elimBoolCmp rewrites x == true → x and x == false → !x.
136174
func elimBoolCmp(a *ast.AST) {
137175
fac := ast.NewExprFactory()
@@ -293,6 +331,134 @@ func isNegatedHasTest(e ast.Expr) bool {
293331
return len(args) == 1 && isHasTest(args[0])
294332
}
295333

334+
// foldFilter rewrites filter-then-comprehension chains into a single
335+
// comprehension with a filter argument:
336+
//
337+
// x.filter(v, cond).map(v, t) → x.map(v, cond, t)
338+
// x.filter(v, cond).transformList(_, v, t) → x.transformList(_, v, cond, t)
339+
// x.filter(v, cond).transformMap(_, v, t) → x.transformMap(_, v, cond, t)
340+
// x.filter(v, cond).transformMapEntry(_, v, t) → x.transformMapEntry(_, v, cond, t)
341+
//
342+
// For two-variable comprehensions the merge is only applied when the first
343+
// iteration variable (index/key) is "_", since the filter changes element
344+
// indices and merging would alter semantics if the index is used.
345+
func foldFilter(a *ast.AST) {
346+
info := a.SourceInfo()
347+
fac := ast.NewExprFactory()
348+
for {
349+
folded := false
350+
for id, call := range info.MacroCalls() {
351+
if call.Kind() != ast.CallKind {
352+
continue
353+
}
354+
c := call.AsCall()
355+
if !c.IsMemberFunction() {
356+
continue
357+
}
358+
fn := c.FunctionName()
359+
args := c.Args()
360+
361+
// Identify comprehensions that accept an optional filter arg
362+
// and currently lack one (i.e. they have the minimum arg count).
363+
var valIdx int // index of the value iteration variable in args
364+
switch fn {
365+
case "map":
366+
if len(args) != 2 {
367+
continue
368+
}
369+
valIdx = 0
370+
case "transformList", "transformMap", "transformMapEntry":
371+
if len(args) != 3 {
372+
continue
373+
}
374+
valIdx = 1
375+
default:
376+
continue
377+
}
378+
379+
// The target of the macro call should reference the filter
380+
// comprehension by ID.
381+
target := c.Target()
382+
filterCall, ok := info.GetMacroCall(target.ID())
383+
if !ok || filterCall.Kind() != ast.CallKind {
384+
continue
385+
}
386+
fc := filterCall.AsCall()
387+
if fc.FunctionName() != "filter" || !fc.IsMemberFunction() {
388+
continue
389+
}
390+
filterArgs := fc.Args()
391+
if len(filterArgs) != 2 {
392+
continue
393+
}
394+
filterVar := filterArgs[0]
395+
filterCond := filterArgs[1]
396+
397+
// The filter's iteration variable must match the consuming
398+
// comprehension's value variable, or be safely renameable.
399+
iterVar := args[valIdx]
400+
if filterVar.Kind() != ast.IdentKind || iterVar.Kind() != ast.IdentKind {
401+
continue
402+
}
403+
needsRename := filterVar.AsIdent() != iterVar.AsIdent()
404+
if needsRename && countIdentInMacroTree(info, filterCond, iterVar.AsIdent()) > 0 {
405+
continue // target name already in condition — rename would capture
406+
}
407+
408+
// For two-variable comprehensions the first variable (key/index)
409+
// must be unused; otherwise the merge changes semantics because
410+
// the filter alters element indices.
411+
if valIdx == 1 {
412+
first := args[0]
413+
if first.Kind() != ast.IdentKind || first.AsIdent() != "_" {
414+
continue
415+
}
416+
}
417+
418+
// Build the merged macro call: target.fn(args..., cond, transform).
419+
filterTarget := fc.Target()
420+
if needsRename {
421+
substituteIdentInMacroTree(info, filterCond, filterVar.AsIdent(), fac.NewIdent(0, iterVar.AsIdent()))
422+
}
423+
var newArgs []ast.Expr
424+
switch fn {
425+
case "map":
426+
newArgs = []ast.Expr{iterVar, filterCond, args[1]}
427+
default:
428+
newArgs = []ast.Expr{args[0], iterVar, filterCond, args[2]}
429+
}
430+
newMCall := fac.NewMemberCall(0, fn, filterTarget, newArgs...)
431+
info.SetMacroCall(id, newMCall)
432+
info.ClearMacroCall(target.ID())
433+
434+
// Update the AST: skip the filter comprehension so the
435+
// consuming comprehension iterates directly over the
436+
// filter's source.
437+
ast.PreOrderVisit(a.Expr(), ast.NewExprVisitor(func(e ast.Expr) {
438+
if e.ID() != id || e.Kind() != ast.ComprehensionKind {
439+
return
440+
}
441+
iterRange := e.AsComprehension().IterRange()
442+
if iterRange.Kind() != ast.ComprehensionKind {
443+
return
444+
}
445+
filterSource := iterRange.AsComprehension().IterRange()
446+
if mcall, ok := info.GetMacroCall(filterSource.ID()); ok {
447+
info.SetMacroCall(iterRange.ID(), mcall)
448+
info.ClearMacroCall(filterSource.ID())
449+
}
450+
iterRange.SetKindCase(filterSource)
451+
}))
452+
453+
folded = true
454+
break
455+
}
456+
if !folded {
457+
return
458+
}
459+
}
460+
}
461+
296462
// exprEqual reports whether two expression trees are structurally identical,
297463
// ignoring node IDs. Returns false for any kind it cannot compare.
298464
func exprEqual(a, b ast.Expr) bool {

simplify_test.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/google/cel-go/common"
2727
"github.com/google/cel-go/common/decls"
2828
"github.com/google/cel-go/common/types"
29+
"github.com/google/cel-go/ext"
2930
)
3031

3132
func TestSimplify(t *testing.T) {
@@ -66,6 +67,16 @@ func TestSimplify(t *testing.T) {
6667
want: "has(x.f) ?\n\t// keep this\n\tx.f\n:\n\t0",
6768
opts: []FormatOption{Pretty()},
6869
},
70+
71+
// fold filter into comprehension
72+
{name: "filter_map", in: `x.filter(v, v > 0).map(v, v * 2)`, want: `x.map(v, v > 0, v * 2)`},
73+
{name: "filter_map_rename", in: `x.filter(e, e > 0).map(f, f * 2)`, want: `x.map(f, f > 0, f * 2)`},
74+
{name: "filter_map_rename_capture", in: `x.filter(e, y.exists(f, f == e)).map(f, f * 2)`, want: `x.filter(e, y.exists(f, f == e)).map(f, f * 2)`},
75+
{name: "filter_map_already_filtered", in: `x.map(v, v > 0, v * 2)`, want: `x.map(v, v > 0, v * 2)`},
76+
{name: "filter_transformList", in: `x.filter(v, v > 0).transformList(_, v, v * 2)`, want: `x.transformList(_, v, v > 0, v * 2)`},
77+
{name: "filter_transformMap", in: `x.filter(v, v > 0).transformMap(_, v, v * 2)`, want: `x.transformMap(_, v, v > 0, v * 2)`},
78+
{name: "filter_transformMapEntry", in: `x.filter(v, v > 0).transformMapEntry(_, v, {v: v})`, want: `x.transformMapEntry(_, v, v > 0, {v: v})`},
79+
{name: "filter_transformList_used_idx", in: `x.filter(v, v > 0).transformList(i, v, i)`, want: `x.filter(v, v > 0).transformList(i, v, i)`},
6980
}
7081

7182
env := newTestEnv(t)
@@ -101,7 +112,8 @@ func newTestEnv(t *testing.T) *cel.Env {
101112
decls.NewVariable("b", types.DynType),
102113
),
103114
lib.Collections(),
104-
cel.OptionalTypes(),
115+
cel.OptionalTypes(cel.OptionalTypesVersion(lib.OptionalTypesVersion)),
116+
ext.TwoVarComprehensions(ext.TwoVarComprehensionsVersion(lib.OptionalTypesVersion)),
105117
cel.EnableMacroCallTracking(),
106118
)
107119
if err != nil {

0 commit comments

Comments
 (0)