Skip to content

Commit f44048f

Browse files
Show the definitions of the aliases a refinement error uses (#489)
Closes #295. A refinement error now says what the aliases in Found and Expected mean, both as structured data for the IDE and as text in the CLI. ## CLI The message keeps the alias names. Under it, one `where` line per alias the message uses, in order of appearance (Found first, then Expected), each alias once. The definition is printed as the spec wrote it: parameter names and named constants unchanged, whitespace collapsed to one line. **Nested aliases.** When a definition uses another alias, that alias gets its own `where` line after the one that uses it, and so on down. Recursive aliases are not expanded again. ### Repro 1: minimal, aliases in both Found and Expected ```java @RefinementAlias("Positive(int x) { x > 0 }") @RefinementAlias("Nat(int x) { x >= 0 }") public class Probe { static void need(@refinement("Positive(_)") int v) {} static void run(@refinement("Nat(_)") int n) { need(n); } } ``` Before: ``` Refinement Error: Nat(n) is not a subtype of Positive(n) ``` After: ``` Refinement Error: Nat(n) is not a subtype of Positive(n) where Nat(x) = x >= 0 where Positive(x) = x > 0 ``` ### Repro 2: external spec with named constants The `x_e_oss_a1_01_sliderecoder` exercise, with the `ImageWriteMode` alias from the issue on the `javax.imageio.ImageWriteParam` spec, `setCompressionMode(@refinement("ImageWriteMode(_)") int mode)`, and `wParam.setCompressionMode(4);` after `getDefaultWriteParam()`. Before: ``` Refinement Error: mode³⁰⁶ == 4 is not a subtype of ImageWriteMode(mode³⁰⁶) ``` After: ``` Refinement Error: mode³⁰⁶ == 4 is not a subtype of ImageWriteMode(mode³⁰⁶) where ImageWriteMode(m) = m == ImageWriteParam.MODE_DISABLED || m == ImageWriteParam.MODE_DEFAULT || m == ImageWriteParam.MODE_EXPLICIT || m == ImageWriteParam.MODE_COPY_FROM_METADATA ``` ### Repro 3: `Port` alias The `y_e_oss_a2_04_vmwarecimprobe` exercise plus `static InetSocketAddress agentAt(String host, int port) { return new InetSocketAddress(host, port); }`. Before: ``` Refinement Error: expected Port(port⁶⁵) but could not prove it --> Counterexample: port⁶⁵ == 65536 ``` After: ``` Refinement Error: expected Port(port⁶⁵) but could not prove it where Port(p) = 0 <= p && p <= 65535 --> Counterexample: port⁶⁵ == 65536 ``` The exercise's other error (`expected timeout >= 0 but could not prove it`) uses no alias and is unchanged. ### Nested aliases and argument order ``` Refinement Error: expected Small(n) but could not prove it where Small(x) = Nat(x) && x < 10 where Nat(x) = x >= 0 Refinement Error: Below(3, b) is not a subtype of Below(b, 5) where Below(a, b) = a < b ``` ## Structured data for the IDE `RefinementError` has two new accessors: - `getFoundAliases()`: one entry per distinct alias invocation in the final simplified Found VC chain, the chain the extension renders as Found. - `getExpectedAliases()`: the same for Expected. Each returns a `List<liquidjava.diagnostics.AliasExpansion>`. Entries come in order of appearance, followed by the aliases their definitions use. `AliasExpansion` is a record: | field | example | meaning | |---|---|---| | `invocation` | `Nat(n)` | the invocation as the diagnostic displays it, so the client can find it in the Found/Expected text | | `expansion` | `n >= 0` | the definition with the parameters replaced by the arguments (all at once). Nested alias invocations are kept and have their own entry | | `alias` | `Nat` | alias name | | `parameters` | `["x"]` | parameter names | | `definition` | `x >= 0` | definition as written in the `@RefinementAlias` | | `declarationPosition` | `SourcePosition` | the `@RefinementAlias` annotation value, for go-to-definition | `declaration()` gives `Nat(x) = x >= 0`. `getMessageNotes()`, new on `LJDiagnostic` and empty by default, returns the CLI `where` lines. The extension would need to add these to `RefinementErrorDTO`, e.g. `foundAliases` / `expectedAliases` built from a small `AliasExpansionDTO`, with `declarationPosition` converted via `SourcePositionDTO.from`. For repro 1 that gives: ```json "foundAliases": [ { "invocation": "Nat(n)", "expansion": "n >= 0", "alias": "Nat", "parameters": ["x"], "definition": "x >= 0", "declarationPosition": { "file": ".../Probe.java", "lineStart": …, "colStart": …, "lineEnd": …, "colEnd": … } } ], "expectedAliases": [ { "invocation": "Positive(n)", "expansion": "n > 0", "alias": "Positive", "parameters": ["x"], "definition": "x > 0", "declarationPosition": { "file": ".../Probe.java", "lineStart": …, "colStart": …, "lineEnd": …, "colEnd": … } } ] ``` Positions are elided above; they are the `@RefinementAlias` value range in the extension's 0-based `SourcePositionDTO` form. With that DTO, the webview can show each invocation's expansion next to Found and Expected (hover or an expandable row) and link `alias` to `declarationPosition`. The extension is not changed in this PR. ## Changes - `AliasWrapper` keeps the definition text as written (whitespace collapsed to one line, except inside string and char literals) and the declaration position. `AliasDTO` exposes the raw text it already parsed. - `AliasExpansion` (new) collects and substitutes the invocations. - `RefinementError` takes the context aliases (from `VCChecker`) and computes the expansions and the `where` lines. - `LJDiagnostic.toString` prints the message notes under the message. The earlier derivation-based expansion from #184, removed in #261, attached alias bodies as origins in the expression derivation tree. The VC chain has no such per-node origins, so this PR records the expansions alongside the chain instead. It reuses the old approach of substituting the arguments into the alias body. `StateRefinementError` is not covered. Its Expected is states, and it has its own Found shape. ## Tests `TestAliasExpansions` (2 tests, existing fixtures, no new ones): - `ErrorAlias`: Found and Expected each record their substituted `InRange` invocation, with parameters and declaration line. - `ErrorStaticFinalInAlias`: one `where` line with the constants as written, printed right under the message. Full verifier module (rebased on main): 708 tests, 0 failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 327a0d1 commit f44048f

9 files changed

Lines changed: 232 additions & 6 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package liquidjava.diagnostics;
2+
3+
import java.util.ArrayList;
4+
import java.util.HashSet;
5+
import java.util.LinkedHashMap;
6+
import java.util.List;
7+
import java.util.Map;
8+
import java.util.Set;
9+
10+
import liquidjava.processor.context.AliasWrapper;
11+
import liquidjava.rj_language.ast.AliasInvocation;
12+
import liquidjava.rj_language.ast.Expression;
13+
import liquidjava.rj_language.ast.Var;
14+
import spoon.reflect.cu.SourcePosition;
15+
16+
/**
17+
* An alias invocation in a diagnostic and what it stands for
18+
*
19+
* @param invocation
20+
* the invocation as shown in the diagnostic, e.g. {@code Nat(n)}
21+
* @param expansion
22+
* the definition with the parameters replaced by the arguments, e.g. {@code n >= 0}; aliases it uses are
23+
* kept and have their own expansions
24+
* @param alias
25+
* the alias name
26+
* @param parameters
27+
* the parameter names of the alias
28+
* @param definition
29+
* the definition as written in the alias declaration
30+
* @param declarationPosition
31+
* where the alias is declared
32+
*/
33+
public record AliasExpansion(String invocation, String expansion, String alias, List<String> parameters,
34+
String definition, SourcePosition declarationPosition) {
35+
36+
/** The alias as it is declared, e.g. {@code Nat(x) = x >= 0} */
37+
public String declaration() {
38+
return alias + "(" + String.join(", ", parameters) + ") = " + definition;
39+
}
40+
41+
/**
42+
* The expansions of the alias invocations in an expression, each invocation once in order of appearance, followed
43+
* by those of the aliases their definitions use
44+
*/
45+
public static List<AliasExpansion> of(Expression expression, List<AliasWrapper> aliases) {
46+
Map<String, AliasWrapper> byName = new LinkedHashMap<>();
47+
for (AliasWrapper alias : aliases)
48+
byName.put(alias.getName(), alias);
49+
Map<String, AliasExpansion> expansions = new LinkedHashMap<>();
50+
collect(expression, byName, expansions, new HashSet<>());
51+
return new ArrayList<>(expansions.values());
52+
}
53+
54+
// Expands the invocations under expression, skipping aliases already being expanded so recursive ones end
55+
private static void collect(Expression expression, Map<String, AliasWrapper> aliases,
56+
Map<String, AliasExpansion> expansions, Set<String> expanding) {
57+
List<AliasInvocation> invocations = new ArrayList<>();
58+
invocationsOf(expression, invocations);
59+
Map<String, Expression> bodies = new LinkedHashMap<>();
60+
for (AliasInvocation invocation : invocations) {
61+
AliasWrapper alias = aliases.get(invocation.getName());
62+
// keyed by the invocation itself, since different names can display the same
63+
String key = invocation.toString();
64+
if (alias == null || expanding.contains(alias.getName()) || expansions.containsKey(key))
65+
continue;
66+
Expression body = substituteArguments(alias, invocation.getArgs());
67+
expansions.put(key, new AliasExpansion(invocation.toDisplayString(), body.toDisplayString(),
68+
alias.getName(), alias.getVarNames(), alias.getDefinition(), alias.getPosition()));
69+
bodies.put(key, body);
70+
}
71+
for (Map.Entry<String, Expression> body : bodies.entrySet()) {
72+
Set<String> inner = new HashSet<>(expanding);
73+
inner.add(expansions.get(body.getKey()).alias());
74+
collect(body.getValue(), aliases, expansions, inner);
75+
}
76+
}
77+
78+
private static void invocationsOf(Expression expression, List<AliasInvocation> invocations) {
79+
if (expression instanceof AliasInvocation invocation)
80+
invocations.add(invocation);
81+
for (Expression child : expression.getChildren())
82+
invocationsOf(child, invocations);
83+
}
84+
85+
// Replaces all parameters at once, through placeholders, so an argument naming another parameter is kept
86+
private static Expression substituteArguments(AliasWrapper alias, List<Expression> args) {
87+
Expression body = alias.getClonedPredicate().getExpression();
88+
List<String> parameters = alias.getVarNames();
89+
int n = Math.min(parameters.size(), args.size());
90+
for (int i = 0; i < n; i++)
91+
body = body.substitute(new Var(parameters.get(i)), new Var("#alias_parameter_" + i));
92+
for (int i = 0; i < n; i++)
93+
body = body.substitute(new Var("#alias_parameter_" + i), args.get(i).clone());
94+
return body;
95+
}
96+
}

‎liquidjava-verifier/src/main/java/liquidjava/diagnostics/LJDiagnostic.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ public SourcePosition getDeclarationPosition() {
7070
return null;
7171
}
7272

73+
/** Lines printed under the message that explain it, such as what the names it uses mean */
74+
public List<String> getMessageNotes() {
75+
return List.of();
76+
}
77+
7378
public void setPosition(SourcePosition pos) {
7479
if (pos == null || pos.getFile() == null)
7580
return;
@@ -108,6 +113,8 @@ public String toString() {
108113
// title
109114
sb.append("\n").append(accentColor).append(title).append(": ").append(Colors.RESET).append(message)
110115
.append("\n");
116+
for (String note : getMessageNotes())
117+
sb.append(" ").append(note).append("\n");
111118

112119
// snippet
113120
String snippet = getSnippet();

‎liquidjava-verifier/src/main/java/liquidjava/diagnostics/errors/RefinementError.java‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
import java.util.ArrayList;
44
import java.util.Comparator;
55
import java.util.HashSet;
6+
import java.util.LinkedHashMap;
67
import java.util.List;
8+
import java.util.Map;
79
import java.util.Set;
810
import java.util.stream.Collectors;
911

12+
import liquidjava.diagnostics.AliasExpansion;
1013
import liquidjava.diagnostics.TranslationTable;
1114
import liquidjava.diagnostics.categories.ErrorCategory;
1215
import liquidjava.diagnostics.hints.Hint;
16+
import liquidjava.processor.context.AliasWrapper;
1317
import liquidjava.rj_language.Predicate;
1418
import liquidjava.rj_language.ast.BinaryExpression;
1519
import liquidjava.rj_language.ast.Enum;
@@ -41,13 +45,21 @@ public class RefinementError extends LJError {
4145
private final VCSimplificationResult found;
4246
private final Counterexample counterexample;
4347
private final SourcePosition declarationPosition;
48+
private final List<AliasExpansion> foundAliases;
49+
private final List<AliasExpansion> expectedAliases;
50+
private final List<String> aliasDeclarations;
4451

4552
public RefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate expected,
4653
VCSimplificationResult found, TranslationTable translationTable, Counterexample counterexample,
47-
String customMessage) {
54+
String customMessage, List<AliasWrapper> aliases) {
4855
super("Refinement Error", message(expected, found), position, translationTable, customMessage);
4956
this.expected = expected;
5057
this.found = found;
58+
Expression foundExpression = found.getImplication().toPredicate().getExpression();
59+
this.foundAliases = AliasExpansion.of(foundExpression, aliases);
60+
this.expectedAliases = AliasExpansion.of(expected.getExpression(), aliases);
61+
this.aliasDeclarations = aliasDeclarations(linkedToExpected(foundExpression, expected.getExpression()),
62+
expected.getExpression(), aliases);
5163
this.counterexample = filterCounterexample(counterexample);
5264
this.declarationPosition = declarationPosition;
5365
if (!this.counterexample.isEmpty()) {
@@ -73,6 +85,16 @@ private static String message(Predicate expected, VCSimplificationResult found)
7385
expected.getExpression().toDisplayString());
7486
}
7587

88+
// Each alias the message uses once, with those their definitions use, as declared
89+
private static List<String> aliasDeclarations(Expression shownFound, Expression expected,
90+
List<AliasWrapper> aliases) {
91+
Map<String, String> declarations = new LinkedHashMap<>();
92+
for (Expression shown : List.of(shownFound, expected))
93+
for (AliasExpansion expansion : AliasExpansion.of(shown, aliases))
94+
declarations.putIfAbsent(expansion.alias(), expansion.declaration());
95+
return new ArrayList<>(declarations.values());
96+
}
97+
7698
/** Unknown when what was found says nothing about the values the expected refinement constrains */
7799
private static ErrorCategory categoryOf(Predicate expected, VCSimplificationResult found) {
78100
List<String> foundVariables = found.getImplication().toPredicate().getVariableNames();
@@ -97,6 +119,22 @@ public VCSimplificationResult getFound() {
97119
return found;
98120
}
99121

122+
/** The alias invocations in found and what they stand for */
123+
public List<AliasExpansion> getFoundAliases() {
124+
return foundAliases;
125+
}
126+
127+
/** The alias invocations in expected and what they stand for */
128+
public List<AliasExpansion> getExpectedAliases() {
129+
return expectedAliases;
130+
}
131+
132+
/** A where line declaring each alias the message uses */
133+
@Override
134+
public List<String> getMessageNotes() {
135+
return aliasDeclarations.stream().map(declaration -> "where " + declaration).toList();
136+
}
137+
100138
// Keeps the counterexample assignments to the terms of expected and of the found facts linked to them, except
101139
// values found already states, so there is none when found fixes every value expected depends on. The terms of
102140
// expected come first, those found says nothing about before the rest, then the linked terms in the order of the

‎liquidjava-verifier/src/main/java/liquidjava/processor/context/AliasWrapper.java‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import java.util.ArrayList;
44
import java.util.List;
5+
import java.util.regex.Matcher;
6+
import java.util.regex.Pattern;
57
import liquidjava.processor.facade.AliasDTO;
68
import liquidjava.rj_language.Predicate;
79
import liquidjava.utils.Utils;
10+
import spoon.reflect.cu.SourcePosition;
811
import spoon.reflect.factory.Factory;
912
import spoon.reflect.reference.CtTypeReference;
1013

@@ -13,10 +16,17 @@ public class AliasWrapper {
1316
private final List<CtTypeReference<?>> varTypes;
1417
private final List<String> varNames;
1518
private final Predicate expression;
19+
private final String definition;
20+
private final SourcePosition position;
1621

17-
public AliasWrapper(AliasDTO a, Factory factory, String klass, String path) {
22+
private static final Pattern LITERAL_OR_SPACES = Pattern
23+
.compile("\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|\\s+");
24+
25+
public AliasWrapper(AliasDTO a, Factory factory, String klass, String path, SourcePosition position) {
1826
name = a.getName();
1927
expression = new Predicate(a.getExpression());
28+
definition = a.getRef() == null ? expression.getExpression().toDisplayString() : oneLine(a.getRef());
29+
this.position = position;
2030
varTypes = new ArrayList<>();
2131
varNames = a.getVarNames();
2232
for (String s : a.getVarTypes()) {
@@ -37,6 +47,22 @@ public List<String> getVarNames() {
3747
return varNames;
3848
}
3949

50+
// Collapses each run of whitespace into one space, except inside string and char literals
51+
private static String oneLine(String definition) {
52+
return LITERAL_OR_SPACES.matcher(definition.strip())
53+
.replaceAll(m -> m.group().isBlank() ? " " : Matcher.quoteReplacement(m.group()));
54+
}
55+
56+
/** The definition as written in the declaration, on one line */
57+
public String getDefinition() {
58+
return definition;
59+
}
60+
61+
/** Position of the alias declaration */
62+
public SourcePosition getPosition() {
63+
return position;
64+
}
65+
4066
public Predicate getClonedPredicate() {
4167
return expression.clone();
4268
}

‎liquidjava-verifier/src/main/java/liquidjava/processor/facade/AliasDTO.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,9 @@ public List<String> getVarNames() {
6565
public Expression getExpression() {
6666
return expression;
6767
}
68+
69+
/** The definition as written in the alias declaration, or null when built from an expression */
70+
public String getRef() {
71+
return ref;
72+
}
6873
}

‎liquidjava-verifier/src/main/java/liquidjava/processor/refinement_checker/TypeChecker.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ protected void handleAlias(String ref, CtElement element, SourcePosition positio
339339
throw new InvalidRefinementError(position, "Refinement alias must return a boolean expression",
340340
ref);
341341
}
342-
AliasWrapper aw = new AliasWrapper(a, factory, klass, path);
342+
AliasWrapper aw = new AliasWrapper(a, factory, klass, path, position);
343343
context.addAlias(aw);
344344
}
345345
} catch (LJError e) {

‎liquidjava-verifier/src/main/java/liquidjava/processor/refinement_checker/VCChecker.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public void processSubtyping(Predicate expectedType, List<GhostState> list, CtEl
8585
if (result.isError()) {
8686
throw new RefinementError(element.getPosition(), declarationPosition, expectedType,
8787
implBeforeChange.simplify(NeededVariables.forRefinementError(expectedType)), map,
88-
result.getCounterexample(), customMessage);
88+
result.getCounterexample(), customMessage, context.getAliases());
8989
}
9090
}
9191

@@ -445,7 +445,8 @@ protected void throwRefinementError(SourcePosition position, SourcePosition decl
445445
TranslationTable map = new TranslationTable();
446446
VCImplication premises = buildPremiseChain(map, expected, found);
447447
throw new RefinementError(position, declarationPosition, expected,
448-
premises.simplify(NeededVariables.forRefinementError(expected)), map, counterexample, customMessage);
448+
premises.simplify(NeededVariables.forRefinementError(expected)), map, counterexample, customMessage,
449+
context.getAliases());
449450
}
450451

451452
/** What a state refinement error shows as found: what is known about the variables of both predicates */
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package liquidjava.api.tests;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import java.util.List;
6+
7+
import org.junit.jupiter.api.Test;
8+
9+
import liquidjava.api.CommandLineLauncher;
10+
import liquidjava.diagnostics.AliasExpansion;
11+
import liquidjava.diagnostics.Diagnostics;
12+
import liquidjava.diagnostics.errors.LJError;
13+
import liquidjava.diagnostics.errors.RefinementError;
14+
15+
class TestAliasExpansions {
16+
17+
private static final String TEST_SUITE = "../liquidjava-example/src/main/java/testSuite/";
18+
19+
@Test
20+
void foundAndExpectedRecordTheirSubstitutedAliases() {
21+
RefinementError error = verify("ErrorAlias.java");
22+
assertExpansion(error.getFoundAliases(), "InRange(j, 10, 16)", "10 < j && j < 16");
23+
assertExpansion(error.getExpectedAliases(), "InRange(j, 10, 15)", "10 < j && j < 15");
24+
AliasExpansion expected = error.getExpectedAliases().get(0);
25+
assertEquals("InRange", expected.alias());
26+
assertEquals(List.of("val", "low", "up"), expected.parameters());
27+
assertEquals(7, expected.declarationPosition().getLine());
28+
}
29+
30+
@Test
31+
void messageDeclaresEachAliasOnceAsWritten() {
32+
RefinementError error = verify("ErrorStaticFinalInAlias.java");
33+
assertEquals(
34+
List.of("where Mode(x) = x == ImageWriteParam.MODE_DISABLED || x == ImageWriteParam.MODE_DEFAULT "
35+
+ "|| x == ImageWriteParam.MODE_EXPLICIT || x == ImageWriteParam.MODE_COPY_FROM_METADATA"),
36+
error.getMessageNotes());
37+
assertTrue(error.toString().contains(error.getMessage() + "\n where Mode(x) = "));
38+
}
39+
40+
private static void assertExpansion(List<AliasExpansion> expansions, String invocation, String expansion) {
41+
assertEquals(1, expansions.size());
42+
// instance numbers of variables are shown as superscripts
43+
assertEquals(invocation, expansions.get(0).invocation().replaceAll("[⁰¹²³⁴⁵⁶⁷⁸⁹]", ""));
44+
assertEquals(expansion, expansions.get(0).expansion().replaceAll("[⁰¹²³⁴⁵⁶⁷⁸⁹]", ""));
45+
}
46+
47+
private static RefinementError verify(String test) {
48+
CommandLineLauncher.launch(TEST_SUITE + test);
49+
List<LJError> errors = Diagnostics.getInstance().getErrors().stream().toList();
50+
assertEquals(1, errors.size(), "Expected exactly one error from " + test);
51+
return assertInstanceOf(RefinementError.class, errors.get(0));
52+
}
53+
}

‎liquidjava-verifier/src/test/java/liquidjava/diagnostics/errors/RefinementErrorCounterexampleTest.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ private static RefinementError error(String expected, VCImplication found, Strin
174174
Counterexample counterexample = new Counterexample(
175175
Arrays.stream(assignments).map(a -> a.split("=")).map(parts -> pair(parts[0], parts[1])).toList());
176176
return new RefinementError(null, null, new Predicate(RefinementsParser.createAST(expected, "")),
177-
found.simplify(), new TranslationTable(), counterexample, null);
177+
found.simplify(), new TranslationTable(), counterexample, null, List.of());
178178
}
179179

180180
private static Pair<String, String> pair(String name, String value) {

0 commit comments

Comments
 (0)