/*
* Copyright 2016 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.template.soy.error;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Ascii;
import com.google.common.collect.ImmutableSet;
import javax.annotation.Nullable;
/** Utility methods for constructing Soy error messages. */
public final class SoyErrors {
/**
* Given a collection of strings and a name that isn't contained in it. Return a message that
* suggests one of the names.
*
* <p>Returns the empty string if {@code allNames} is empty or there is no close match.
*/
public static String getDidYouMeanMessage(Iterable<String> allNames, String wrongName) {
String closestName = getClosest(allNames, wrongName);
if (closestName != null) {
return String.format(" Did you mean '%s'?", closestName);
}
return "";
}
/**
* Same as {@link #getDidYouMeanMessage(Iterable, String)} but with some additional heuristics for
* proto fields.
*/
public static String getDidYouMeanMessageForProtoFields(
ImmutableSet<String> fields, String fieldName) {
// TODO(lukes): when we have map/case enum support add more cases here.
if (fields.contains(fieldName + "List")) {
return String.format(" Did you mean '%sList'?", fieldName);
} else {
return getDidYouMeanMessage(fields, fieldName);
}
}
/**
* Returns the member of {@code allNames} that is closest to {@code wrongName}, or {@code null} if
* {@code allNames} is empty.
*
* <p>The distance metric is a case insensitive Levenshtein distance.
*
* @throws IllegalArgumentException if {@code wrongName} is a member of {@code allNames}
*/
@Nullable
@VisibleForTesting
static String getClosest(Iterable<String> allNames, String wrongName) {
// only suggest matches that are closer than this. This magic heuristic is based on what llvm
// and javac do
int shortest = (wrongName.length() + 2) / 3 + 1;
String closestName = null;
for (String otherName : allNames) {
if (otherName.equals(wrongName)) {
throw new IllegalArgumentException("'" + wrongName + "' is contained in " + allNames);
}
int distance = distance(otherName, wrongName, shortest);
if (distance < shortest) {
shortest = distance;
closestName = otherName;
if (distance == 0) {
return closestName;
}
}
}
return closestName;
}
/**
* Performs a case insensitive Levenshtein edit distance based on the 2 rows implementation.
*
* @param s The first string
* @param t The second string
* @param maxDistance The distance to beat, if we can't do better, stop trying
* @return an integer describing the number of edits needed to transform s into t
* @see "https://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows"
*/
private static int distance(String s, String t, int maxDistance) {
// create two work vectors of integer distances
// it is possible to reduce this to only one array, but performance isn't that important here.
// We could also avoid calculating a lot of the entries by taking maxDistance into account in
// the inner loop. This would only be worth optimizing if it showed up in a profile.
int[] v0 = new int[t.length() + 1];
int[] v1 = new int[t.length() + 1];
// initialize v0 (the previous row of distances)
// this row is A[0][i]: edit distance for an empty s
// the distance is just the number of characters to delete from t
for (int i = 0; i < v0.length; i++) {
v0[i] = i;
}
for (int i = 0; i < s.length(); i++) {
// calculate v1 (current row distances) from the previous row v0
// first element of v1 is A[i+1][0]
// edit distance is delete (i+1) chars from s to match empty t
v1[0] = i + 1;
int bestThisRow = v1[0];
char sChar = Ascii.toLowerCase(s.charAt(i));
// use formula to fill in the rest of the row
for (int j = 0; j < t.length(); j++) {
char tChar = Ascii.toLowerCase(t.charAt(j));
v1[j + 1] =
Math.min(
v1[j] + 1, // deletion
Math.min(
v0[j + 1] + 1, // insertion
v0[j] + ((sChar == tChar) ? 0 : 1))); // substitution
bestThisRow = Math.min(bestThisRow, v1[j + 1]);
}
if (bestThisRow > maxDistance) {
// if we couldn't possibly do better than maxDistance, stop trying.
return maxDistance + 1;
}
// swap v1 (current row) to v0 (previous row) for next iteration. no need to clear previous
// row since we always update all of v1 on each iteration.
int[] tmp = v0;
v0 = v1;
v1 = tmp;
}
// The best answer is the last slot in v0 (due to the swap on the last iteration)
return v0[t.length()];
}
}