Skip to content

Commit 440097e

Browse files
Attach the receiver's states to state refinement errors (#421)
## Why The VS Code extension shows a state machine with the rejected call highlighted. Today liquidjava-fsm works out the receiver's possible and accepted states again by reading the text of the error's `expected` and `found` predicates. The verifier has already computed both with the SMT solver, and the text reading can disagree with it, for example with guarded `from` clauses, `old(...)` or several state sets. This PR exposes the verifier's answer, so fsm can go back to only drawing the diagram. ## What `StateRefinementError#getContext()` returns a `StateErrorContext`, or null when the states could not be computed: | Field | Example | |---|---| | `receiver` | `f` (as written; `this` if implicit) | | `receiverClass` | `testSuite.ErrorStateHintCallSequence` | | `method` | `read()` (same format as the hints) | | `possibleStates` | `[["created"]]` | | `acceptedStates` | `[["open"]]`, or null when they depend on the arguments | | `complete` | whether every method that changes the state is known | Each state lists its name in every state set, in declaration order, e.g. `["closed", "locked"]`. The states stay combined because projecting them onto each set separately would mark states as accepted that are not. To support this, `State` now keeps its per-set names, and `name()` still joins them with " and " for the hints. Hints and the category already reach clients through `LJDiagnostic`. ## Tests `TestStateErrorContext` covers a single state set, several state sets, a path-dependent state, an argument-dependent transition (accepted states unknown, machine incomplete) and a class without a state set (no context). `mvn test`: 492 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b3a4e4a commit 440097e

10 files changed

