blob: c6698ef1437005b8f0dfa7576a1ff2d394eaf4a9 [file] [log] [blame]
// Copyright 2023 The Bazel Authors. All rights reserved.
//
// 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.devtools.build.lib.packages;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Iterables;
import java.util.Locale;
import java.util.Set;
import net.starlark.java.spelling.SpellChecker;
/**
* Some static utility functions for determining suggested targets when a user requests a
* non-existent target.
*/
public final class TargetSuggester {
private static final int MAX_SUGGESTED_TARGETS_SIZE = 10;
private static final int MAX_SUGGESTION_EDIT_DISTANCE = 5;
private TargetSuggester() {}
/**
* Given a nonexistent target and the targets in its package, suggest what the user may have
* intended based on lexicographic closeness to the possibilities.
*
* <p>This will be pretty printed in the following form No suggested targets -> "". Suggested
* target "a" -> "a". Suggested targets "a", "b", "c" -> "a, b, or c".
*/
static String suggestTargets(String input, Set<String> words) {
ImmutableList<String> suggestedTargets = suggestedTargets(input, words);
return prettyPrintTargets(suggestedTargets);
}
/**
* Given a requested target and a Set of targets in the same package, return a list of strings
* closest based on edit distance.
*
* <p>If any strings are identical minus capitalization changes, they will be returned. If any
* other strings are exactly 1 character off, they will be returned. Otherwise, the 10 nearest
* (within a small edit distance) will be returned.
*/
@VisibleForTesting
static ImmutableList<String> suggestedTargets(String input, Set<String> words) {
final String lowerCaseInput = input.toLowerCase(Locale.US);
// Add words based on edit distance
ImmutableListMultimap.Builder<Integer, String> editDistancesBuilder =
ImmutableListMultimap.builder();
int maxEditDistance = Math.min(MAX_SUGGESTION_EDIT_DISTANCE, (input.length() + 1) / 2);
for (String word : words) {
String lowerCaseWord = word.toLowerCase(Locale.US);
int editDistance = SpellChecker.editDistance(lowerCaseInput, lowerCaseWord, maxEditDistance);
if (editDistance >= 0) {
editDistancesBuilder.put(editDistance, word);
}
}
ImmutableListMultimap<Integer, String> editDistanceToWords = editDistancesBuilder.build();
ImmutableList<String> zeroEditDistanceWords = editDistanceToWords.get(0);
ImmutableList<String> oneEditDistanceWords = editDistanceToWords.get(1);
if (editDistanceToWords.isEmpty()) {
return ImmutableList.of();
} else if (!zeroEditDistanceWords.isEmpty()) {
int sublistLength = Math.min(zeroEditDistanceWords.size(), MAX_SUGGESTED_TARGETS_SIZE);
return ImmutableList.copyOf(zeroEditDistanceWords.subList(0, sublistLength));
} else if (!oneEditDistanceWords.isEmpty()) {
int sublistLength = Math.min(oneEditDistanceWords.size(), MAX_SUGGESTED_TARGETS_SIZE);
return ImmutableList.copyOf(oneEditDistanceWords.subList(0, sublistLength));
} else {
return getSuggestedTargets(editDistanceToWords, maxEditDistance);
}
}
/**
* Given a map of edit distance values to words that are that distance from the requested target,
* returns up to MAX_SUGGESTED_TARGETS_SIZE targets that are at least edit distance 2 but no more
* than the given max away.
*/
private static ImmutableList<String> getSuggestedTargets(
ImmutableListMultimap<Integer, String> editDistanceToWords, int maxEditDistance) {
// iterate through until MAX is achieved
int total = 0;
ImmutableList.Builder<String> suggestedTargets = ImmutableList.builder();
for (int editDistance = 2;
editDistance < maxEditDistance && total < MAX_SUGGESTED_TARGETS_SIZE;
editDistance++) {
ImmutableList<String> values = editDistanceToWords.get(editDistance);
int addAmount = Math.min(values.size(), MAX_SUGGESTED_TARGETS_SIZE - total);
suggestedTargets.addAll(values.subList(0, addAmount));
total += addAmount;
}
return suggestedTargets.build();
}
/**
* Create a pretty-printable String for a list. Joiner doesn't currently support multiple
* separators so this is a custom roll for now. Returns a comma-delimited list with " or " before
* the last element.
*/
@VisibleForTesting
public static String prettyPrintTargets(ImmutableList<String> targets) {
String targetString;
if (targets.isEmpty()) {
return "";
} else if (targets.size() == 1) {
targetString = targets.get(0);
} else {
String firstPart = Joiner.on(", ").join(targets.subList(0, targets.size() - 1));
targetString = Joiner.on(", or ").join(firstPart, Iterables.getLast(targets));
}
return " (did you mean " + targetString + "?)";
}
}