Skip to content

Commit 090112e

Browse files
ChinmayMadeshicopybara-github
authored andcommitted
Setup of the coverage index.
PiperOrigin-RevId: 805684099
1 parent 2f87ed9 commit 090112e

File tree

17 files changed

+846
-20
lines changed

17 files changed

+846
-20
lines changed

parser/src/main/java/dev/cel/parser/CelUnparserVisitor.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ public String unparse() {
6666
return stringBuilder.toString();
6767
}
6868

69+
/**
70+
* Unparses a specific {@link CelExpr} node within the AST.
71+
*
72+
* <p>This method exists to allow unparsing of an arbitrary node within the stored AST in this
73+
* visitor.
74+
*/
75+
public String unparse(CelExpr expr) {
76+
visit(expr);
77+
return stringBuilder.toString();
78+
}
79+
6980
private static String maybeQuoteField(String field) {
7081
if (RESTRICTED_FIELD_NAMES.contains(field)
7182
|| !IDENTIFIER_SEGMENT_PATTERN.matcher(field).matches()) {

testing/src/main/java/dev/cel/testing/testrunner/BUILD.bazel

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ java_library(
1515
],
1616
deps = [
1717
":annotations",
18+
":cel_coverage_index",
1819
":cel_test_suite",
1920
":cel_test_suite_exception",
2021
":cel_test_suite_text_proto_parser",
@@ -33,7 +34,11 @@ java_library(
3334
srcs = ["JUnitXmlReporter.java"],
3435
tags = [
3536
],
36-
deps = ["@maven//:com_google_guava_guava"],
37+
deps = [
38+
":cel_coverage_index",
39+
"@maven//:com_google_guava_guava",
40+
"@maven//:org_jspecify_jspecify",
41+
],
3742
)
3843

3944
java_library(
@@ -42,11 +47,32 @@ java_library(
4247
tags = [
4348
],
4449
deps = [
50+
":cel_coverage_index",
4551
":cel_expression_source",
4652
":cel_test_context",
4753
":cel_test_suite",
4854
":test_runner_library",
4955
"@maven//:junit_junit",
56+
"@maven//:org_jspecify_jspecify",
57+
],
58+
)
59+
60+
java_library(
61+
name = "cel_coverage_index",
62+
srcs = ["CelCoverageIndex.java"],
63+
tags = [
64+
],
65+
deps = [
66+
"//:auto_value",
67+
"//common:cel_ast",
68+
"//common/ast",
69+
"//common/navigation",
70+
"//common/types:type_providers",
71+
"//parser:unparser_visitor",
72+
"//runtime:evaluation_listener",
73+
"@maven//:com_google_code_findbugs_annotations",
74+
"@maven//:com_google_errorprone_error_prone_annotations",
75+
"@maven//:com_google_guava_guava",
5076
],
5177
)
5278

@@ -56,6 +82,7 @@ java_library(
5682
tags = [
5783
],
5884
deps = [
85+
":cel_coverage_index",
5986
":cel_expression_source",
6087
":cel_test_context",
6188
":cel_test_suite",
@@ -80,6 +107,7 @@ java_library(
80107
"@cel_spec//proto/cel/expr:expr_java_proto",
81108
"@maven//:com_google_guava_guava",
82109
"@maven//:com_google_protobuf_protobuf_java",
110+
"@maven//:org_jspecify_jspecify",
83111
],
84112
)
85113

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package dev.cel.testing.testrunner;
15+
16+
import static com.google.common.collect.ImmutableList.toImmutableList;
17+
18+
import com.google.auto.value.AutoValue;
19+
import com.google.common.collect.ImmutableList;
20+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
21+
import javax.annotation.concurrent.ThreadSafe;
22+
import dev.cel.common.CelAbstractSyntaxTree;
23+
import dev.cel.common.ast.CelExpr;
24+
import dev.cel.common.ast.CelExpr.ExprKind;
25+
import dev.cel.common.navigation.CelNavigableAst;
26+
import dev.cel.common.navigation.CelNavigableExpr;
27+
import dev.cel.common.types.CelKind;
28+
import dev.cel.parser.CelUnparserVisitor;
29+
import dev.cel.runtime.CelEvaluationListener;
30+
import java.util.Map;
31+
import java.util.concurrent.ConcurrentHashMap;
32+
import java.util.concurrent.atomic.AtomicBoolean;
33+
34+
import java.util.logging.Logger;
35+
36+
/**
37+
* A class for managing the coverage index for CEL tests.
38+
*
39+
* <p>This class is used to manage the coverage index for CEL tests. It provides a method for
40+
* getting the coverage index for a given test case.
41+
*/
42+
final class CelCoverageIndex {
43+
44+
private static final Logger logger = Logger.getLogger(CelCoverageIndex.class.getName());
45+
46+
private CelAbstractSyntaxTree ast;
47+
private final ConcurrentHashMap<Long, NodeCoverageStats> nodeCoverageStatsMap =
48+
new ConcurrentHashMap<>();
49+
50+
public void init(CelAbstractSyntaxTree ast) {
51+
this.ast = ast;
52+
CelNavigableExpr.fromExpr(ast.getExpr())
53+
.allNodes()
54+
.forEach(
55+
celNavigableExpr -> {
56+
NodeCoverageStats nodeCoverageStats = new NodeCoverageStats();
57+
nodeCoverageStats.isBooleanNode.set(isNodeTypeBoolean(celNavigableExpr.expr()));
58+
nodeCoverageStatsMap.put(celNavigableExpr.id(), nodeCoverageStats);
59+
});
60+
}
61+
62+
/**
63+
* Returns the evaluation listener for the CEL test suite.
64+
*
65+
* <p>This listener is used to track the coverage of the CEL test suite.
66+
*/
67+
public CelEvaluationListener newEvaluationListener() {
68+
return new EvaluationListener(nodeCoverageStatsMap);
69+
}
70+
71+
/** Returns the coverage report for the CEL test suite. */
72+
public CoverageReport generateCoverageReport() {
73+
CoverageReport.Builder reportBuilder =
74+
CoverageReport.builder().setCelExpression(new CelUnparserVisitor(ast).unparse());
75+
traverseAndCalculateCoverage(
76+
CelNavigableAst.fromAst(ast).getRoot(), nodeCoverageStatsMap, true, "", reportBuilder);
77+
CoverageReport report = reportBuilder.build();
78+
logger.info("CEL Expression: " + report.celExpression());
79+
logger.info("Nodes: " + report.nodes());
80+
logger.info("Covered Nodes: " + report.coveredNodes());
81+
logger.info("Branches: " + report.branches());
82+
logger.info("Covered Boolean Outcomes: " + report.coveredBooleanOutcomes());
83+
logger.info("Unencountered Nodes: \n" + String.join("\n", report.unencounteredNodes()));
84+
logger.info("Unencountered Branches: \n" + String.join("\n",
85+
report.unencounteredBranches()));
86+
return report;
87+
}
88+
89+
/** A class for managing the coverage report for a CEL test suite. */
90+
@AutoValue
91+
public abstract static class CoverageReport {
92+
public abstract String celExpression();
93+
94+
public abstract long nodes();
95+
96+
public abstract long coveredNodes();
97+
98+
public abstract long branches();
99+
100+
public abstract long coveredBooleanOutcomes();
101+
102+
public abstract ImmutableList<String> unencounteredNodes();
103+
104+
public abstract ImmutableList<String> unencounteredBranches();
105+
106+
public static Builder builder() {
107+
return new AutoValue_CelCoverageIndex_CoverageReport.Builder()
108+
.setNodes(0L)
109+
.setCoveredNodes(0L)
110+
.setBranches(0L)
111+
.setCelExpression("")
112+
.setCoveredBooleanOutcomes(0L);
113+
}
114+
115+
/** Builder for {@link CoverageReport}. */
116+
@AutoValue.Builder
117+
public abstract static class Builder {
118+
public abstract Builder setCelExpression(String value);
119+
120+
public abstract long nodes();
121+
122+
public abstract Builder setNodes(long value);
123+
124+
public abstract long coveredNodes();
125+
126+
public abstract Builder setCoveredNodes(long value);
127+
128+
public abstract long branches();
129+
130+
public abstract Builder setBranches(long value);
131+
132+
public abstract long coveredBooleanOutcomes();
133+
134+
public abstract Builder setCoveredBooleanOutcomes(long value);
135+
136+
public abstract ImmutableList.Builder<String> unencounteredNodesBuilder();
137+
138+
public abstract ImmutableList.Builder<String> unencounteredBranchesBuilder();
139+
140+
@CanIgnoreReturnValue
141+
public final Builder addUnencounteredNodes(String value) {
142+
unencounteredNodesBuilder().add(value);
143+
return this;
144+
}
145+
146+
@CanIgnoreReturnValue
147+
public final Builder addUnencounteredBranches(String value) {
148+
unencounteredBranchesBuilder().add(value);
149+
return this;
150+
}
151+
152+
public abstract CoverageReport build();
153+
}
154+
}
155+
156+
/** A class for managing the coverage stats for a CEL node. */
157+
@ThreadSafe
158+
private static final class NodeCoverageStats {
159+
final AtomicBoolean isBooleanNode = new AtomicBoolean(false);
160+
final AtomicBoolean covered = new AtomicBoolean(false);
161+
final AtomicBoolean hasTrueBranch = new AtomicBoolean(false);
162+
final AtomicBoolean hasFalseBranch = new AtomicBoolean(false);
163+
}
164+
165+
private Boolean isNodeTypeBoolean(CelExpr celExpr) {
166+
return ast.getTypeMap().containsKey(celExpr.id())
167+
&& ast.getTypeMap().get(celExpr.id()).kind().equals(CelKind.BOOL);
168+
}
169+
170+
private void traverseAndCalculateCoverage(
171+
CelNavigableExpr node,
172+
Map<Long, NodeCoverageStats> statsMap,
173+
boolean logUnencountered,
174+
String precedingTabs,
175+
CoverageReport.Builder reportBuilder) {
176+
long nodeId = node.id();
177+
NodeCoverageStats stats = statsMap.getOrDefault(nodeId, new NodeCoverageStats());
178+
reportBuilder.setNodes(reportBuilder.nodes() + 1);
179+
180+
boolean isInterestingBooleanNode = isInterestingBooleanNode(node, stats);
181+
182+
// Only unparse if the node is interesting (boolean node) and we need to log
183+
// unencountered nodes.
184+
String exprText = "";
185+
if (isInterestingBooleanNode && logUnencountered) {
186+
exprText = new CelUnparserVisitor(ast).unparse(node.expr());
187+
}
188+
189+
// Update coverage for the current node and determine if we should continue logging
190+
// unencountered.
191+
logUnencountered =
192+
updateNodeCoverage(
193+
nodeId, stats, isInterestingBooleanNode, exprText, logUnencountered, reportBuilder);
194+
195+
if (isInterestingBooleanNode) {
196+
precedingTabs =
197+
updateBooleanBranchCoverage(
198+
nodeId, stats, exprText, precedingTabs, logUnencountered, reportBuilder);
199+
}
200+
201+
for (CelNavigableExpr child : node.children().collect(toImmutableList())) {
202+
traverseAndCalculateCoverage(child, statsMap, logUnencountered, precedingTabs, reportBuilder);
203+
}
204+
}
205+
206+
private boolean isInterestingBooleanNode(CelNavigableExpr node, NodeCoverageStats stats) {
207+
return stats.isBooleanNode.get()
208+
&& !node.expr().getKind().equals(ExprKind.Kind.CONSTANT)
209+
&& !(node.expr().getKind().equals(ExprKind.Kind.CALL)
210+
&& node.expr().call().function().equals("cel.@block"));
211+
}
212+
213+
/**
214+
* Updates the coverage report based on whether the current node was covered. Returns true if
215+
* logging of unencountered nodes should continue for children, false otherwise.
216+
*/
217+
private boolean updateNodeCoverage(
218+
long nodeId,
219+
NodeCoverageStats stats,
220+
boolean isInterestingBooleanNode,
221+
String exprText,
222+
boolean logUnencountered,
223+
CoverageReport.Builder reportBuilder) {
224+
if (stats.covered.get()) {
225+
reportBuilder.setCoveredNodes(reportBuilder.coveredNodes() + 1);
226+
} else {
227+
if (logUnencountered) {
228+
if (isInterestingBooleanNode) {
229+
reportBuilder.addUnencounteredNodes(
230+
String.format("Expression ID %d ('%s')", nodeId, exprText));
231+
}
232+
// Once an unencountered node is found, we don't log further unencountered nodes in its
233+
// subtree to avoid noise.
234+
return false;
235+
}
236+
}
237+
return logUnencountered;
238+
}
239+
240+
/**
241+
* Updates the coverage report for boolean nodes, including branch coverage. Returns the
242+
* potentially modified `precedingTabs` string.
243+
*/
244+
private String updateBooleanBranchCoverage(
245+
long nodeId,
246+
NodeCoverageStats stats,
247+
String exprText,
248+
String precedingTabs,
249+
boolean logUnencountered,
250+
CoverageReport.Builder reportBuilder) {
251+
reportBuilder.setBranches(reportBuilder.branches() + 2);
252+
if (stats.hasTrueBranch.get()) {
253+
reportBuilder.setCoveredBooleanOutcomes(reportBuilder.coveredBooleanOutcomes() + 1);
254+
} else if (logUnencountered) {
255+
reportBuilder.addUnencounteredBranches(
256+
String.format(
257+
"%sExpression ID %d ('%s'): lacks 'true' coverage", precedingTabs, nodeId, exprText));
258+
precedingTabs += "\t\t";
259+
}
260+
if (stats.hasFalseBranch.get()) {
261+
reportBuilder.setCoveredBooleanOutcomes(reportBuilder.coveredBooleanOutcomes() + 1);
262+
} else if (logUnencountered) {
263+
reportBuilder.addUnencounteredBranches(
264+
String.format(
265+
"%sExpression ID %d ('%s'): lacks 'false' coverage",
266+
precedingTabs, nodeId, exprText));
267+
precedingTabs += "\t\t";
268+
}
269+
return precedingTabs;
270+
}
271+
272+
@ThreadSafe
273+
private static final class EvaluationListener implements CelEvaluationListener {
274+
275+
private final ConcurrentHashMap<Long, NodeCoverageStats> nodeCoverageStatsMap;
276+
277+
EvaluationListener(ConcurrentHashMap<Long, NodeCoverageStats> nodeCoverageStatsMap) {
278+
this.nodeCoverageStatsMap = nodeCoverageStatsMap;
279+
}
280+
281+
@Override
282+
public void callback(CelExpr celExpr, Object evaluationResult) {
283+
NodeCoverageStats nodeCoverageStats = nodeCoverageStatsMap.get(celExpr.id());
284+
nodeCoverageStats.covered.set(true);
285+
if (nodeCoverageStats.isBooleanNode.get()) {
286+
if (evaluationResult instanceof Boolean) {
287+
if ((Boolean) evaluationResult) {
288+
nodeCoverageStats.hasTrueBranch.set(true);
289+
} else {
290+
nodeCoverageStats.hasFalseBranch.set(true);
291+
}
292+
}
293+
}
294+
}
295+
}
296+
}

0 commit comments

Comments
 (0)