blob: 57b90e9668d84f6576ca1ef74b3e7ff89a26f46c [file] [log] [blame]
/*
* Copyright (C) 2008, Florian Köberle <florianskarten@web.de> and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
* https://www.eclipse.org/org/documents/edl-v10.php.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
package org.eclipse.jgit.fnmatch;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.ListIterator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jgit.errors.InvalidPatternException;
import org.eclipse.jgit.errors.NoClosingBracketException;
/**
* This class can be used to match filenames against fnmatch like patterns. It
* is not thread save.
* <p>
* Supported are the wildcard characters * and ? and groups with:
* <ul>
* <li>characters e.g. [abc]</li>
* <li>ranges e.g. [a-z]</li>
* <li>the following character classes
* <ul>
* <li>[:alnum:]</li>
* <li>[:alpha:]</li>
* <li>[:blank:]</li>
* <li>[:cntrl:]</li>
* <li>[:digit:]</li>
* <li>[:graph:]</li>
* <li>[:lower:]</li>
* <li>[:print:]</li>
* <li>[:punct:]</li>
* <li>[:space:]</li>
* <li>[:upper:]</li>
* <li>[:word:]</li>
* <li>[:xdigit:]</li>
* </ul>
* e. g. [[:xdigit:]]</li>
* </ul>
* Any character can be escaped by prepending it with a \
*/
public class FileNameMatcher {
static final List<Head> EMPTY_HEAD_LIST = Collections.emptyList();
private static final Pattern characterClassStartPattern = Pattern
.compile("\\[[.:=]"); //$NON-NLS-1$
private List<Head> headsStartValue;
private List<Head> heads;
/**
* {{@link #extendStringToMatchByOneCharacter(char)} needs a list for the
* new heads, allocating a new array would be bad for the performance, as
* the method gets called very often.
*
*/
private List<Head> listForLocalUseage;
/**
*
* @param headsStartValue
* must be a list which will never be modified.
*/
private FileNameMatcher(List<Head> headsStartValue) {
this(headsStartValue, headsStartValue);
}
/**
*
* @param headsStartValue
* must be a list which will never be modified.
* @param heads
* a list which will be cloned and then used as current head
* list.
*/
private FileNameMatcher(final List<Head> headsStartValue,
final List<Head> heads) {
this.headsStartValue = headsStartValue;
this.heads = new ArrayList<>(heads.size());
this.heads.addAll(heads);
this.listForLocalUseage = new ArrayList<>(heads.size());
}
/**
* Constructor for FileNameMatcher
*
* @param patternString
* must contain a pattern which fnmatch would accept.
* @param invalidWildgetCharacter
* if this parameter isn't null then this character will not
* match at wildcards(* and ? are wildcards).
* @throws org.eclipse.jgit.errors.InvalidPatternException
* if the patternString contains a invalid fnmatch pattern.
*/
public FileNameMatcher(final String patternString,
final Character invalidWildgetCharacter)
throws InvalidPatternException {
this(createHeadsStartValues(patternString, invalidWildgetCharacter));
}
/**
* A Copy Constructor which creates a new
* {@link org.eclipse.jgit.fnmatch.FileNameMatcher} with the same state and
* reset point like <code>other</code>.
*
* @param other
* another {@link org.eclipse.jgit.fnmatch.FileNameMatcher}
* instance.
*/
public FileNameMatcher(FileNameMatcher other) {
this(other.headsStartValue, other.heads);
}
private static List<Head> createHeadsStartValues(
final String patternString, final Character invalidWildgetCharacter)
throws InvalidPatternException {
final List<AbstractHead> allHeads = parseHeads(patternString,
invalidWildgetCharacter);
List<Head> nextHeadsSuggestion = new ArrayList<>(2);
nextHeadsSuggestion.add(LastHead.INSTANCE);
for (int i = allHeads.size() - 1; i >= 0; i--) {
final AbstractHead head = allHeads.get(i);
// explanation:
// a and * of the pattern "a*b"
// need *b as newHeads
// that's why * extends the list for it self and it's left neighbor.
if (head.isStar()) {
nextHeadsSuggestion.add(head);
head.setNewHeads(nextHeadsSuggestion);
} else {
head.setNewHeads(nextHeadsSuggestion);
nextHeadsSuggestion = new ArrayList<>(2);
nextHeadsSuggestion.add(head);
}
}
return nextHeadsSuggestion;
}
private static int findGroupEnd(final int indexOfStartBracket,
final String pattern) throws InvalidPatternException {
int firstValidCharClassIndex = indexOfStartBracket + 1;
int firstValidEndBracketIndex = indexOfStartBracket + 2;
if (indexOfStartBracket + 1 >= pattern.length())
throw new NoClosingBracketException(indexOfStartBracket, "[", "]", //$NON-NLS-1$ //$NON-NLS-2$
pattern);
if (pattern.charAt(firstValidCharClassIndex) == '!') {
firstValidCharClassIndex++;
firstValidEndBracketIndex++;
}
final Matcher charClassStartMatcher = characterClassStartPattern
.matcher(pattern);
int groupEnd = -1;
while (groupEnd == -1) {
final int possibleGroupEnd = indexOfUnescaped(pattern, ']',
firstValidEndBracketIndex);
if (possibleGroupEnd == -1)
throw new NoClosingBracketException(indexOfStartBracket, "[", //$NON-NLS-1$
"]", pattern); //$NON-NLS-1$
final boolean foundCharClass = charClassStartMatcher
.find(firstValidCharClassIndex);
if (foundCharClass
&& charClassStartMatcher.start() < possibleGroupEnd) {
final String classStart = charClassStartMatcher.group(0);
final String classEnd = classStart.charAt(1) + "]"; //$NON-NLS-1$
final int classStartIndex = charClassStartMatcher.start();
final int classEndIndex = pattern.indexOf(classEnd,
classStartIndex + 2);
if (classEndIndex == -1)
throw new NoClosingBracketException(classStartIndex,
classStart, classEnd, pattern);
firstValidCharClassIndex = classEndIndex + 2;
firstValidEndBracketIndex = firstValidCharClassIndex;
} else {
groupEnd = possibleGroupEnd;
}
}
return groupEnd;
}
private static List<AbstractHead> parseHeads(final String pattern,
final Character invalidWildgetCharacter)
throws InvalidPatternException {
int currentIndex = 0;
List<AbstractHead> heads = new ArrayList<>();
while (currentIndex < pattern.length()) {
final int groupStart = indexOfUnescaped(pattern, '[', currentIndex);
if (groupStart == -1) {
final String patternPart = pattern.substring(currentIndex);
heads.addAll(createSimpleHeads(patternPart,
invalidWildgetCharacter));
currentIndex = pattern.length();
} else {
final String patternPart = pattern.substring(currentIndex,
groupStart);
heads.addAll(createSimpleHeads(patternPart,
invalidWildgetCharacter));
final int groupEnd = findGroupEnd(groupStart, pattern);
final String groupPart = pattern.substring(groupStart + 1,
groupEnd);
heads.add(new GroupHead(groupPart, pattern));
currentIndex = groupEnd + 1;
}
}
return heads;
}
private static List<AbstractHead> createSimpleHeads(
final String patternPart, final Character invalidWildgetCharacter) {
final List<AbstractHead> heads = new ArrayList<>(
patternPart.length());
boolean escaped = false;
for (int i = 0; i < patternPart.length(); i++) {
final char c = patternPart.charAt(i);
if (escaped) {
final CharacterHead head = new CharacterHead(c);
heads.add(head);
escaped = false;
} else {
switch (c) {
case '*': {
final AbstractHead head = createWildCardHead(
invalidWildgetCharacter, true);
heads.add(head);
break;
}
case '?': {
final AbstractHead head = createWildCardHead(
invalidWildgetCharacter, false);
heads.add(head);
break;
}
case '\\':
escaped = true;
break;
default:
final CharacterHead head = new CharacterHead(c);
heads.add(head);
}
}
}
return heads;
}
private static AbstractHead createWildCardHead(
final Character invalidWildgetCharacter, final boolean star) {
if (invalidWildgetCharacter != null) {
return new RestrictedWildCardHead(invalidWildgetCharacter
.charValue(), star);
}
return new WildCardHead(star);
}
/**
* @param c new character to append
* @return true to continue, false if the matcher can stop appending
*/
private boolean extendStringToMatchByOneCharacter(char c) {
final List<Head> newHeads = listForLocalUseage;
newHeads.clear();
List<Head> lastAddedHeads = null;
for (int i = 0; i < heads.size(); i++) {
final Head head = heads.get(i);
final List<Head> headsToAdd = head.getNextHeads(c);
// Why the next performance optimization isn't wrong:
// Some times two heads return the very same list.
// We save future effort if we don't add these heads again.
// This is the case with the heads "a" and "*" of "a*b" which
// both can return the list ["*","b"]
if (headsToAdd != lastAddedHeads) {
if (!headsToAdd.isEmpty())
newHeads.addAll(headsToAdd);
lastAddedHeads = headsToAdd;
}
}
listForLocalUseage = heads;
heads = newHeads;
return !newHeads.isEmpty();
}
private static int indexOfUnescaped(final String searchString,
final char ch, final int fromIndex) {
for (int i = fromIndex; i < searchString.length(); i++) {
char current = searchString.charAt(i);
if (current == ch)
return i;
if (current == '\\')
i++; // Skip the next char as it is escaped }
}
return -1;
}
/**
* Append to the string which is matched against the patterns of this class
*
* @param stringToMatch
* extends the string which is matched against the patterns of
* this class.
*/
public void append(String stringToMatch) {
for (int i = 0; i < stringToMatch.length(); i++) {
final char c = stringToMatch.charAt(i);
if (!extendStringToMatchByOneCharacter(c))
break;
}
}
/**
* Resets this matcher to it's state right after construction.
*/
public void reset() {
heads.clear();
heads.addAll(headsStartValue);
}
/**
* Create a {@link org.eclipse.jgit.fnmatch.FileNameMatcher} instance which
* uses the same pattern like this matcher, but has the current state of
* this matcher as reset and start point
*
* @return a {@link org.eclipse.jgit.fnmatch.FileNameMatcher} instance which
* uses the same pattern like this matcher, but has the current
* state of this matcher as reset and start point.
*/
public FileNameMatcher createMatcherForSuffix() {
final List<Head> copyOfHeads = new ArrayList<>(heads.size());
copyOfHeads.addAll(heads);
return new FileNameMatcher(copyOfHeads);
}
/**
* Whether the matcher matches
*
* @return whether the matcher matches
*/
public boolean isMatch() {
if (heads.isEmpty())
return false;
final ListIterator<Head> headIterator = heads
.listIterator(heads.size());
while (headIterator.hasPrevious()) {
final Head head = headIterator.previous();
if (head == LastHead.INSTANCE) {
return true;
}
}
return false;
}
/**
* Whether a match can be appended
*
* @return a boolean.
*/
public boolean canAppendMatch() {
for (Head head : heads) {
if (head != LastHead.INSTANCE) {
return true;
}
}
return false;
}
}