blob: c96e79b23cd40f39f20b1087113f4463ea4623b9 [file] [log] [blame]
/*
* Copyright 2000-2012 JetBrains s.r.o.
*
* 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.intellij.psi.codeStyle;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.util.containers.FList;
import com.intellij.util.io.IOUtil;
import com.intellij.util.text.Matcher;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.BitSet;
import java.util.Iterator;
/**
* @author peter
*/
public class MinusculeMatcher implements Matcher {
/**
* Lowercase humps don't work for parts separated by these characters
* Need either an explicit uppercase letter or the same separator character in prefix
*/
private static final String HARD_SEPARATORS = " ()";
private ThreadLocal<MatchingState> myMatchingState = new ThreadLocal<MatchingState>() {
@Override
protected MatchingState initialValue() {
return new MatchingState();
}
};
private final char[] myPattern;
private final NameUtil.MatchingCaseSensitivity myOptions;
private final boolean myHasHumps;
private final boolean myHasSeparators;
private final boolean myHasDots;
private final boolean[] isLowerCase;
private final boolean[] isUpperCase;
private final boolean[] isWordSeparator;
private final char[] toUpperCase;
private final char[] toLowerCase;
private final boolean myHasWildCards;
public MinusculeMatcher(@NotNull String pattern, @NotNull NameUtil.MatchingCaseSensitivity options) {
myOptions = options;
myPattern = StringUtil.trimEnd(pattern, "* ").toCharArray();
isLowerCase = new boolean[myPattern.length];
isUpperCase = new boolean[myPattern.length];
isWordSeparator = new boolean[myPattern.length];
toUpperCase = new char[myPattern.length];
toLowerCase = new char[myPattern.length];
for (int k = 0; k < myPattern.length; k++) {
char c = myPattern[k];
isLowerCase[k] = Character.isLowerCase(c);
isUpperCase[k] = Character.isUpperCase(c);
isWordSeparator[k] = isWordSeparator(c);
toUpperCase[k] = StringUtil.toUpperCase(c);
toLowerCase[k] = StringUtil.toLowerCase(c);
}
int i = 0;
while (isWildcard(i)) i++;
myHasHumps = hasFlag(i + 1, isUpperCase) && hasFlag(i, isLowerCase);
myHasSeparators = hasFlag(i, isWordSeparator);
myHasDots = hasDots(i);
myHasWildCards = hasWildCards();
}
private static boolean isWordSeparator(char c) {
return Character.isWhitespace(c) || c == '_' || c == '-' || c == ':' || c == '+';
}
private static boolean isWordStart(String text, int i) {
char c = text.charAt(i);
if (Character.isUpperCase(c)) {
if (i > 0 && Character.isUpperCase(text.charAt(i - 1))) {
// check that we're not in the middle of an all-caps word
return i + 1 < text.length() && Character.isLowerCase(text.charAt(i + 1));
}
return true;
}
if (Character.isDigit(c)) {
return true;
}
if (!Character.isLetter(c)) {
return false;
}
return i == 0 || !Character.isLetterOrDigit(text.charAt(i - 1));
}
private boolean hasWildCards() {
for (int i = 0; i < myPattern.length; i++) {
if (isWildcard(i)) {
return true;
}
}
return false;
}
private boolean hasFlag(int start, boolean[] flags) {
for (int i = start; i < myPattern.length; i++) {
if (flags[i]) {
return true;
}
}
return false;
}
private boolean hasDots(int start) {
for (int i = start; i < myPattern.length; i++) {
if (myPattern[i] == '.') {
return true;
}
}
return false;
}
private static FList<TextRange> prependRange(@NotNull FList<TextRange> ranges, int from, int length) {
TextRange head = ranges.getHead();
if (head != null && head.getStartOffset() == from + length) {
return ranges.getTail().prepend(new TextRange(from, head.getEndOffset()));
}
return ranges.prepend(TextRange.from(from, length));
}
public int matchingDegree(@NotNull String name) {
FList<TextRange> iterable = matchingFragments(name);
if (iterable == null) return Integer.MIN_VALUE;
if (iterable.isEmpty()) return 0;
final TextRange first = iterable.getHead();
boolean startMatch = first.getStartOffset() == 0;
int matchingCase = 0;
int p = -1;
int integral = 0; // -sum of matching-char-count * hump-index over all matched humps; favors longer fragments matching earlier words
int humpIndex = 1;
int nextHumpStart = 0;
for (TextRange range : iterable) {
for (int i = range.getStartOffset(); i < range.getEndOffset(); i++) {
boolean isHumpStart = false;
while (nextHumpStart <= i) {
if (nextHumpStart == i) {
isHumpStart = true;
}
nextHumpStart = NameUtil.nextWord(name, nextHumpStart);
if (first != range) {
humpIndex++;
}
}
integral -= humpIndex;
char c = name.charAt(i);
p = StringUtil.indexOf(myPattern, c, p + 1, myPattern.length, false);
if (p < 0) {
break;
}
if (c == myPattern[p]) {
if (isUpperCase[p]) matchingCase += 50; // strongly prefer user's uppercase matching uppercase: they made an effort to press Shift
else if (i == 0 && startMatch) matchingCase += 15; // the very first letter case distinguishes classes in Java etc
else if (isHumpStart) matchingCase += 1; // if a lowercase matches lowercase hump start, that also means something
} else if (isHumpStart) {
// disfavor hump starts where pattern letter case doesn't match name case
matchingCase -= 20;
}
}
}
int startIndex = first.getStartOffset();
boolean afterSeparator = StringUtil.indexOfAny(name, HARD_SEPARATORS, 0, startIndex) >= 0;
boolean wordStart = startIndex == 0 || isWordStart(name, startIndex) && !isWordStart(name, startIndex - 1);
boolean finalMatch = iterable.get(iterable.size() - 1).getEndOffset() == name.length();
return (wordStart ? 1000 : 0) +
integral * 10 +
matchingCase * (startMatch ? 10 : 1) + // in start matches, case is more important; in middle matches - fragment length (integral)
(afterSeparator ? 0 : 2) +
(finalMatch ? 1 : 0);
}
public boolean isStartMatch(@NotNull String name) {
Iterable<TextRange> fragments = matchingFragments(name);
if (fragments != null) {
Iterator<TextRange> iterator = fragments.iterator();
if (!iterator.hasNext() || iterator.next().getStartOffset() == 0) {
return true;
}
}
return false;
}
@Override
public boolean matches(@NotNull String name) {
// optimisation: name too short for this pattern
if (!myHasWildCards && name.length() < myPattern.length) return false;
return matchingFragments(name) != null;
}
@Nullable
public FList<TextRange> matchingFragments(@NotNull String name) {
MatchingState state = myMatchingState.get();
state.initializeState(name);
try {
return matchWildcards(name, 0, 0, state);
}
finally {
state.releaseState();
}
}
/**
* After a wildcard (* or space), search for the first non-wildcard pattern character in the name starting from nameIndex
* and try to {@link #matchFragment(String, int, int, com.intellij.psi.codeStyle.MinusculeMatcher.MatchingState)} for it.
*/
@Nullable
private FList<TextRange> matchWildcards(@NotNull String name,
int patternIndex,
int nameIndex,
MatchingState matchingState) {
if (nameIndex < 0) {
return null;
}
if (!isWildcard(patternIndex)) {
if (patternIndex == myPattern.length) {
return FList.emptyList();
}
return matchFragment(name, patternIndex, nameIndex, matchingState);
}
do {
patternIndex++;
} while (isWildcard(patternIndex));
if (patternIndex == myPattern.length) {
boolean space = isPatternChar(patternIndex - 1, ' ');
// the trailing space should match if the pattern ends with the last word part, or only its first hump character
if (space && nameIndex != name.length() && (patternIndex < 2 || !NameUtil.isWordStart(myPattern[patternIndex - 2]))) {
int spaceIndex = name.indexOf(' ', nameIndex);
if (spaceIndex >= 0) {
return FList.<TextRange>emptyList().prepend(TextRange.from(spaceIndex, 1));
}
return null;
}
return FList.emptyList();
}
FList<TextRange> ranges = matchFragment(name, patternIndex, nameIndex, matchingState);
if (ranges != null) {
return ranges;
}
return matchSkippingWords(name, patternIndex, nameIndex, true, matchingState);
}
/**
* Enumerates places in name that could be matched by the pattern at patternIndex position
* and invokes {@link #matchFragment(String, int, int, com.intellij.psi.codeStyle.MinusculeMatcher.MatchingState)} at those candidate positions
*/
@Nullable
private FList<TextRange> matchSkippingWords(@NotNull String name,
final int patternIndex,
int nameIndex,
boolean allowSpecialChars,
MatchingState matchingState) {
boolean star = isPatternChar(patternIndex - 1, '*');
final char p = myPattern[patternIndex];
while (true) {
int nextOccurrence = star ?
indexOfIgnoreCase(name, nameIndex + 1, p, patternIndex, matchingState.isAsciiName) :
indexOfWordStart(name, patternIndex, nameIndex);
if (nextOccurrence < 0) {
return null;
}
// pattern humps are allowed to match in words separated by " ()", lowercase characters aren't
if (!allowSpecialChars && !myHasSeparators && !myHasHumps && StringUtil.containsAnyChar(name, HARD_SEPARATORS, nameIndex, nextOccurrence)) {
return null;
}
// if the user has typed a dot, don't skip other dots between humps
if (!allowSpecialChars && myHasDots && StringUtil.contains(name, nameIndex, nextOccurrence, '.')) {
return null;
}
// uppercase should match either uppercase or a word start
if (!isUpperCase[patternIndex] ||
Character.isUpperCase(name.charAt(nextOccurrence)) ||
isWordStart(name, nextOccurrence) ||
// accept uppercase matching lowercase if the whole prefix is uppercase and case sensitivity allows that
!myHasHumps && myOptions != NameUtil.MatchingCaseSensitivity.ALL) {
FList<TextRange> ranges = matchFragment(name, patternIndex, nextOccurrence, matchingState);
if (ranges != null) {
return ranges;
}
}
nameIndex = nextOccurrence;
}
}
private boolean charEquals(char patternChar, int patternIndex, char c, boolean isIgnoreCase) {
return patternChar == c ||
isIgnoreCase && (toLowerCase[patternIndex] == c || toUpperCase[patternIndex] == c);
}
@Nullable
private FList<TextRange> matchFragment(@NotNull String name,
int patternIndex,
int nameIndex,
MatchingState matchingState) {
if (matchingState.hasFailed(patternIndex, nameIndex)) {
return null;
}
FList<TextRange> result = doMatchFragments(name, patternIndex, nameIndex, matchingState);
if (result == null) {
matchingState.registerFailure(patternIndex, nameIndex);
}
return result;
}
/**
* Attempts to match an alphanumeric sequence of pattern (starting at patternIndex)
* to some continuous substring of name, starting from nameIndex.
*/
private FList<TextRange> doMatchFragments(String name,
int patternIndex,
int nameIndex,
MatchingState matchingState) {
if (!isFirstCharMatching(name, nameIndex, patternIndex)) {
return null;
}
// middle matches have to be at least of length 3, to prevent too many irrelevant matches
int minFragment = isPatternChar(patternIndex - 1, '*') && !isWildcard(patternIndex + 1) &&
Character.isLetterOrDigit(name.charAt(nameIndex)) && !isWordStart(name, nameIndex)
? 3 : 1;
int i = 1;
boolean ignoreCase = myOptions != NameUtil.MatchingCaseSensitivity.ALL;
while (nameIndex + i < name.length() &&
patternIndex + i < myPattern.length &&
charEquals(myPattern[patternIndex+i], patternIndex+i, name.charAt(nameIndex + i), ignoreCase)) {
if (isUpperCase[patternIndex + i] && myHasHumps) {
if (i < minFragment) {
return null;
}
// when an uppercase pattern letter matches lowercase name letter, try to find an uppercase (better) match further in the name
if (myPattern[patternIndex + i] != name.charAt(nameIndex + i)) {
int nextWordStart = indexOfWordStart(name, patternIndex + i, nameIndex + i);
FList<TextRange> ranges = matchWildcards(name, patternIndex + i, nextWordStart, matchingState);
if (ranges != null) {
return prependRange(ranges, nameIndex, i);
}
// at least three consecutive uppercase letters shouldn't match lowercase
if (myHasHumps && i > 1 && isUpperCase[patternIndex + i - 1] && isUpperCase[patternIndex + i - 2]) {
// but if there's a lowercase after them, it can match (in case shift was released a bit later)
if (nameIndex + i + 1 == name.length() ||
patternIndex + i + 1 < myPattern.length && !isLowerCase[patternIndex + i + 1]) {
return null;
}
}
}
}
i++;
}
// we've found the longest fragment matching pattern and name
if (patternIndex + i >= myPattern.length) {
return FList.<TextRange>emptyList().prepend(TextRange.from(nameIndex, i));
}
// try to match the remainder of pattern with the remainder of name
// it may not succeed with the longest matching fragment, then try shorter matches
while (i >= minFragment || isWildcard(patternIndex + i)) {
FList<TextRange> ranges = isWildcard(patternIndex + i) ?
matchWildcards(name, patternIndex + i, nameIndex + i, matchingState) :
matchSkippingWords(name, patternIndex + i, nameIndex + i, false, matchingState);
if (ranges != null) {
return prependRange(ranges, nameIndex, i);
}
i--;
}
return null;
}
private boolean isFirstCharMatching(@NotNull String name, int nameIndex, int patternIndex) {
if (nameIndex >= name.length()) return false;
boolean ignoreCase = myOptions != NameUtil.MatchingCaseSensitivity.ALL;
char patternChar = myPattern[patternIndex];
if (!charEquals(patternChar, patternIndex, name.charAt(nameIndex), ignoreCase)) return false;
if (myOptions == NameUtil.MatchingCaseSensitivity.FIRST_LETTER &&
(patternIndex == 0 || patternIndex == 1 && isWildcard(0)) &&
Character.isUpperCase(patternChar) != Character.isUpperCase(name.charAt(0))) {
return false;
}
return true;
}
private boolean isWildcard(int patternIndex) {
if (patternIndex >= 0 && patternIndex < myPattern.length) {
char pc = myPattern[patternIndex];
return pc == ' ' || pc == '*';
}
return false;
}
private boolean isPatternChar(int patternIndex, char c) {
return patternIndex >= 0 && patternIndex < myPattern.length && myPattern[patternIndex] == c;
}
private int indexOfWordStart(@NotNull String name, int patternIndex, int startFrom) {
final char p = myPattern[patternIndex];
if (startFrom >= name.length() ||
myHasHumps && isLowerCase[patternIndex] && !(patternIndex > 0 && isWordSeparator[patternIndex - 1])) {
return -1;
}
int nextWordStart = startFrom;
while (true) {
nextWordStart = NameUtil.nextWord(name, nextWordStart);
if (nextWordStart >= name.length()) {
return -1;
}
if (charEquals(p, patternIndex, name.charAt(nextWordStart), true)) {
return nextWordStart;
}
}
}
private int indexOfIgnoreCase(String name, int fromIndex, char p, int patternIndex, boolean isAsciiName) {
if (isAsciiName && IOUtil.isAscii(p)) {
char pUpper = toUpperCase[patternIndex];
char pLower = toLowerCase[patternIndex];
for (int i = fromIndex; i < name.length(); i++) {
char c = name.charAt(i);
if (c == p || toUpperAscii(c) == pUpper || toLowerAscii(c) == pLower) {
return i;
}
}
return -1;
}
return StringUtil.indexOfIgnoreCase(name, p, fromIndex);
}
private static char toUpperAscii(char c) {
if (c >= 'a' && c <= 'z') {
return (char)(c + ('A' - 'a'));
}
return c;
}
private static char toLowerAscii(char c) {
if (c >= 'A' && c <= 'Z') {
return (char)(c - ('A' - 'a'));
}
return c;
}
@NonNls
@Override
public String toString() {
return "MinusculeMatcher{myPattern=" + new String(myPattern) + ", myOptions=" + myOptions + '}';
}
private static class MatchingState {
private boolean myBusy;
private int myNameLength;
private boolean isAsciiName;
private final BitSet myTable = new BitSet();
void initializeState(String name) {
assert !myBusy;
myBusy = true;
myNameLength = name.length();
isAsciiName = IOUtil.isAscii(name);
myTable.clear();
}
void releaseState() {
assert myBusy;
myBusy = false;
}
void registerFailure(int patternIndex, int nameIndex) {
myTable.set(patternIndex * myNameLength + nameIndex);
}
boolean hasFailed(int patternIndex, int nameIndex) {
return myTable.get(patternIndex * myNameLength + nameIndex);
}
}
}