Skip to content

Commit ae278d2

Browse files
Show variables by their name, with a line only to tell instances apart (#493)
Closes #492 Variables in errors no longer carry a superscript counter (`box¹⁵`). That counter matched nothing in the code, and it mixed Latin-1 `¹²³` with the Superscripts block, which many fonts draw unevenly. Each error now names its variables within one scope: - A variable that no other variable in the scope is written like keeps its plain name. - When several are written alike, one keeps the plain name: the declared variable, or else the latest instance the message shows. Each other one gets the line its value comes from, `x@12`. - Two tagged variables from the same line (or with no line) are numbered `x@12#1`, `x@12#2`. Two different instances never print the same. The message, counterexample and Found steps of an error share the same names. The context view follows the same rule within each refinement. Verification, internal names, counters and the translation table are unchanged; this is presentation only. `toSuperscript` is removed. ### Before / after **Plain names** (A2_10 RandomFailureSupplier, the case reported in the issue): ``` before: Refinement Error: failures.size()⁸⁴² == size(this.failures) && failures.size()⁸⁴² >= 0 is not a subtype of failures.size()⁸⁴² > 0 --> Counterexample: failures.size()⁸⁴² == 0 after: Refinement Error: failures.size() == size(this.failures) && failures.size() >= 0 is not a subtype of failures.size() > 0 --> Counterexample: failures.size() == 0 ``` **A3_07 ScannerOutputStage** (the only study error with two instances of a name in the message): ``` before: found startTiling(preview¹¹¹) && state2(preview¹¹¹) == state2(preview¹⁰⁹) && compressionSet(preview¹¹⁶) && state1(preview¹¹⁶) == state1(preview¹¹¹) but expected tilingExplicit(preview¹¹⁶) || tilingSet(preview¹¹⁶) after: found startTiling(preview@51) && state2(preview@51) == state2(preview@50) && compressionSet(preview) && state1(preview) == state1(preview@51) but expected tilingExplicit(preview) || tilingSet(preview) ``` **A1_56 EditCommand**: the message, and the original Found the panel steps back to: ``` before: expected redoable(undoManager⁵⁵⁷) but could not prove it after: expected redoable(undoManager) but could not prove it before after ∀#getUndoManager_result⁵⁵⁵ true ∀#getUndoManager_result true ∀#operation_result⁵³⁷ true ∀#operation_result@41 true ∀#if_condition⁵³⁸ !#operation_result⁵³⁷ ∀#if_condition@41 !#operation_result@41 ∀#operation_result⁵⁵⁰ true ∀#operation_result@56 true ∀#operation_result⁵⁵¹ true ∀#operation_result true ∀#if_condition⁵⁵² !(#operation_result⁵⁵⁰ || …⁵⁵¹) ∀#if_condition !(#operation_result@56 || #operation_result) ∀undoManager⁵⁵⁷ undoManager⁵⁵⁷ == …⁵⁵⁵ ∀undoManager undoManager == #getUndoManager_result ``` Across the test suite, 11 of 437 errors still show a tag in the message. All of them are before-and-after values (`s--`, `b = -b`, a typestate object across calls). Counters remain only where values on one line must be told apart, e.g. `n@11#1` in `for (; n > 0; n -= 2);`. ### API for the extension (liquid-java/vscode-liquidjava#175) - `LJError.getDisplayNames()` returns the error's `DisplayNames` for a `RefinementError` or `StateRefinementError` (null for other errors). - Binder names, counterexample variables and translation-table keys: `names.get(internalName)`. - Expected and the predicate of each step: `ExpressionFormatter.format(expression, names)`. - Context view: `ExpressionFormatter.format(refinement.getExpression(), ContextHistory.getInstance()::lineOf)` for `VariableDTO.refinement` / `mainRefinement`. - `RefinementError.getFoundAliases()` / `getExpectedAliases()` (#489) use the same names: `invocation` and `expansion` are formatted with the error's `DisplayNames`, so an invocation reads as in Found/Expected, e.g. `Below(b@9, 10)` with expansion `b@9 < 10`. - `VariableFormatter.format` and `ExpressionFormatter.format(expression)` keep working without a scope. They write plain names and number clashes within the expression. - The webview tokenizers should treat `name@12#2` as one identifier and can render the tag as `<sub>`. ### Tests - `DisplayNamesTest`: line tags with the latest shown value plain; numbering for a shared line or no line, and the declared variable kept plain. - `TestFoundBinders` (new `Basket.negate` fixture): message, counterexample and original Found rows name `b@85`, `b@86` and `b` alike. - `ContextHistoryTest`: the refinement of `b` after `b = -b` reads `b == b@19 && b > 0`. - Existing assertions on superscripts are updated to plain names where they compare them; assertions that strip superscripts are left as they are. `mvn test` (rebased on main after #489): 712 tests, 0 failures. Diff vs main: +353/−113 in 23 files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f44048f commit ae278d2

23 files changed

Lines changed: 353 additions & 113 deletions

‎liquidjava-example/src/main/java/testSuite/classes/found_binders_error/Basket.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,11 @@ static int count() {
7878
int pickByCount() {
7979
return random.nextInt(count()); // Expect: Refinement Error
8080
}
81+
82+
// the earlier values of a variable are written with the line they come from
83+
void negate() {
84+
@Refinement("_ > 0")
85+
int b = 8;
86+
b = -b; // Expect: Refinement Error
87+
}
8188
}

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import liquidjava.rj_language.ast.AliasInvocation;
1212
import liquidjava.rj_language.ast.Expression;
1313
import liquidjava.rj_language.ast.Var;
14+
import liquidjava.rj_language.ast.formatter.DisplayNames;
15+
import liquidjava.rj_language.ast.formatter.ExpressionFormatter;
1416
import spoon.reflect.cu.SourcePosition;
1517

1618
/**
@@ -40,19 +42,19 @@ public String declaration() {
4042

4143
/**
4244
* 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
45+
* by those of the aliases their definitions use, naming the variables as the diagnostic does
4446
*/
45-
public static List<AliasExpansion> of(Expression expression, List<AliasWrapper> aliases) {
47+
public static List<AliasExpansion> of(Expression expression, List<AliasWrapper> aliases, DisplayNames names) {
4648
Map<String, AliasWrapper> byName = new LinkedHashMap<>();
4749
for (AliasWrapper alias : aliases)
4850
byName.put(alias.getName(), alias);
4951
Map<String, AliasExpansion> expansions = new LinkedHashMap<>();
50-
collect(expression, byName, expansions, new HashSet<>());
52+
collect(expression, byName, names, expansions, new HashSet<>());
5153
return new ArrayList<>(expansions.values());
5254
}
5355

5456
// 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,
57+
private static void collect(Expression expression, Map<String, AliasWrapper> aliases, DisplayNames names,
5658
Map<String, AliasExpansion> expansions, Set<String> expanding) {
5759
List<AliasInvocation> invocations = new ArrayList<>();
5860
invocationsOf(expression, invocations);
@@ -64,14 +66,16 @@ private static void collect(Expression expression, Map<String, AliasWrapper> ali
6466
if (alias == null || expanding.contains(alias.getName()) || expansions.containsKey(key))
6567
continue;
6668
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+
expansions.put(key,
70+
new AliasExpansion(ExpressionFormatter.format(invocation, names),
71+
ExpressionFormatter.format(body, names), alias.getName(), alias.getVarNames(),
72+
alias.getDefinition(), alias.getPosition()));
6973
bodies.put(key, body);
7074
}
7175
for (Map.Entry<String, Expression> body : bodies.entrySet()) {
7276
Set<String> inner = new HashSet<>(expanding);
7377
inner.add(expansions.get(body.getKey()).alias());
74-
collect(body.getValue(), aliases, expansions, inner);
78+
collect(body.getValue(), aliases, names, expansions, inner);
7579
}
7680
}
7781

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package liquidjava.diagnostics.errors;
2+
3+
import static liquidjava.rj_language.opt.VCSimplificationUtils.linkedToExpected;
4+
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import java.util.function.Predicate;
8+
9+
import liquidjava.diagnostics.TranslationTable;
10+
import liquidjava.processor.context.PlacementInCode;
11+
import liquidjava.rj_language.ast.Expression;
12+
import liquidjava.rj_language.ast.formatter.DisplayNames;
13+
import liquidjava.rj_language.ast.formatter.ExpressionFormatter;
14+
import liquidjava.rj_language.opt.VCSimplificationResult;
15+
16+
/** The message of an error that found does not prove expected, and the names it gives the variables of both */
17+
record ErrorMessage(String text, DisplayNames names) {
18+
19+
/**
20+
* Writes the facts of found about the values expected constrains and expected in frame, or only expected when
21+
* showsFound rejects those facts; path conditions about other values stay in the derivation
22+
*/
23+
static ErrorMessage of(Expression expected, VCSimplificationResult found, TranslationTable translationTable,
24+
Predicate<Expression> showsFound, String frame) {
25+
Expression shown = linkedToExpected(found.getImplication().toPredicate().getExpression(), expected);
26+
// the names of the message first, then those only the original found has, each with the line of its value
27+
List<String> message = new ArrayList<>();
28+
shown.getVariableNames(message);
29+
expected.getVariableNames(message);
30+
VCSimplificationResult original = found.getOriginal();
31+
List<String> others = original.getBinders();
32+
original.getImplication().toPredicate().getExpression().getVariableNames(others);
33+
DisplayNames names = DisplayNames.of(message, others, name -> {
34+
PlacementInCode placement = translationTable == null ? null : translationTable.get(name);
35+
return placement == null ? null : placement.getLine();
36+
});
37+
String expectedText = ExpressionFormatter.format(expected, names);
38+
String text = showsFound.test(shown)
39+
? String.format(frame, ExpressionFormatter.format(shown, names), expectedText)
40+
: String.format("expected %s but could not prove it", expectedText);
41+
return new ErrorMessage(text, names);
42+
}
43+
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import liquidjava.diagnostics.LJDiagnostic;
44
import liquidjava.diagnostics.TranslationTable;
55
import liquidjava.diagnostics.Colors;
6+
import liquidjava.rj_language.ast.formatter.DisplayNames;
67
import spoon.reflect.cu.SourcePosition;
78

89
/**
@@ -11,18 +12,30 @@
1112
public abstract class LJError extends LJDiagnostic {
1213

1314
private final TranslationTable translationTable;
15+
private final DisplayNames displayNames;
1416

1517
public LJError(String title, String message, SourcePosition pos, TranslationTable translationTable) {
1618
this(title, message, pos, translationTable, null);
1719
}
1820

1921
public LJError(String title, String message, SourcePosition pos, TranslationTable translationTable,
2022
String customMessage) {
21-
super(title, message, pos, Colors.BOLD_RED, customMessage);
23+
this(title, new ErrorMessage(message, null), pos, translationTable, customMessage);
24+
}
25+
26+
LJError(String title, ErrorMessage message, SourcePosition pos, TranslationTable translationTable,
27+
String customMessage) {
28+
super(title, message.text(), pos, Colors.BOLD_RED, customMessage);
2229
this.translationTable = translationTable != null ? translationTable : new TranslationTable();
30+
this.displayNames = message.names();
2331
}
2432

2533
public TranslationTable getTranslationTable() {
2634
return translationTable;
2735
}
36+
37+
/** The names the error gives its variables, for every view of it to show them alike, or null when it has none */
38+
public DisplayNames getDisplayNames() {
39+
return displayNames;
40+
}
2841
}

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

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import liquidjava.rj_language.ast.Enum;
2020
import liquidjava.rj_language.ast.Expression;
2121
import liquidjava.rj_language.ast.Var;
22-
import liquidjava.rj_language.ast.formatter.VariableFormatter;
2322
import liquidjava.rj_language.opt.VCSimplificationResult;
2423
import liquidjava.smt.Counterexample;
2524
import liquidjava.utils.GeneratedNames;
@@ -52,19 +51,20 @@ public class RefinementError extends LJError {
5251
public RefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate expected,
5352
VCSimplificationResult found, TranslationTable translationTable, Counterexample counterexample,
5453
String customMessage, List<AliasWrapper> aliases) {
55-
super("Refinement Error", message(expected, found), position, translationTable, customMessage);
54+
super("Refinement Error", ErrorMessage.of(expected.getExpression(), found, translationTable,
55+
shown -> !isTrue(shown), "%s is not a subtype of %s"), position, translationTable, customMessage);
5656
this.expected = expected;
5757
this.found = found;
5858
Expression foundExpression = found.getImplication().toPredicate().getExpression();
59-
this.foundAliases = AliasExpansion.of(foundExpression, aliases);
60-
this.expectedAliases = AliasExpansion.of(expected.getExpression(), aliases);
59+
this.foundAliases = AliasExpansion.of(foundExpression, aliases, getDisplayNames());
60+
this.expectedAliases = AliasExpansion.of(expected.getExpression(), aliases, getDisplayNames());
6161
this.aliasDeclarations = aliasDeclarations(linkedToExpected(foundExpression, expected.getExpression()),
6262
expected.getExpression(), aliases);
6363
this.counterexample = filterCounterexample(counterexample);
6464
this.declarationPosition = declarationPosition;
6565
if (!this.counterexample.isEmpty()) {
6666
String counterexampleString = this.counterexample.assignments().stream()
67-
.map(a -> VariableFormatter.format(a.first()) + " == " + a.second())
67+
.map(a -> getDisplayNames().get(a.first()) + " == " + a.second())
6868
.collect(Collectors.joining(" && "));
6969
setCounterexampleStr("Counterexample: " + counterexampleString);
7070
}
@@ -74,23 +74,11 @@ public RefinementError(SourcePosition position, SourcePosition declarationPositi
7474
"Not enough information to prove the expected refinement. Add a refinement or condition to constrain it."));
7575
}
7676

77-
// Only the facts of found about the values expected constrains; path conditions about other values stay in the
78-
// derivation
79-
private static String message(Predicate expected, VCSimplificationResult found) {
80-
Expression shown = linkedToExpected(found.getImplication().toPredicate().getExpression(),
81-
expected.getExpression());
82-
if (isTrue(shown))
83-
return String.format("expected %s but could not prove it", expected.getExpression().toDisplayString());
84-
return String.format("%s is not a subtype of %s", shown.toDisplayString(),
85-
expected.getExpression().toDisplayString());
86-
}
87-
8877
// 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) {
78+
private List<String> aliasDeclarations(Expression shownFound, Expression expected, List<AliasWrapper> aliases) {
9179
Map<String, String> declarations = new LinkedHashMap<>();
9280
for (Expression shown : List.of(shownFound, expected))
93-
for (AliasExpansion expansion : AliasExpansion.of(shown, aliases))
81+
for (AliasExpansion expansion : AliasExpansion.of(shown, aliases, getDisplayNames()))
9482
declarations.putIfAbsent(expansion.alias(), expansion.declaration());
9583
return new ArrayList<>(declarations.values());
9684
}
@@ -182,10 +170,7 @@ private static boolean states(Expression term, Expression value, Pair<String, St
182170

183171
// Binders of the VC chain before simplification, which keeps the variables in the order they were introduced
184172
private List<String> originalBinders() {
185-
VCSimplificationResult original = getFound();
186-
while (original.getOrigin() != null)
187-
original = original.getOrigin();
188-
return original.getBinders();
173+
return getFound().getOriginal().getBinders();
189174
}
190175

191176
// 0 for terms of expected found says nothing about, whose values alone break it, 1 for the other terms of

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

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package liquidjava.diagnostics.errors;
22

33
import static liquidjava.rj_language.opt.VCSimplificationUtils.isTrue;
4-
import static liquidjava.rj_language.opt.VCSimplificationUtils.linkedToExpected;
54

65
import java.util.List;
76

@@ -30,7 +29,9 @@ public class StateRefinementError extends LJError {
3029
public StateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate expected,
3130
VCSimplificationResult found, TranslationTable translationTable, String customMessage, List<Hint> hints,
3231
ErrorCategory category, StateErrorContext context) {
33-
super("State Refinement Error", message(expected, found), position, translationTable, customMessage);
32+
super("State Refinement Error", ErrorMessage.of(expected.getExpression(), found, translationTable,
33+
StateRefinementError::appliesGhost, "found %s but expected %s"), position, translationTable,
34+
customMessage);
3435
this.declarationPosition = declarationPosition;
3536
this.expected = expected;
3637
this.found = found;
@@ -44,15 +45,6 @@ public StateRefinementError(SourcePosition position, SourcePosition declarationP
4445

4546
// Only the facts of found about the objects of expected, and only when one of them tells a state: an alias or a
4647
// guard on another value says nothing about the state expected needs
47-
private static String message(Predicate expected, VCSimplificationResult found) {
48-
Expression shown = linkedToExpected(found.getImplication().toPredicate().getExpression(),
49-
expected.getExpression());
50-
if (!appliesGhost(shown))
51-
return String.format("expected %s but could not prove it", expected.getExpression().toDisplayString());
52-
return String.format("found %s but expected %s", shown.toDisplayString(),
53-
expected.getExpression().toDisplayString());
54-
}
55-
5648
private static boolean appliesGhost(Expression expression) {
5749
return expression instanceof FunctionInvocation
5850
|| expression.getChildren().stream().anyMatch(StateRefinementError::appliesGhost);

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import java.util.HashMap;
44
import java.util.HashSet;
55
import java.util.Map;
6+
import java.util.Objects;
67
import java.util.Set;
8+
import java.util.stream.Stream;
79

810
import liquidjava.api.CommandLineLauncher;
911
import liquidjava.utils.Utils;
@@ -96,4 +98,11 @@ public Set<RefinedFunction> getMethods() {
9698
public Map<String, Set<String>> getFileScopes() {
9799
return fileScopes;
98100
}
101+
102+
/** The line where the value of a saved variable comes from, or null when unknown */
103+
public Integer lineOf(String name) {
104+
return Stream.concat(localVars.stream(), globalVars.stream()).filter(v -> v.getName().equals(name))
105+
.map(RefinedVariable::getPlacementInCode).filter(Objects::nonNull).map(PlacementInCode::getLine)
106+
.filter(Objects::nonNull).findFirst().orElse(null);
107+
}
99108
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ public SourcePosition getAnnotationPosition() {
3131
return annotationPosition;
3232
}
3333

34+
/** The line of this placement, or null when it is not in code */
35+
public Integer getLine() {
36+
return position == null || !position.isValidPosition() ? null : position.getLine();
37+
}
38+
3439
public static PlacementInCode createPlacement(CtElement elem) {
3540
CtElement elemCopy = elem.clone();
3641
// cleanup annotations
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package liquidjava.rj_language.ast.formatter;
2+
3+
import java.util.Collection;
4+
import java.util.Comparator;
5+
import java.util.HashMap;
6+
import java.util.LinkedHashSet;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.Set;
10+
import java.util.function.Function;
11+
import java.util.stream.Collectors;
12+
13+
/**
14+
* The names of the variables of one scope, such as an error or a refinement, as they are shown. A name is written as in
15+
* code; when the scope has several variables written alike, one keeps the plain name and each other one gets the line
16+
* its value comes from, {@code x@12}, numbered when they share a line or have none, {@code x@12#1} and {@code x@12#2}.
17+
* The plain name goes to the declared variable, or else to its latest instance, preferring those shown first
18+
*/
19+
public final class DisplayNames {
20+
private final Map<String, String> tags = new HashMap<>();
21+
22+
private DisplayNames() {
23+
}
24+
25+
/**
26+
* @param shown
27+
* the names shown first, such as those of an error message
28+
* @param others
29+
* the other names of the scope
30+
* @param lineOf
31+
* the line where the value of a variable comes from, or null when unknown
32+
*/
33+
public static DisplayNames of(Collection<String> shown, Collection<String> others,
34+
Function<String, Integer> lineOf) {
35+
DisplayNames names = new DisplayNames();
36+
Set<String> all = new LinkedHashSet<>(shown);
37+
all.addAll(others);
38+
Map<String, List<String>> alike = all.stream().collect(Collectors.groupingBy(VariableFormatter::format));
39+
for (List<String> variables : alike.values()) {
40+
if (variables.size() > 1)
41+
names.tag(variables, shown, lineOf);
42+
}
43+
return names;
44+
}
45+
46+
/**
47+
* The display name of a variable, or of a ghost application over variables such as {@code size(#s_2)}
48+
*/
49+
public String get(String name) {
50+
return VariableFormatter.format(name, variable -> tags.getOrDefault(variable, ""));
51+
}
52+
53+
private void tag(List<String> alike, Collection<String> shown, Function<String, Integer> lineOf) {
54+
String plain = plainOf(alike, shown);
55+
Map<String, List<String>> byLine = alike.stream().filter(name -> !name.equals(plain))
56+
.sorted(Comparator.comparingInt(VariableFormatter::counterOf))
57+
.collect(Collectors.groupingBy(name -> lineTag(lineOf.apply(name))));
58+
byLine.forEach((line, variables) -> {
59+
for (int i = 0; i < variables.size(); i++)
60+
tags.put(variables.get(i), tagFor(line, i, variables.size()));
61+
});
62+
}
63+
64+
// The declared variable, or else the latest instance, preferring those shown
65+
private static String plainOf(List<String> alike, Collection<String> shown) {
66+
List<String> candidates = alike.stream().filter(shown::contains).toList();
67+
return (candidates.isEmpty() ? alike : candidates).stream()
68+
.max(Comparator.comparing((String name) -> VariableFormatter.counterOf(name) < 0)
69+
.thenComparing(VariableFormatter::counterOf))
70+
.orElseThrow();
71+
}
72+
73+
// The line alone tells apart a single value from it; values that share a line, or have none, are numbered
74+
private static String tagFor(String line, int index, int count) {
75+
return count == 1 && !line.isEmpty() ? line : line + "#" + (index + 1);
76+
}
77+
78+
private static String lineTag(Integer line) {
79+
return line == null ? "" : "@" + line;
80+
}
81+
}

0 commit comments

Comments
 (0)