Skip to content

Commit 9168819

Browse files
ChinmayMadeshicopybara-github
authored andcommitted
Setup of the coverage index.
PiperOrigin-RevId: 805684099
1 parent 0bb2f72 commit 9168819

File tree

17 files changed

+845
-20
lines changed

17 files changed

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

0 commit comments

Comments
 (0)