Skip to content

Commit b4dcb1c

Browse files
authored
Add Similar Name Hint to Not Found Error (#289)
1 parent 1d7a79a commit b4dcb1c

8 files changed

Lines changed: 198 additions & 45 deletions

File tree

liquidjava-verifier/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@
256256
<!-- versions -->
257257
<version.junit>5.10.0</version.junit>
258258
<version.memcompiler>1.3.0</version.memcompiler>
259+
<version.commons-text>1.12.0</version.commons-text>
259260
<version.spoon>10.4.2</version.spoon>
260261
<version.z3>4.8.17</version.z3>
261262
<!-- plugin versions -->
@@ -280,6 +281,11 @@
280281
</repository>
281282
</repositories>
282283
<dependencies>
284+
<dependency>
285+
<groupId>org.apache.commons</groupId>
286+
<artifactId>commons-text</artifactId>
287+
<version>${version.commons-text}</version>
288+
</dependency>
283289
<!-- Only needed to run tests in a version of IntelliJ IDEA that bundles older versions -->
284290
<dependency>
285291
<groupId>org.junit.platform</groupId>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package liquidjava.diagnostics;
2+
3+
import java.util.Collection;
4+
import java.util.Comparator;
5+
import java.util.Locale;
6+
import java.util.Optional;
7+
8+
import org.apache.commons.text.similarity.JaroWinklerSimilarity;
9+
10+
import liquidjava.utils.Utils;
11+
12+
/**
13+
* Finds the closest matching name among the elements available in the current context using Jaro-Winkler similarity
14+
*/
15+
public final class NameSuggester {
16+
17+
private static final int MINIMUM_NAME_LENGTH = 3;
18+
private static final double MINIMUM_SIMILARITY = 0.9;
19+
private static final JaroWinklerSimilarity SIMILARITY = new JaroWinklerSimilarity();
20+
21+
private NameSuggester() {
22+
}
23+
24+
public static Optional<String> findClosest(String name, Collection<String> candidates) {
25+
if (name == null || candidates == null || candidates.isEmpty())
26+
return Optional.empty();
27+
28+
String sourceName = getSourceName(name);
29+
if (sourceName.length() < MINIMUM_NAME_LENGTH)
30+
return Optional.empty(); // do not provide suggestions for very short names
31+
32+
String normalizedSourceName = sourceName.toLowerCase(Locale.ROOT);
33+
return candidates.stream().filter(candidate -> candidate != null).map(NameSuggester::getSourceName).distinct()
34+
.filter(candidate -> candidate.length() >= MINIMUM_NAME_LENGTH && !candidate.equals(sourceName))
35+
.map(candidate -> new Match(candidate,
36+
SIMILARITY.apply(normalizedSourceName, candidate.toLowerCase(Locale.ROOT))))
37+
.filter(match -> match.similarity() >= MINIMUM_SIMILARITY)
38+
.max(Comparator.comparingDouble(Match::similarity).thenComparing(Match::name,
39+
String.CASE_INSENSITIVE_ORDER.reversed()))
40+
.map(Match::name);
41+
}
42+
43+
private static String getSourceName(String name) {
44+
String simpleName = Utils.getSimpleName(name);
45+
if (simpleName.startsWith("this#"))
46+
return simpleName.substring("this#".length());
47+
if (simpleName.startsWith("#"))
48+
return simpleName.substring(1).replaceFirst("_\\d+$", "");
49+
return simpleName;
50+
}
51+
52+
private record Match(String name, double similarity) {
53+
}
54+
}
Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package liquidjava.diagnostics.errors;
22

3+
import java.util.Collection;
4+
import java.util.Locale;
5+
6+
import liquidjava.diagnostics.NameSuggester;
37
import liquidjava.diagnostics.TranslationTable;
48
import liquidjava.utils.Utils;
59
import spoon.reflect.cu.SourcePosition;
@@ -12,27 +16,40 @@
1216
public class NotFoundError extends LJError {
1317

1418
private final String name;
15-
private final String kind; // "Variable" | "Ghost" | "Alias"
19+
private final Kind kind;
1620

17-
public NotFoundError(String name, String kind) {
18-
this(null, name, kind, null);
21+
public NotFoundError(String name, Kind kind, Collection<String> availableElements) {
22+
this(null, name, kind, null, availableElements);
1923
}
2024

21-
public NotFoundError(SourcePosition position, String name, String kind) {
22-
this(position, name, kind, null);
25+
public NotFoundError(SourcePosition position, String name, Kind kind, Collection<String> availableElements) {
26+
this(position, name, kind, null, availableElements);
2327
}
2428

25-
public NotFoundError(SourcePosition position, String name, String kind, TranslationTable translationTable) {
26-
super("Not Found Error", String.format("%s '%s' not found", kind, name), position, translationTable);
29+
public NotFoundError(SourcePosition position, String name, Kind kind, TranslationTable translationTable,
30+
Collection<String> availableElements) {
31+
super("Not Found Error", String.format("%s '%s' could not be found", kind, name), position, translationTable);
2732
this.name = Utils.getSimpleName(name);
2833
this.kind = kind;
34+
NameSuggester.findClosest(name, availableElements)
35+
.ifPresent(match -> setHint(String.format("Did you mean '%s'?", match)));
2936
}
3037

3138
public String getName() {
3239
return name;
3340
}
3441

35-
public String getKind() {
42+
public Kind getKind() {
3643
return kind;
3744
}
45+
46+
public enum Kind {
47+
VARIABLE, GHOST, ALIAS, CONSTANT;
48+
49+
@Override
50+
public String toString() {
51+
String name = name().toLowerCase(Locale.ROOT);
52+
return Character.toUpperCase(name.charAt(0)) + name.substring(1);
53+
}
54+
}
3855
}

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,9 @@ private static ObjectState getStates(CtAnnotation<? extends Annotation> ctAnnota
206206
*/
207207
private static Predicate createStatePredicate(String value, String targetClass, TypeChecker tc, CtElement e,
208208
boolean isTo, String prefix) throws LJError {
209+
SourcePosition position = Utils.getLJAnnotationPosition(e, value);
209210
Predicate p = new Predicate(value, e, prefix);
210211
if (!p.getExpression().isBooleanExpression()) {
211-
SourcePosition position = Utils.getLJAnnotationPosition(e, value);
212212
throw new InvalidRefinementError(position, "State refinement transition must be a boolean expression",
213213
value);
214214
}
@@ -233,11 +233,9 @@ private static Predicate createStatePredicate(String value, String targetClass,
233233
Predicate c1 = isTo ? getMissingStates(targetClass, tc, p) : p;
234234
Predicate c = c1.substituteVariable(Keys.THIS, name);
235235
c = c.changeOldMentions(nameOld, name);
236-
boolean ok = tc.checkStateSMT(new Predicate(), c.negate(), e.getPosition(), true);
237-
if (ok) {
238-
SourcePosition pos = Utils.getLJAnnotationPosition(e, value);
239-
tc.throwStateConflictError(pos, p);
240-
}
236+
boolean ok = tc.checkStateSMT(new Predicate(), c.negate(), position, true);
237+
if (ok)
238+
tc.throwStateConflictError(position, p);
241239
return c1;
242240
}
243241

liquidjava-verifier/src/main/java/liquidjava/rj_language/Predicate.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import liquidjava.diagnostics.errors.LJError;
1010
import liquidjava.diagnostics.errors.NotFoundError;
11+
import liquidjava.diagnostics.errors.NotFoundError.Kind;
1112
import liquidjava.processor.context.AliasWrapper;
1213
import liquidjava.processor.context.Context;
1314
import liquidjava.processor.context.GhostFunction;
@@ -100,7 +101,7 @@ private static Expression resolveStaticFinalConstants(Expression root, CtElement
100101
String hint = suggested != null ? "Add: import " + suggested + ";"
101102
: "Add an import for '" + en.getTypeName() + "' if it is a Java class with a static final field";
102103
String name = en.getTypeName() + "." + en.getConstName();
103-
NotFoundError error = new NotFoundError(pos, name, "Constant");
104+
NotFoundError error = new NotFoundError(pos, name, Kind.CONSTANT, List.of());
104105
error.setHint(hint);
105106
throw error;
106107
}

liquidjava-verifier/src/main/java/liquidjava/rj_language/ast/Expression.java

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
import liquidjava.diagnostics.errors.ArgumentMismatchError;
99
import liquidjava.diagnostics.errors.LJError;
1010
import liquidjava.diagnostics.errors.NotFoundError;
11+
import liquidjava.diagnostics.errors.NotFoundError.Kind;
1112
import liquidjava.processor.context.Context;
1213
import liquidjava.processor.context.GhostFunction;
1314
import liquidjava.processor.facade.AliasDTO;
1415
import liquidjava.rj_language.ast.formatter.ExpressionFormatter;
1516
import liquidjava.rj_language.ast.typing.TypeInfer;
1617
import liquidjava.rj_language.visitors.ExpressionVisitor;
1718
import liquidjava.utils.Utils;
18-
import liquidjava.utils.constants.Keys;
1919
import spoon.reflect.factory.Factory;
2020
import spoon.reflect.reference.CtTypeReference;
2121

@@ -199,34 +199,33 @@ private void auxSubstituteState(Map<String, Expression> subMap, String[] toChang
199199
public Expression changeAlias(Map<String, AliasDTO> alias, Context ctx, Factory f) throws LJError {
200200
Expression e = clone();
201201
if (this instanceof AliasInvocation ai) {
202-
if (alias.containsKey(ai.name)) { // object state
203-
AliasDTO dto = alias.get(ai.name);
204-
// check argument count
205-
if (children.size() != dto.getVarNames().size()) {
202+
if (!alias.containsKey(ai.name))
203+
throw new NotFoundError(ai.getName(), Kind.ALIAS, alias.keySet());
204+
AliasDTO dto = alias.get(ai.name);
205+
// check argument count
206+
if (children.size() != dto.getVarNames().size()) {
207+
String msg = String.format("Wrong number of arguments in alias invocation '%s': expected %d, got %d",
208+
ai.name, dto.getVarNames().size(), children.size());
209+
throw new ArgumentMismatchError(msg);
210+
}
211+
Expression sub = dto.getExpression().clone();
212+
for (int i = 0; i < children.size(); i++) {
213+
Expression varExp = new Var(dto.getVarNames().get(i));
214+
String varType = dto.getVarTypes().get(i);
215+
Expression aliasExp = children.get(i);
216+
217+
// check argument types
218+
boolean compatible = TypeInfer.checkCompatibleType(varType, aliasExp, ctx, f);
219+
if (!compatible) {
206220
String msg = String.format(
207-
"Wrong number of arguments in alias invocation '%s': expected %d, got %d", ai.name,
208-
dto.getVarNames().size(), children.size());
221+
"Argument '%s' and parameter '%s' of alias '%s' types are incompatible: expected %s, got %s",
222+
aliasExp, dto.getVarNames().get(i), ai.name, varType,
223+
TypeInfer.getType(ctx, f, aliasExp).get().getQualifiedName());
209224
throw new ArgumentMismatchError(msg);
210225
}
211-
Expression sub = dto.getExpression().clone();
212-
for (int i = 0; i < children.size(); i++) {
213-
Expression varExp = new Var(dto.getVarNames().get(i));
214-
String varType = dto.getVarTypes().get(i);
215-
Expression aliasExp = children.get(i);
216-
217-
// check argument types
218-
boolean compatible = TypeInfer.checkCompatibleType(varType, aliasExp, ctx, f);
219-
if (!compatible) {
220-
String msg = String.format(
221-
"Argument '%s' and parameter '%s' of alias '%s' types are incompatible: expected %s, got %s",
222-
aliasExp, dto.getVarNames().get(i), ai.name, varType,
223-
TypeInfer.getType(ctx, f, aliasExp).get().getQualifiedName());
224-
throw new ArgumentMismatchError(msg);
225-
}
226-
sub = sub.substitute(varExp, aliasExp);
227-
}
228-
e = sub;
226+
sub = sub.substitute(varExp, aliasExp);
229227
}
228+
e = sub;
230229
}
231230
e.auxChangeAlias(alias, ctx, f);
232231
return e;
@@ -237,7 +236,7 @@ private void auxChangeAlias(Map<String, AliasDTO> alias, Context ctx, Factory f)
237236
for (int i = 0; i < children.size(); i++) {
238237
if (children.get(i)instanceof AliasInvocation ai) {
239238
if (!alias.containsKey(ai.name))
240-
throw new NotFoundError(ai.getName(), Keys.ALIAS);
239+
throw new NotFoundError(ai.getName(), Kind.ALIAS, alias.keySet());
241240
AliasDTO dto = alias.get(ai.name);
242241
// check argument count
243242
if (ai.children.size() != dto.getVarNames().size()) {

liquidjava-verifier/src/main/java/liquidjava/smt/TranslatorToZ3.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323

2424
import liquidjava.diagnostics.errors.LJError;
2525
import liquidjava.diagnostics.errors.NotFoundError;
26+
import liquidjava.diagnostics.errors.NotFoundError.Kind;
2627
import liquidjava.processor.context.AliasWrapper;
2728
import liquidjava.utils.Pair;
2829
import liquidjava.utils.Utils;
2930
import liquidjava.utils.constants.Formats;
30-
import liquidjava.utils.constants.Keys;
3131
import com.microsoft.z3.enumerations.Z3_sort_kind;
3232

3333
import org.apache.commons.lang3.NotImplementedException;
@@ -120,12 +120,12 @@ public Expr<?> makeBooleanLiteral(boolean value) {
120120

121121
private Expr<?> getVariableTranslation(String name) throws LJError {
122122
if (!varTranslation.containsKey(name))
123-
throw new NotFoundError(name, Keys.VARIABLE);
123+
throw new NotFoundError(name, Kind.VARIABLE, varTranslation.keySet());
124124
Expr<?> e = varTranslation.get(name);
125125
if (e == null)
126126
e = varTranslation.get(String.format("this#%s", name));
127127
if (e == null)
128-
throw new NotFoundError(name, Keys.VARIABLE);
128+
throw new NotFoundError(name, Kind.VARIABLE, varTranslation.keySet());
129129
return e;
130130
}
131131

@@ -213,7 +213,7 @@ private FuncDecl<?> resolveFunctionDecl(String name, Expr<?>[] params) throws LJ
213213
if (candidate != null) {
214214
return candidate;
215215
}
216-
throw new NotFoundError(name, Keys.GHOST);
216+
throw new NotFoundError(name, Kind.GHOST, funcTranslation.keySet());
217217
}
218218

219219
@SuppressWarnings({ "unchecked", "rawtypes" })
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package liquidjava.api.tests;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertTrue;
5+
6+
import java.util.Arrays;
7+
import java.util.List;
8+
9+
import org.junit.jupiter.api.Test;
10+
11+
import liquidjava.diagnostics.NameSuggester;
12+
13+
class TestNameSuggester {
14+
15+
@Test
16+
void findsClosestName() {
17+
assertEquals("amount", NameSuggester.findClosest("ammount", List.of("total", "amount")).orElseThrow());
18+
}
19+
20+
@Test
21+
void recognizesTransposedCharacters() {
22+
assertEquals("length", NameSuggester.findClosest("lenght", List.of("length")).orElseThrow());
23+
}
24+
25+
@Test
26+
void normalizesQualifiedAndGeneratedNames() {
27+
assertEquals("length", NameSuggester.findClosest("Example.lenght", List.of("Example.length")).orElseThrow());
28+
assertEquals("amount", NameSuggester.findClosest("ammount", List.of("#amount_12")).orElseThrow());
29+
assertEquals("value", NameSuggester.findClosest("valuee", List.of("this#value")).orElseThrow());
30+
}
31+
32+
@Test
33+
void doesNotSuggestUnrelatedOrVeryShortNames() {
34+
assertTrue(NameSuggester.findClosest("counter", List.of("result", "value")).isEmpty());
35+
assertTrue(NameSuggester.findClosest("x", List.of("y")).isEmpty());
36+
}
37+
38+
@Test
39+
void handlesMissingAndEmptyInputs() {
40+
assertTrue(NameSuggester.findClosest(null, List.of("value")).isEmpty());
41+
assertTrue(NameSuggester.findClosest("value", null).isEmpty());
42+
assertTrue(NameSuggester.findClosest("value", List.of()).isEmpty());
43+
assertTrue(NameSuggester.findClosest("", List.of("value")).isEmpty());
44+
}
45+
46+
@Test
47+
void ignoresNullBlankAndExactCandidates() {
48+
assertEquals("amount", NameSuggester.findClosest("ammount", Arrays.asList(null, "", "amount")).orElseThrow());
49+
assertTrue(NameSuggester.findClosest("amount", List.of("amount")).isEmpty());
50+
assertTrue(NameSuggester.findClosest("amount", List.of("Example.amount", "#amount_1")).isEmpty());
51+
}
52+
53+
@Test
54+
void preservesCandidateCapitalization() {
55+
assertEquals("Amount", NameSuggester.findClosest("amount", List.of("Amount")).orElseThrow());
56+
}
57+
58+
@Test
59+
void supportsNamesAtMinimumLength() {
60+
assertEquals("size", NameSuggester.findClosest("siz", List.of("size")).orElseThrow());
61+
}
62+
63+
@Test
64+
void rejectsNamesJustBelowSimilarityThreshold() {
65+
assertTrue(NameSuggester.findClosest("abc", List.of("abd")).isEmpty());
66+
}
67+
68+
@Test
69+
void prefersTheMostSimilarCandidate() {
70+
assertEquals("availableValue",
71+
NameSuggester.findClosest("availableValu", List.of("availableValues", "availableValue")).orElseThrow());
72+
}
73+
74+
@Test
75+
void breaksEquivalentMatchesAlphabetically() {
76+
assertEquals("foobart", NameSuggester.findClosest("foobaru", List.of("foobarv", "foobart")).orElseThrow());
77+
}
78+
}

0 commit comments

Comments
 (0)