Lines changed: 174 additions & 19 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.StateRefinement;
4+
import liquidjava.specification.StateSet;
5+
6+
@StateSet({"open", "closed"})
7+
public class ErrorStateContextArgumentDependent {
8+
9+
@StateRefinement(to = "open(this)")
10+
public ErrorStateContextArgumentDependent() {}
11+
12+
@StateRefinement(from = "open(this)", to = "closed(this)")
13+
public void close() {}
14+
15+
// the states that allow it depend on its argument
16+
@StateRefinement(from = "closed(this) && attempts > 0", to = "open(this)")
17+
public void reopen(int attempts) {}
18+
19+
public static void main(String[] args) {
20+
ErrorStateContextArgumentDependent f = new ErrorStateContextArgumentDependent();
21+
f.reopen(1); // Expect: State Refinement Error
22+
}
23+
}
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 java.util.Collection;
4+
import java.util.List;
5+
import java.util.Set;
6+
7+
import liquidjava.diagnostics.hints.StateHints;
8+
import liquidjava.processor.context.RefinedFunction;
9+
import liquidjava.processor.state_machine.State;
10+
import liquidjava.processor.state_machine.StateMachine;
11+
12+
/**
13+
* What the verifier knows about a call made in a state that does not allow it, for clients that show it. Each state is
14+
* given by its name in each state set of the class, in the order the sets are declared.
15+
*
16+
* @param receiver
17+
* the receiver of the call as written in the code
18+
* @param receiverClass
19+
* the qualified name of the receiver's class
20+
* @param method
21+
* the called method, e.g. {@code open(int)}
22+
* @param possibleStates
23+
* the states the receiver can be in before the call
24+
* @param acceptedStates
25+
* the states that allow the call, or null if they depend on more than the receiver's state
26+
* @param complete
27+
* whether every method that changes the receiver's state is known, so a state that no call reaches is
28+
* unreachable
29+
*/
30+
public record StateErrorContext(String receiver, String receiverClass, String method, List<List<String>> possibleStates,
31+
List<List<String>> acceptedStates, boolean complete) {
32+
33+
public static StateErrorContext of(StateMachine machine, RefinedFunction call, Set<State> current,
34+
String receiver) {
35+
List<List<String>> accepted = machine.models(call) ? names(machine.statesAllowing(call)) : null;
36+
return new StateErrorContext(receiver, call.getTargetClass(), StateHints.signature(call), names(current),
37+
accepted, machine.complete());
38+
}
39+
40+
private static List<List<String>> names(Collection<State> states) {
41+
return states.stream().map(State::names).toList();
42+
}
43+
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ public class StateRefinementError extends LJError {
2222
private final Predicate expected;
2323
private final VCSimplificationResult found;
2424
private final SourcePosition declarationPosition;
25+
private final StateErrorContext context;
2526

2627
public StateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate expected,
2728
VCSimplificationResult found, TranslationTable translationTable, String customMessage, List<Hint> hints,
28-
ErrorCategory category) {
29+
ErrorCategory category, StateErrorContext context) {
2930
super("State Refinement Error",
3031
String.format("found %s but expected %s",
3132
found.getImplication().toPredicate().getExpression().toDisplayString(),
@@ -34,6 +35,7 @@ public StateRefinementError(SourcePosition position, SourcePosition declarationP
3435
this.declarationPosition = declarationPosition;
3536
this.expected = expected;
3637
this.found = found;
38+
this.context = context;
3739
hints.forEach(this::addHint);
3840
setCategory(category);
3941
if (hints.isEmpty() && isTrue(found.getImplication().toPredicate().getExpression()))
@@ -57,4 +59,9 @@ public VCImplication getFound() {
5759
public VCSimplificationResult getFoundSimplification() {
5860
return found;
5961
}
62+
63+
/** What is known about the receiver's states, or null if they could not be computed */
64+
public StateErrorContext getContext() {
65+
return context;
66+
}
6067
}

‎liquidjava-verifier/src/main/java/liquidjava/diagnostics/hints/StateHints.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ private String call(RefinedFunction method) {
149149
return receiver + "." + signature(method);
150150
}
151151

152-
private static String signature(RefinedFunction method) {
152+
/** A method as shown in hints, e.g. {@code open(int)} */
153+
public static String signature(RefinedFunction method) {
153154
return Utils.getSimpleName(method.getName()) + parameters(method);
154155
}
155156

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,13 +430,14 @@ public void throwRefinementError(SourcePosition position, SourcePosition declara
430430

431431
public void throwStateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate found,
432432
Predicate expected, String customMessage) throws LJError {
433-
throwStateRefinementError(position, declarationPosition, found, expected, customMessage, List.of(), null);
433+
throwStateRefinementError(position, declarationPosition, found, expected, customMessage, List.of(), null, null);
434434
}
435435

436436
public void throwStateRefinementError(SourcePosition position, SourcePosition declarationPosition, Predicate found,
437-
Predicate expected, String customMessage, List<Hint> hints, ErrorCategory category) throws LJError {
437+
Predicate expected, String customMessage, List<Hint> hints, ErrorCategory category,
438+
StateErrorContext context) throws LJError {
438439
vcChecker.throwStateRefinementError(position, declarationPosition, found, expected, customMessage, hints,
439-
category);
440+
category, context);
440441
}
441442

442443
public void throwStateConflictError(SourcePosition position, Predicate expectedType) throws LJError {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -438,12 +438,12 @@ protected void throwRefinementError(SourcePosition position, SourcePosition decl
438438
}
439439

440440
protected void throwStateRefinementError(SourcePosition position, SourcePosition declarationPosition,
441-
Predicate found, Predicate expected, String customMessage, List<Hint> hints, ErrorCategory category)
442-
throws StateRefinementError {
441+
Predicate found, Predicate expected, String customMessage, List<Hint> hints, ErrorCategory category,
442+
StateErrorContext context) throws StateRefinementError {
443443
TranslationTable map = new TranslationTable();
444444
VCImplication foundState = buildPremiseChain(map, expected, found);
445445
throw new StateRefinementError(position, declarationPosition, expected, foundState.simplify(), map,
446-
customMessage, hints, category);
446+
customMessage, hints, category, context);
447447
}
448448

449449
protected void throwStateConflictError(SourcePosition position, Predicate expected) throws StateConflictError {

‎liquidjava-verifier/src/main/java/liquidjava/processor/refinement_checker/object_checkers/AuxStateHandler.java‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import liquidjava.diagnostics.errors.InvalidRefinementError;
1111
import liquidjava.diagnostics.errors.LJError;
1212
import liquidjava.diagnostics.errors.SMTUnknownError;
13+
import liquidjava.diagnostics.errors.StateErrorContext;
1314
import liquidjava.diagnostics.hints.Hint;
1415
import liquidjava.diagnostics.hints.StateHints;
1516
import liquidjava.processor.context.*;
@@ -661,8 +662,11 @@ private static void changeState(TypeChecker tc, VariableInstance vi, RefinedFunc
661662
List<Hint> hints = deciding.isPresent()
662663
? receiverOf(invocation).map(r -> StateHints.decidingArgument(deciding.get(), r)).stream().toList()
663664
: states.map(r -> stateHints(r, function, category, invocation)).orElse(List.of());
665+
StateErrorContext context = states
666+
.map(r -> StateErrorContext.of(r.machine(), function, r.current(), writtenReceiver(invocation)))
667+
.orElse(null);
664668
tc.throwStateRefinementError(invocation.getPosition(), declarationPosition, prevState,
665-
expectedStatesDisjunction, message, hints, category);
669+
expectedStatesDisjunction, message, hints, category, context);
666670
}
667671
}
668672

@@ -718,13 +722,18 @@ private static Optional<String> receiverOf(CtElement invocation) {
718722
if (!(invocation instanceof CtInvocation<?> call))
719723
return Optional.empty();
720724
CtExpression<?> target = call.getTarget();
721-
if (target == null || target.isImplicit())
722-
return Optional.of(Keys.THIS);
723-
if (target instanceof CtVariableRead<?> || target instanceof CtThisAccess<?>)
724-
return Optional.of(target.toString());
725+
if (target == null || target.isImplicit() || target instanceof CtVariableRead<?>
726+
|| target instanceof CtThisAccess<?>)
727+
return Optional.of(writtenReceiver(invocation));
725728
return Optional.empty();
726729
}
727730

731+
/** The receiver of {@code invocation} as written in the code, {@code this} if it is implicit */
732+
private static String writtenReceiver(CtElement invocation) {
733+
CtExpression<?> target = invocation instanceof CtInvocation<?> call ? call.getTarget() : null;
734+
return target == null || target.isImplicit() ? Keys.THIS : target.toString();
735+
}
736+
728737
private static Predicate checkOldMentions(Predicate transitionedState, String instanceName,
729738
String newInstanceName) {
730739
return transitionedState.changeOldMentions(instanceName, newInstanceName);
Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
package liquidjava.processor.state_machine;
22

3+
import java.util.List;
4+
35
import liquidjava.rj_language.Predicate;
46

57
/**
68
* A state of a class's typestate: one state from each of its state sets
79
*
8-
* @param name
9-
* the state names, e.g. {@code open} or {@code open and locked} for a class with two state sets
10+
* @param names
11+
* the name of the state in each state set, in the order the sets are declared
1012
* @param predicate
1113
* the predicate that holds for {@code this} in this state
1214
*/
13-
public record State(String name, Predicate predicate) {
15+
public record State(List<String> names, Predicate predicate) {
16+
17+
public State {
18+
names = List.copyOf(names);
19+
}
20+
21+
/** The state names, e.g. {@code open} or {@code open and locked} for a class with two state sets */
22+
public String name() {
23+
return String.join(" and ", names);
24+
}
1425
}

‎liquidjava-verifier/src/main/java/liquidjava/processor/state_machine/StateMachineBuilder.java‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,17 @@ private static List<State> statesOf(Context context, String className) {
9090
if (stateSets.isEmpty())
9191
return List.of();
9292

93-
List<State> combinations = List.of(new State("", new Predicate()));
93+
List<State> combinations = List.of(new State(List.of(), new Predicate()));
9494
for (List<GhostState> stateSet : stateSets.values())
9595
combinations = combinations.stream().flatMap(s -> stateSet.stream().map(g -> combine(s, g))).toList();
9696
return combinations;
9797
}
9898

9999
private static State combine(State state, GhostState ghostState) {
100-
String name = state.name().isEmpty() ? ghostState.getName() : state.name() + " and " + ghostState.getName();
100+
List<String> names = new ArrayList<>(state.names());
101+
names.add(ghostState.getName());
101102
Predicate holds = Predicate.createInvocation(ghostState.getQualifiedName(), Predicate.createVar(Keys.THIS));
102-
return new State(name, Predicate.createConjunction(state.predicate(), holds));
103+
return new State(names, Predicate.createConjunction(state.predicate(), holds));
103104
}
104105

105106
private StateMachine build(String className, CtTypeReference<?> type) throws LJError {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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.Diagnostics;
11+
import liquidjava.diagnostics.errors.LJError;
12+
import liquidjava.diagnostics.errors.StateErrorContext;
13+
import liquidjava.diagnostics.errors.StateRefinementError;
14+
15+
class TestStateErrorContext {
16+
17+
private static final String TEST_SUITE = "../liquidjava-example/src/main/java/testSuite/";
18+
19+
@Test
20+
void callInTheWrongStateHasThePossibleAndAcceptedStates() {
21+
assertEquals(
22+
new StateErrorContext("f", "testSuite.ErrorStateHintCallSequence", "read()",
23+
List.of(List.of("created")), List.of(List.of("open")), true),
24+
contextOf("ErrorStateHintCallSequence.java"));
25+
}
26+
27+
@Test
28+
void statesAreNamedInEachStateSet() {
29+
assertEquals(
30+
new StateErrorContext("d", "testSuite.ErrorStateHintMultipleSets", "open()",
31+
List.of(List.of("closed", "locked")), List.of(List.of("closed", "unlocked")), true),
32+
contextOf("ErrorStateHintMultipleSets.java"));
33+
}
34+
35+
@Test
36+
void stateDependingOnThePathHasEveryPossibleState() {
37+
assertEquals(List.of(List.of("created"), List.of("open")),
38+
contextOf("ErrorStateHintPathDependent.java").possibleStates());
39+
}
40+
41+
@Test
42+
void transitionDependingOnArgumentsLeavesTheAcceptedStatesUnknown() {
43+
StateErrorContext context = contextOf("ErrorStateContextArgumentDependent.java");
44+
assertNull(context.acceptedStates());
45+
assertFalse(context.complete());
46+
}
47+
48+
@Test
49+
void classWithoutStateSetHasNoContext() {
50+
assertNull(contextOf("ErrorUnconstrainedStateRefinement.java"));
51+
}
52+
53+
private static StateErrorContext contextOf(String test) {
54+
CommandLineLauncher.launch(TEST_SUITE + test);
55+
List<LJError> errors = Diagnostics.getInstance().getErrors().stream().toList();
56+
assertEquals(1, errors.size(), "Expected exactly one error from " + test);
57+
return assertInstanceOf(StateRefinementError.class, errors.get(0)).getContext();
58+
}
59+
}

0 commit comments

Comments
 (0)