diff --git a/gradle.properties b/gradle.properties index 967f19df8..f7b3a8613 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,5 +16,5 @@ curse_project_id=238222 # Version version_major=7 -version_minor=7 -version_patch=1 +version_minor=8 +version_patch=0 diff --git a/src/main/java/mezz/jei/collect/IngredientSet.java b/src/main/java/mezz/jei/collect/IngredientSet.java index 7597b791f..29ea09a68 100644 --- a/src/main/java/mezz/jei/collect/IngredientSet.java +++ b/src/main/java/mezz/jei/collect/IngredientSet.java @@ -1,17 +1,17 @@ package mezz.jei.collect; -import javax.annotation.Nullable; +import mezz.jei.api.ingredients.IIngredientHelper; +import mezz.jei.api.ingredients.subtypes.UidContext; + import java.util.AbstractSet; import java.util.Collection; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.function.Function; -import mezz.jei.api.ingredients.IIngredientHelper; -import mezz.jei.api.ingredients.subtypes.UidContext; - public class IngredientSet extends AbstractSet { public static IngredientSet create(IIngredientHelper ingredientHelper, UidContext context) { final Function uidGenerator = v -> ingredientHelper.getUniqueId(v, context); @@ -59,9 +59,8 @@ public boolean contains(Object o) { return ingredients.containsKey(uid); } - @Nullable - public V getByUid(String uid) { - return ingredients.get(uid); + public Optional getByUid(String uid) { + return Optional.ofNullable(ingredients.get(uid)); } @Override diff --git a/src/main/java/mezz/jei/collect/MultiMap.java b/src/main/java/mezz/jei/collect/MultiMap.java index 262a9637c..2bb06919f 100644 --- a/src/main/java/mezz/jei/collect/MultiMap.java +++ b/src/main/java/mezz/jei/collect/MultiMap.java @@ -6,6 +6,7 @@ import java.util.Set; import java.util.function.Function; import java.util.function.Supplier; +import java.util.stream.Collectors; import com.google.common.collect.ImmutableMultimap; @@ -52,6 +53,16 @@ public Set keySet() { return map.keySet(); } + public Collection allValues() { + return this.map.values().stream() + .flatMap(Collection::stream) + .collect(Collectors.toList()); + } + + public void clear() { + map.clear(); + } + public ImmutableMultimap toImmutable() { ImmutableMultimap.Builder builder = ImmutableMultimap.builder(); for (Map.Entry entry : map.entrySet()) { diff --git a/src/main/java/mezz/jei/config/BookmarkConfig.java b/src/main/java/mezz/jei/config/BookmarkConfig.java index cc9d3cbd1..03bb8d0aa 100644 --- a/src/main/java/mezz/jei/config/BookmarkConfig.java +++ b/src/main/java/mezz/jei/config/BookmarkConfig.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Optional; import com.mojang.brigadier.exceptions.CommandSyntaxException; import mezz.jei.api.ingredients.subtypes.UidContext; @@ -111,9 +112,9 @@ private String getUid(IIngredientManager ingredientManager, IIngredientListE @Nullable private Object getUnknownIngredientByUid(IngredientManager ingredientManager, Collection> ingredientTypes, String uid) { for (IIngredientType ingredientType : ingredientTypes) { - Object ingredient = ingredientManager.getIngredientByUid(ingredientType, uid); - if (ingredient != null) { - return ingredient; + Optional ingredient = ingredientManager.getIngredientByUid(ingredientType, uid); + if (ingredient.isPresent()) { + return ingredient.get(); } } return null; diff --git a/src/main/java/mezz/jei/config/EditModeConfig.java b/src/main/java/mezz/jei/config/EditModeConfig.java index c1b527b9d..3fe517dde 100644 --- a/src/main/java/mezz/jei/config/EditModeConfig.java +++ b/src/main/java/mezz/jei/config/EditModeConfig.java @@ -9,6 +9,8 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import mezz.jei.api.ingredients.IIngredientHelper; import mezz.jei.api.ingredients.subtypes.UidContext; @@ -71,7 +73,8 @@ public void addIngredientToConfigBlacklist(IngredientFilter ingredientFilter // combine item-level blacklist into wildcard-level ones if (blacklistType == IngredientBlacklistType.ITEM) { final String uid = getIngredientUid(ingredient, IngredientBlacklistType.ITEM, ingredientHelper); - List> elementsToBeBlacklisted = ingredientFilter.getMatches(ingredient, ingredientHelper, (input) -> getIngredientUid(input, IngredientBlacklistType.WILDCARD, ingredientHelper)); + List> elementsToBeBlacklisted = ingredientFilter.searchForMatchingElement(ingredient, ingredientHelper, (input) -> getIngredientUid(input, IngredientBlacklistType.WILDCARD, ingredientHelper)) + .collect(Collectors.toList()); if (areAllBlacklisted(elementsToBeBlacklisted, ingredientHelper, uid)) { if (addIngredientToConfigBlacklist(ingredientFilter, ingredient, IngredientBlacklistType.WILDCARD, ingredientHelper)) { saveBlacklist(); @@ -89,7 +92,8 @@ private boolean addIngredientToConfigBlacklist(IngredientFilter ingredientFi // remove lower-level blacklist entries when a higher-level one is added if (blacklistType == IngredientBlacklistType.WILDCARD) { - List> elementsToBeBlacklisted = ingredientFilter.getMatches(ingredient, ingredientHelper, (input) -> getIngredientUid(input, blacklistType, ingredientHelper)); + List> elementsToBeBlacklisted = ingredientFilter.searchForMatchingElement(ingredient, ingredientHelper, (input) -> getIngredientUid(input, blacklistType, ingredientHelper)) + .collect(Collectors.toList()); for (IIngredientListElementInfo elementToBeBlacklistedInfo : elementsToBeBlacklisted) { IIngredientListElement elementToBeBlacklisted = elementToBeBlacklistedInfo.getElement(); V ingredientToBeBlacklisted = elementToBeBlacklisted.getIngredient(); @@ -119,13 +123,15 @@ private boolean areAllBlacklisted(List> elemen public void removeIngredientFromConfigBlacklist(IngredientFilter ingredientFilter, IIngredientManager ingredientManager, V ingredient, IngredientBlacklistType blacklistType, IIngredientHelper ingredientHelper) { boolean updated = false; + Function uidFunction = (input) -> getIngredientUid(input, IngredientBlacklistType.WILDCARD, ingredientHelper); if (blacklistType == IngredientBlacklistType.ITEM) { // deconstruct any wildcard blacklist since we are removing one element from it final String wildUid = getIngredientUid(ingredient, IngredientBlacklistType.WILDCARD, ingredientHelper); if (blacklist.contains(wildUid)) { updated = true; blacklist.remove(wildUid); - List> modMatches = ingredientFilter.getMatches(ingredient, ingredientHelper, (input) -> getIngredientUid(input, IngredientBlacklistType.WILDCARD, ingredientHelper)); + List> modMatches = ingredientFilter.searchForMatchingElement(ingredient, ingredientHelper, uidFunction) + .collect(Collectors.toList()); for (IIngredientListElementInfo modMatch : modMatches) { IIngredientListElement element = modMatch.getElement(); addIngredientToConfigBlacklist(ingredientFilter, element.getIngredient(), IngredientBlacklistType.ITEM, ingredientHelper); @@ -133,7 +139,8 @@ public void removeIngredientFromConfigBlacklist(IngredientFilter ingredientF } } else if (blacklistType == IngredientBlacklistType.WILDCARD) { // remove any item-level blacklist on items that match this wildcard - List> modMatches = ingredientFilter.getMatches(ingredient, ingredientHelper, (input) -> getIngredientUid(input, IngredientBlacklistType.WILDCARD, ingredientHelper)); + List> modMatches = ingredientFilter.searchForMatchingElement(ingredient, ingredientHelper, uidFunction) + .collect(Collectors.toList()); for (IIngredientListElementInfo modMatch : modMatches) { IIngredientListElement element = modMatch.getElement(); V matchIngredient = element.getIngredient(); diff --git a/src/main/java/mezz/jei/ingredients/IngredientFilter.java b/src/main/java/mezz/jei/ingredients/IngredientFilter.java index 596b23e67..a92f9c427 100644 --- a/src/main/java/mezz/jei/ingredients/IngredientFilter.java +++ b/src/main/java/mezz/jei/ingredients/IngredientFilter.java @@ -1,11 +1,8 @@ package mezz.jei.ingredients; import com.google.common.collect.ImmutableList; -import it.unimi.dsi.fastutil.chars.Char2ObjectMap; -import it.unimi.dsi.fastutil.chars.Char2ObjectOpenHashMap; -import it.unimi.dsi.fastutil.ints.IntIterator; -import it.unimi.dsi.fastutil.ints.IntOpenHashSet; -import it.unimi.dsi.fastutil.ints.IntSet; +import com.google.common.collect.ImmutableSet; +import mezz.jei.api.helpers.IColorHelper; import mezz.jei.api.helpers.IModIdHelper; import mezz.jei.api.ingredients.IIngredientHelper; import mezz.jei.api.ingredients.subtypes.UidContext; @@ -14,33 +11,36 @@ import mezz.jei.config.IEditModeConfig; import mezz.jei.config.IIngredientFilterConfig; import mezz.jei.config.IWorldConfig; -import mezz.jei.config.SearchMode; import mezz.jei.events.EditModeToggleEvent; import mezz.jei.events.EventBusHelper; import mezz.jei.events.PlayerJoinedWorldEvent; import mezz.jei.gui.ingredients.IIngredientListElement; import mezz.jei.gui.overlay.IIngredientGridSource; +import mezz.jei.search.ElementPrefixParser; import mezz.jei.search.ElementSearch; import mezz.jei.search.ElementSearchLowMem; import mezz.jei.search.IElementSearch; -import mezz.jei.search.PrefixInfo; import mezz.jei.util.LoggedTimer; import mezz.jei.util.Translator; import net.minecraft.util.NonNullList; +import org.apache.logging.log4j.LogManager; import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.function.Function; -import org.apache.logging.log4j.LogManager; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.Stream; public class IngredientFilter implements IIngredientGridSource { private static final Pattern QUOTE_PATTERN = Pattern.compile("\""); @@ -54,7 +54,7 @@ public class IngredientFilter implements IIngredientGridSource { private final boolean debugMode; private final IElementSearch elementSearch; - private final Char2ObjectMap prefixInfos = new Char2ObjectOpenHashMap<>(); + private final ElementPrefixParser elementPrefixParser; private final Set modNamesForSorting = new HashSet<>(); @Nullable @@ -71,31 +71,22 @@ public IngredientFilter( IIngredientManager ingredientManager, IIngredientSorter sorter, NonNullList> ingredients, - IModIdHelper modIdHelper) { + IModIdHelper modIdHelper + ) { this.blacklist = blacklist; this.worldConfig = worldConfig; this.editModeConfig = editModeConfig; this.ingredientManager = ingredientManager; this.sorter = sorter; + this.elementPrefixParser = new ElementPrefixParser(ingredientManager, config); if (clientConfig.isLowMemorySlowSearchEnabled()) { this.elementSearch = new ElementSearchLowMem(); } else { - this.elementSearch = new ElementSearch(); + this.elementSearch = new ElementSearch(elementPrefixParser); } this.debugMode = clientConfig.isDebugModeEnabled(); - this.prefixInfos.put('@', new PrefixInfo(config::getModNameSearchMode, IIngredientListElementInfo::getModNameStrings)); - this.prefixInfos.put('#', new PrefixInfo(config::getTooltipSearchMode, e -> e.getTooltipStrings(config, ingredientManager))); - this.prefixInfos.put('$', new PrefixInfo(config::getTagSearchMode, e -> e.getTagStrings(ingredientManager))); - this.prefixInfos.put('%', new PrefixInfo(config::getCreativeTabSearchMode, e -> e.getCreativeTabsStrings(ingredientManager))); - this.prefixInfos.put('^', new PrefixInfo(config::getColorSearchMode, e -> e.getColorStrings(ingredientManager))); - this.prefixInfos.put('&', new PrefixInfo(config::getResourceIdSearchMode, element -> Collections.singleton(element.getResourceId()))); - - for (PrefixInfo prefixInfo : this.prefixInfos.values()) { - this.elementSearch.registerPrefix(prefixInfo); - } - EventBusHelper.registerWeakListener(this, EditModeToggleEvent.class, (ingredientFilter, editModeToggleEvent) -> { ingredientFilter.updateHidden(); }); @@ -136,14 +127,12 @@ public List> findMatchingElements(IIngredientH @SuppressWarnings("unchecked") final Class ingredientClass = (Class) ingredient.getClass(); final List> matchingElements = new ArrayList<>(); - final IntSet matchingIndexes = this.elementSearch.getSearchResults(Translator.toLowercaseWithLocale(displayName), PrefixInfo.NO_PREFIX); - if (matchingIndexes == null) { + ElementPrefixParser.TokenInfo tokenInfo = new ElementPrefixParser.TokenInfo(Translator.toLowercaseWithLocale(displayName), ElementPrefixParser.NO_PREFIX); + Set> searchResults = this.elementSearch.getSearchResults(tokenInfo); + if (searchResults.isEmpty()) { return matchingElements; } - final IntIterator iterator = matchingIndexes.iterator(); - while (iterator.hasNext()) { - int index = iterator.nextInt(); - IIngredientListElementInfo matchingElementInfo = this.elementSearch.get(index); + for (IIngredientListElementInfo matchingElementInfo : searchResults) { Object matchingIngredient = matchingElementInfo.getElement().getIngredient(); if (ingredientClass.isInstance(matchingIngredient)) { V castMatchingIngredient = ingredientClass.cast(matchingIngredient); @@ -159,7 +148,6 @@ public List> findMatchingElements(IIngredientH } public void modesChanged() { - this.elementSearch.start(); this.filterCached = null; } @@ -202,7 +190,7 @@ public List> getIngredientList(String filterText) { //This is used to allow the sorting function to set all item's indexes, precomuting master sort order. public List> getIngredientListPreSort(Comparator> directComparator) { //First step is to get the full list. - List> ingredientList = elementSearch.getAllIngredients(); + Collection> ingredientList = elementSearch.getAllIngredients(); LoggedTimer filterTimer = new LoggedTimer(); if (debugMode) { filterTimer.start("Pre-Sorting."); @@ -235,171 +223,146 @@ public ImmutableList getFilteredIngredients(String filterText) { private List> getIngredientListUncached(String filterText) { String[] filters = filterText.split("\\|"); + List searchTokens = Arrays.stream(filters) + .map(this::parseSearchTokens) + .filter(s -> !s.toSearch.isEmpty()) + .collect(Collectors.toList()); - IntSet matches = null; - - for (String filter : filters) { - IntSet elements = getElements(filter); - if (elements != null) { - if (matches == null) { - matches = elements; - } else { - matches.addAll(elements); - } - } + Stream> elementInfoStream; + if (searchTokens.isEmpty()) { + elementInfoStream = this.elementSearch.getAllIngredients() + .parallelStream(); + } else { + elementInfoStream = searchTokens.stream() + .map(this::getSearchResults) + .flatMap(Set::stream) + .distinct(); } - if (matches == null) { - return this.elementSearch.getAllIngredients() - .parallelStream() - .filter(info -> { - IIngredientListElement element = info.getElement(); - return element.isVisible(); - }) + return elementInfoStream + .filter(info -> info.getElement().isVisible()) + .sorted(sorter.getComparator(this, this.ingredientManager)) .collect(Collectors.toList()); - } - - List> matchingIngredients = new ArrayList<>(); - int[] matchesList = matches.toIntArray(); - Arrays.sort(matchesList); - for (int match : matchesList) { - IIngredientListElementInfo info = this.elementSearch.get(match); - IIngredientListElement element = info.getElement(); - if (element.isVisible()) { - matchingIngredients.add(info); - } - } - return matchingIngredients; } - /** - * Scans up and down the element list to find wildcard matches that touch the given element. - */ - public List> getMatches(T ingredient, IIngredientHelper ingredientHelper, Function uidFunction) { - final String uid = uidFunction.apply(ingredient); - @SuppressWarnings("unchecked") - Class ingredientClass = (Class) ingredient.getClass(); - List> matchingElements = findMatchingElements(ingredientHelper, ingredient); - IntSet matchingIndexes = new IntOpenHashSet(50); - IntSet startingIndexes = new IntOpenHashSet(matchingElements.size()); - for (IIngredientListElementInfo matchingElement : matchingElements) { - int index = this.elementSearch.indexOf(matchingElement); - startingIndexes.add(index); - matchingIndexes.add(index); - } + private SearchTokens parseSearchTokens(String filterText) { + SearchTokens searchTokens = new SearchTokens(new ArrayList<>(), new ArrayList<>()); - IntIterator iterator = startingIndexes.iterator(); - while (iterator.hasNext()) { - int startingIndex = iterator.nextInt(); - for (int i = startingIndex - 1; i >= 0 && !matchingIndexes.contains(i); i--) { - IIngredientListElementInfo info = this.elementSearch.get(i); - Object elementIngredient = info.getElement().getIngredient(); - if (elementIngredient.getClass() != ingredientClass) { - break; - } - String elementWildcardId = uidFunction.apply(ingredientClass.cast(elementIngredient)); - if (!uid.equals(elementWildcardId)) { - break; - } - matchingIndexes.add(i); - @SuppressWarnings("unchecked") - IIngredientListElementInfo castInfo = (IIngredientListElementInfo) info; - matchingElements.add(castInfo); - } - for (int i = startingIndex + 1; i < this.elementSearch.size() && !matchingIndexes.contains(i); i++) { - IIngredientListElementInfo info = this.elementSearch.get(i); - Object elementIngredient = info.getElement().getIngredient(); - if (elementIngredient.getClass() != ingredientClass) { - break; - } - String elementWildcardId = uidFunction.apply(ingredientClass.cast(elementIngredient)); - if (!uid.equals(elementWildcardId)) { - break; - } - matchingIndexes.add(i); - @SuppressWarnings("unchecked") - IIngredientListElementInfo castElement = (IIngredientListElementInfo) info; - matchingElements.add(castElement); - } + if (filterText.isEmpty()) { + return searchTokens; } - return matchingElements; - } - - @Nullable - private IntSet getElements(String filterText) { Matcher filterMatcher = FILTER_SPLIT_PATTERN.matcher(filterText); - - IntSet matches = null; - IntSet removeMatches = null; while (filterMatcher.find()) { - String token = filterMatcher.group(1); - final boolean remove = token.startsWith("-"); + String string = filterMatcher.group(1); + final boolean remove = string.startsWith("-"); if (remove) { - token = token.substring(1); + string = string.substring(1); } - token = QUOTE_PATTERN.matcher(token).replaceAll(""); - - IntSet searchResults = getSearchResults(token); - if (searchResults != null) { - if (remove) { - if (removeMatches == null) { - removeMatches = searchResults; - } else { - removeMatches.addAll(searchResults); - } - } else { - if (matches == null) { - matches = searchResults; - } else { - matches = intersection(matches, searchResults); - } - if (matches.isEmpty()) { - break; - } - } + string = QUOTE_PATTERN.matcher(string).replaceAll(""); + if (string.isEmpty()) { + continue; } + this.elementPrefixParser.parseToken(string) + .ifPresent(result -> { + if (remove) { + searchTokens.toRemove.add(result); + } else { + searchTokens.toSearch.add(result); + } + }); } + return searchTokens; + } + + public Stream> searchForMatchingElement( + V ingredient, + IIngredientHelper ingredientHelper, + Function uidFunction + ) { + String ingredientUid = uidFunction.apply(ingredient); + String displayName = ingredientHelper.getDisplayName(ingredient); + String lowercaseDisplayName = Translator.toLowercaseWithLocale(displayName); + + ElementPrefixParser.TokenInfo tokenInfo = new ElementPrefixParser.TokenInfo(lowercaseDisplayName, ElementPrefixParser.NO_PREFIX); + @SuppressWarnings("unchecked") + Class ingredientClass = (Class) ingredient.getClass(); + return this.elementSearch.getSearchResults(tokenInfo) + .stream() + .map(elementInfo -> checkForMatch(elementInfo, ingredientClass, ingredientUid, uidFunction)) + .filter(Optional::isPresent) + .map(Optional::get); + } + + private static Optional> checkForMatch(IIngredientListElementInfo info, Class ingredientClass, String uid, Function uidFunction) { + return optionalCast(info, ingredientClass) + .filter(cast -> { + String elementUid = uidFunction.apply(cast.getElement().getIngredient()); + return uid.equals(elementUid); + }); + } - if (matches != null && removeMatches != null) { - matches.removeAll(removeMatches); + private static Optional> optionalCast(IIngredientListElementInfo info, Class ingredientClass) { + Object ingredient = info.getElement().getIngredient(); + if (ingredientClass.isInstance(ingredient)) { + @SuppressWarnings("unchecked") + IIngredientListElementInfo cast = (IIngredientListElementInfo) info; + return Optional.of(cast); } + return Optional.empty(); + } + + private static final class SearchTokens { + private final List toSearch; + private final List toRemove; - return matches; + private SearchTokens(List toSearch, List toRemove) { + this.toSearch = toSearch; + this.toRemove = toRemove; + } } /** * Gets the appropriate search tree for the given token, based on if the token has a prefix. */ - @Nullable - private IntSet getSearchResults(String token) { - if (token.isEmpty()) { - return null; - } - final char firstChar = token.charAt(0); - final PrefixInfo prefixInfo = this.prefixInfos.get(firstChar); - if (prefixInfo != null && prefixInfo.getMode() != SearchMode.DISABLED) { - token = token.substring(1); - if (token.isEmpty()) { - return null; + private Set> getSearchResults(SearchTokens searchTokens) { + List>> resultsPerToken = searchTokens.toSearch.stream() + .map(this.elementSearch::getSearchResults) + .collect(ImmutableList.toImmutableList()); + Set> results = intersection(resultsPerToken); + + if (!results.isEmpty() && !searchTokens.toRemove.isEmpty()) { + for (ElementPrefixParser.TokenInfo tokenInfo : searchTokens.toRemove) { + Set> resultsToRemove = this.elementSearch.getSearchResults(tokenInfo); + results.removeAll(resultsToRemove); + if (results.isEmpty()) { + break; + } } - return this.elementSearch.getSearchResults(token, prefixInfo); - } else { - return this.elementSearch.getSearchResults(token, PrefixInfo.NO_PREFIX); } + return results; + } /** - * Efficiently get the elements contained in both sets. - * Note that this implementation will alter the original sets. + * Get the elements that are contained in every set. */ - private static IntSet intersection(IntSet set1, IntSet set2) { - if (set1.size() > set2.size()) { - set2.retainAll(set1); - return set2; - } else { - set1.retainAll(set2); - return set1; + private static Set intersection(List> sets) { + Set smallestSet = sets.stream() + .min(Comparator.comparing(Set::size)) + .orElseGet(ImmutableSet::of); + + Set results = Collections.newSetFromMap(new IdentityHashMap<>()); + results.addAll(smallestSet); + + for (Set set : sets) { + if (set == smallestSet) { + continue; + } + if (results.retainAll(set) && results.isEmpty()) { + break; + } } + return results; } @Override diff --git a/src/main/java/mezz/jei/ingredients/IngredientFilterBackgroundBuilder.java b/src/main/java/mezz/jei/ingredients/IngredientFilterBackgroundBuilder.java deleted file mode 100644 index 448ce3706..000000000 --- a/src/main/java/mezz/jei/ingredients/IngredientFilterBackgroundBuilder.java +++ /dev/null @@ -1,91 +0,0 @@ -package mezz.jei.ingredients; - -import mezz.jei.config.SearchMode; -import mezz.jei.events.EventBusHelper; -import mezz.jei.search.PrefixInfo; -import mezz.jei.search.suffixtree.GeneralizedSuffixTree; -import net.minecraft.client.Minecraft; -import net.minecraft.util.NonNullList; -import net.minecraftforge.event.TickEvent; -import net.minecraftforge.fml.LogicalSide; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -public class IngredientFilterBackgroundBuilder { - private final Map> prefixedSearchTrees; - private final NonNullList> elementList; - private final Consumer onTickHandler; - - public IngredientFilterBackgroundBuilder( - Map> prefixedSearchTrees, - NonNullList> elementList - ) { - this.prefixedSearchTrees = prefixedSearchTrees; - this.elementList = elementList; - this.onTickHandler = this::onClientTick; - } - - public void start() { - boolean finished = run(10000); - if (!finished) { - EventBusHelper.addListener(this, TickEvent.ClientTickEvent.class, this.onTickHandler); - } - } - - private void onClientTick(TickEvent.ClientTickEvent event) { - if (event.side == LogicalSide.CLIENT && Minecraft.getInstance().player != null) { - boolean finished = run(20); - if (!finished) { - return; - } - } - EventBusHelper.removeListener(this, this.onTickHandler); - } - - private boolean run(final int timeoutMs) { - final long startTime = System.currentTimeMillis(); - List> activeTrees = new ArrayList<>(); - int startIndex = Integer.MAX_VALUE; - for (PrefixedSearchable prefixedTree : this.prefixedSearchTrees.values()) { - SearchMode mode = prefixedTree.getMode(); - if (mode != SearchMode.DISABLED) { - GeneralizedSuffixTree searchable = prefixedTree.getSearchable(); - int nextFreeIndex = searchable.getHighestIndex() + 1; - startIndex = Math.min(nextFreeIndex, startIndex); - if (nextFreeIndex < elementList.size()) { - activeTrees.add(prefixedTree); - } - } - } - - if (activeTrees.isEmpty()) { - return true; - } - - for (int i = startIndex; i < elementList.size(); i++) { - IIngredientListElementInfo info = elementList.get(i); - for (PrefixedSearchable prefixedTree : activeTrees) { - GeneralizedSuffixTree searchable = prefixedTree.getSearchable(); - int nextFreeIndex = searchable.getHighestIndex() + 1; - if (nextFreeIndex >= i) { - Collection strings = prefixedTree.getStrings(info); - if (strings.isEmpty()) { - searchable.put("", i); - } else { - for (String string : strings) { - searchable.put(string, i); - } - } - } - } - if (System.currentTimeMillis() - startTime >= timeoutMs) { - return false; - } - } - return true; - } -} diff --git a/src/main/java/mezz/jei/ingredients/IngredientInfo.java b/src/main/java/mezz/jei/ingredients/IngredientInfo.java new file mode 100644 index 000000000..24caa2371 --- /dev/null +++ b/src/main/java/mezz/jei/ingredients/IngredientInfo.java @@ -0,0 +1,55 @@ +package mezz.jei.ingredients; + +import mezz.jei.api.ingredients.IIngredientHelper; +import mezz.jei.api.ingredients.IIngredientRenderer; +import mezz.jei.api.ingredients.IIngredientType; +import mezz.jei.api.ingredients.subtypes.UidContext; +import mezz.jei.collect.IngredientSet; + +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; + +public class IngredientInfo { + private final IIngredientType ingredientType; + private final IIngredientHelper ingredientHelper; + private final IIngredientRenderer ingredientRenderer; + private final IngredientSet ingredientSet; + + public IngredientInfo(IIngredientType ingredientType, Collection ingredients, IIngredientHelper ingredientHelper, IIngredientRenderer ingredientRenderer) { + this.ingredientType = ingredientType; + this.ingredientHelper = ingredientHelper; + this.ingredientRenderer = ingredientRenderer; + + this.ingredientSet = IngredientSet.create(ingredientHelper, UidContext.Ingredient); + this.ingredientSet.addAll(ingredients); + } + + public IIngredientType getIngredientType() { + return ingredientType; + } + + public IIngredientHelper getIngredientHelper() { + return ingredientHelper; + } + + public IIngredientRenderer getIngredientRenderer() { + return ingredientRenderer; + } + + public Collection getAllIngredients() { + return Collections.unmodifiableCollection(ingredientSet); + } + + public void addIngredients(Collection ingredients) { + this.ingredientSet.addAll(ingredients); + } + + public void removeIngredients(Collection ingredients) { + this.ingredientSet.removeAll(ingredients); + } + + public Optional getIngredientByUid(String uid) { + return ingredientSet.getByUid(uid); + } +} diff --git a/src/main/java/mezz/jei/ingredients/IngredientListElementFactory.java b/src/main/java/mezz/jei/ingredients/IngredientListElementFactory.java index 514e68638..68188af9c 100644 --- a/src/main/java/mezz/jei/ingredients/IngredientListElementFactory.java +++ b/src/main/java/mezz/jei/ingredients/IngredientListElementFactory.java @@ -1,19 +1,17 @@ package mezz.jei.ingredients; -import java.util.Collection; - -import net.minecraft.util.NonNullList; - -import mezz.jei.api.ingredients.IIngredientHelper; import mezz.jei.api.ingredients.IIngredientType; import mezz.jei.api.runtime.IIngredientManager; import mezz.jei.gui.ingredients.IIngredientListElement; +import net.minecraft.util.NonNullList; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.Collection; + public final class IngredientListElementFactory { private static final Logger LOGGER = LogManager.getLogger(); - private static final IngredientOrderTracker ORDER_TRACKER = new IngredientOrderTracker(); + private static int ingredientAddedIndex = 0; private IngredientListElementFactory() { } @@ -29,13 +27,10 @@ public static NonNullList> createBaseList(IIngredientM } public static NonNullList> createList(IIngredientManager ingredientManager, IIngredientType ingredientType, Collection ingredients) { - IIngredientHelper ingredientHelper = ingredientManager.getIngredientHelper(ingredientType); - NonNullList> list = NonNullList.create(); for (V ingredient : ingredients) { if (ingredient != null) { - int orderIndex = ORDER_TRACKER.getOrderIndex(ingredient, ingredientHelper); - IngredientListElement ingredientListElement = new IngredientListElement<>(ingredient, orderIndex); + IIngredientListElement ingredientListElement = createOrderedElement(ingredient); list.add(ingredientListElement); } } @@ -46,21 +41,17 @@ public static IIngredientListElement createUnorderedElement(V ingredient) return new IngredientListElement<>(ingredient, 0); } - public static IIngredientListElement createOrderedElement(IIngredientManager ingredientManager, IIngredientType ingredientType, V ingredient) { - IIngredientHelper ingredientHelper = ingredientManager.getIngredientHelper(ingredientType); - int orderIndex = ORDER_TRACKER.getOrderIndex(ingredient, ingredientHelper); + public static IIngredientListElement createOrderedElement(V ingredient) { + int orderIndex = ingredientAddedIndex++; return new IngredientListElement<>(ingredient, orderIndex); } private static void addToBaseList(NonNullList> baseList, IIngredientManager ingredientManager, IIngredientType ingredientType) { - IIngredientHelper ingredientHelper = ingredientManager.getIngredientHelper(ingredientType); - Collection ingredients = ingredientManager.getAllIngredients(ingredientType); LOGGER.debug("Registering ingredients: " + ingredientType.getIngredientClass().getSimpleName()); for (V ingredient : ingredients) { if (ingredient != null) { - int orderIndex = ORDER_TRACKER.getOrderIndex(ingredient, ingredientHelper); - IngredientListElement ingredientListElement = new IngredientListElement<>(ingredient, orderIndex); + IIngredientListElement ingredientListElement = createOrderedElement(ingredient); baseList.add(ingredientListElement); } } diff --git a/src/main/java/mezz/jei/ingredients/IngredientManager.java b/src/main/java/mezz/jei/ingredients/IngredientManager.java index 7c0f6ff7a..16d0cce01 100644 --- a/src/main/java/mezz/jei/ingredients/IngredientManager.java +++ b/src/main/java/mezz/jei/ingredients/IngredientManager.java @@ -18,6 +18,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.Set; public class IngredientManager implements IIngredientManager { @@ -78,11 +79,10 @@ public Collection getAllIngredients(IIngredientType ingredientType) { } } - @Nullable - public V getIngredientByUid(IIngredientType ingredientType, String uid) { + public Optional getIngredientByUid(IIngredientType ingredientType, String uid) { RegisteredIngredient registeredIngredient = getRegisteredIngredient(ingredientType); if (registeredIngredient == null) { - return null; + return Optional.empty(); } else { IngredientSet ingredients = registeredIngredient.getIngredientSet(); return ingredients.getByUid(uid); @@ -172,7 +172,7 @@ public void addIngredientsAtRuntime(IIngredientType ingredientType, Colle LOGGER.debug("Updated ingredient: {}", ingredientHelper.getErrorInfo(ingredient)); } } else { - IIngredientListElement element = IngredientListElementFactory.createOrderedElement(this, ingredientType, ingredient); + IIngredientListElement element = IngredientListElementFactory.createOrderedElement(ingredient); IIngredientListElementInfo info = IngredientListElementInfo.create(element, this, modIdHelper); if (info != null) { blacklist.removeIngredientFromBlacklist(ingredient, ingredientHelper); diff --git a/src/main/java/mezz/jei/ingredients/IngredientOrderTracker.java b/src/main/java/mezz/jei/ingredients/IngredientOrderTracker.java deleted file mode 100644 index d6ec980b9..000000000 --- a/src/main/java/mezz/jei/ingredients/IngredientOrderTracker.java +++ /dev/null @@ -1,23 +0,0 @@ -package mezz.jei.ingredients; - -import java.util.HashMap; -import java.util.Map; - -import mezz.jei.api.ingredients.IIngredientHelper; - -public class IngredientOrderTracker { - private final Map wildcardAddedOrder = new HashMap<>(); - private int addedIndex = 0; - - public int getOrderIndex(V ingredient, IIngredientHelper ingredientHelper) { - String uid = ingredientHelper.getWildcardId(ingredient); - if (wildcardAddedOrder.containsKey(uid)) { - return wildcardAddedOrder.get(uid); - } else { - int index = addedIndex; - wildcardAddedOrder.put(uid, index); - addedIndex++; - return index; - } - } -} diff --git a/src/main/java/mezz/jei/ingredients/PrefixedSearchable.java b/src/main/java/mezz/jei/ingredients/PrefixedSearchable.java deleted file mode 100644 index e89aea088..000000000 --- a/src/main/java/mezz/jei/ingredients/PrefixedSearchable.java +++ /dev/null @@ -1,37 +0,0 @@ -package mezz.jei.ingredients; - -import java.util.Collection; - -import it.unimi.dsi.fastutil.ints.IntSet; -import mezz.jei.config.SearchMode; -import mezz.jei.search.ISearchable; -import mezz.jei.search.PrefixInfo; - -public class PrefixedSearchable implements ISearchable { - private final T searchable; - private final PrefixInfo prefixInfo; - - public PrefixedSearchable(T searchable, PrefixInfo prefixInfo) { - this.searchable = searchable; - this.prefixInfo = prefixInfo; - } - - public T getSearchable() { - return searchable; - } - - public Collection getStrings(IIngredientListElementInfo element) { - return prefixInfo.getStrings(element); - } - - @Override - public SearchMode getMode() { - return prefixInfo.getMode(); - } - - @Override - public IntSet search(String word) { - return searchable.search(word); - } - -} diff --git a/src/main/java/mezz/jei/search/CombinedSearchables.java b/src/main/java/mezz/jei/search/CombinedSearchables.java index 7c2bb9549..6809758a0 100644 --- a/src/main/java/mezz/jei/search/CombinedSearchables.java +++ b/src/main/java/mezz/jei/search/CombinedSearchables.java @@ -2,40 +2,32 @@ import java.util.ArrayList; import java.util.List; +import java.util.Set; -import it.unimi.dsi.fastutil.ints.IntOpenHashSet; -import it.unimi.dsi.fastutil.ints.IntSet; import mezz.jei.config.SearchMode; -public class CombinedSearchables { - private final List searchables = new ArrayList<>(); +public class CombinedSearchables implements ISearchable { + private final List> searchables = new ArrayList<>(); - public IntSet search(String word) { - IntSet searchResults = new IntOpenHashSet(0); - for (ISearchable searchable : searchables) { + @Override + public void getSearchResults(String word, Set results) { + for (ISearchable searchable : this.searchables) { if (searchable.getMode() == SearchMode.ENABLED) { - IntSet search = searchable.search(word); - searchResults = union(searchResults, search); + searchable.getSearchResults(word, results); } } - return searchResults; } - public void addSearchable(ISearchable searchable) { - this.searchables.add(searchable); + @Override + public void getAllElements(Set results) { + for (ISearchable searchable : this.searchables) { + if (searchable.getMode() == SearchMode.ENABLED) { + searchable.getAllElements(results); + } + } } - /** - * Efficiently get all the elements from both sets. - * Note that this implementation will alter the original sets. - */ - private static IntSet union(IntSet set1, IntSet set2) { - if (set1.size() > set2.size()) { - set1.addAll(set2); - return set1; - } else { - set2.addAll(set1); - return set2; - } + public void addSearchable(ISearchable searchable) { + this.searchables.add(searchable); } } diff --git a/src/main/java/mezz/jei/search/ElementPrefixParser.java b/src/main/java/mezz/jei/search/ElementPrefixParser.java new file mode 100644 index 000000000..0f3bc6852 --- /dev/null +++ b/src/main/java/mezz/jei/search/ElementPrefixParser.java @@ -0,0 +1,110 @@ +package mezz.jei.search; + +import com.google.common.collect.ImmutableList; +import it.unimi.dsi.fastutil.chars.Char2ObjectMap; +import it.unimi.dsi.fastutil.chars.Char2ObjectOpenHashMap; +import mezz.jei.api.runtime.IIngredientManager; +import mezz.jei.config.IIngredientFilterConfig; +import mezz.jei.config.SearchMode; +import mezz.jei.ingredients.IIngredientListElementInfo; +import mezz.jei.search.suffixtree.GeneralizedSuffixTree; +import mezz.jei.util.Translator; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class ElementPrefixParser { + public static final PrefixInfo> NO_PREFIX = new PrefixInfo<>( + '\0', + () -> SearchMode.ENABLED, + i -> ImmutableList.of(i.getName()), + GeneralizedSuffixTree::new + ); + + private final Char2ObjectMap>> map = new Char2ObjectOpenHashMap<>(); + + public ElementPrefixParser(IIngredientManager ingredientManager, IIngredientFilterConfig config) { + addPrefix(new PrefixInfo<>( + '@', + config::getModNameSearchMode, + IIngredientListElementInfo::getModNameStrings, + LimitedStringStorage::new + )); + addPrefix(new PrefixInfo<>( + '#', + config::getTooltipSearchMode, + e -> e.getTooltipStrings(config, ingredientManager), + GeneralizedSuffixTree::new + )); + addPrefix(new PrefixInfo<>( + '$', + config::getTagSearchMode, + e -> e.getTagStrings(ingredientManager), + LimitedStringStorage::new + )); + addPrefix(new PrefixInfo<>( + '^', + config::getColorSearchMode, + e -> { + Iterable colors = e.getColorStrings(ingredientManager); + return StreamSupport.stream(colors.spliterator(), false) + .map(Translator::toLowercaseWithLocale) + .distinct() + .collect(Collectors.toList()); + }, + LimitedStringStorage::new + )); + addPrefix(new PrefixInfo<>( + '&', + config::getResourceIdSearchMode, + element -> ImmutableList.of(element.getResourceId()), + GeneralizedSuffixTree::new + )); + } + + private void addPrefix(PrefixInfo> info) { + this.map.put(info.getPrefix(), info); + } + + public Collection>> allPrefixInfos() { + Collection>> values = new ArrayList<>(map.values()); + values.add(NO_PREFIX); + return values; + } + + public static final class TokenInfo { + private final String token; + private final PrefixInfo> prefixInfo; + + public TokenInfo(String token, PrefixInfo> prefixInfo) { + this.token = token; + this.prefixInfo = prefixInfo; + } + + public String token() { + return token; + } + + public PrefixInfo> prefixInfo() { + return prefixInfo; + } + } + + public Optional parseToken(String token) { + if (token.isEmpty()) { + return Optional.empty(); + } + char firstChar = token.charAt(0); + PrefixInfo> prefixInfo = map.get(firstChar); + if (prefixInfo == null || prefixInfo.getMode() == SearchMode.DISABLED) { + return Optional.of(new TokenInfo(token, NO_PREFIX)); + } + if (token.length() == 1) { + return Optional.empty(); + } + return Optional.of(new TokenInfo(token.substring(1), prefixInfo)); + } +} diff --git a/src/main/java/mezz/jei/search/ElementSearch.java b/src/main/java/mezz/jei/search/ElementSearch.java index 1b55a372e..6a194b40a 100644 --- a/src/main/java/mezz/jei/search/ElementSearch.java +++ b/src/main/java/mezz/jei/search/ElementSearch.java @@ -1,107 +1,83 @@ package mezz.jei.search; -import it.unimi.dsi.fastutil.ints.IntSet; +import com.google.common.collect.ImmutableSet; import mezz.jei.config.SearchMode; import mezz.jei.ingredients.IIngredientListElementInfo; -import mezz.jei.ingredients.IngredientFilterBackgroundBuilder; -import mezz.jei.ingredients.PrefixedSearchable; -import mezz.jei.search.suffixtree.GeneralizedSuffixTree; -import net.minecraft.util.NonNullList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; -import javax.annotation.Nullable; import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; -import java.util.List; import java.util.Map; +import java.util.Set; public class ElementSearch implements IElementSearch { - private final GeneralizedSuffixTree noPrefixSearchable; - private final Map> prefixedSearchables = new IdentityHashMap<>(); - private final IngredientFilterBackgroundBuilder backgroundBuilder; - private final CombinedSearchables combinedSearchables = new CombinedSearchables(); - /** - * indexed list of ingredients for use with the suffix trees - * includes all elements (even hidden ones) for use when rebuilding - */ - private final NonNullList> elementInfoList; + private static final Logger LOGGER = LogManager.getLogger(); - public ElementSearch() { - this.elementInfoList = NonNullList.create(); - this.noPrefixSearchable = new GeneralizedSuffixTree(); - this.backgroundBuilder = new IngredientFilterBackgroundBuilder(prefixedSearchables, elementInfoList); - this.combinedSearchables.addSearchable(noPrefixSearchable); - } + private final Map>, PrefixedSearchable>> prefixedSearchables = new IdentityHashMap<>(); + private final CombinedSearchables> combinedSearchables = new CombinedSearchables<>(); - @Override - public void start() { - this.backgroundBuilder.start(); + public ElementSearch(ElementPrefixParser elementPrefixParser) { + for (PrefixInfo> prefixInfo : elementPrefixParser.allPrefixInfos()) { + ISearchStorage> storage = prefixInfo.createStorage(); + PrefixedSearchable> prefixedSearchable = new PrefixedSearchable<>(storage, prefixInfo); + this.prefixedSearchables.put(prefixInfo, prefixedSearchable); + this.combinedSearchables.addSearchable(prefixedSearchable); + } } - @Nullable @Override - public IntSet getSearchResults(String token, PrefixInfo prefixInfo) { + public Set> getSearchResults(ElementPrefixParser.TokenInfo tokenInfo) { + String token = tokenInfo.token(); if (token.isEmpty()) { - return null; + return ImmutableSet.of(); } - final ISearchable searchable = this.prefixedSearchables.get(prefixInfo); - if (searchable != null && searchable.getMode() != SearchMode.DISABLED) { - return searchable.search(token); - } else { - return combinedSearchables.search(token); - } - } - @Override - public void add(IIngredientListElementInfo info) { - int index = this.elementInfoList.size(); - this.elementInfoList.add(info); + Set> results = Collections.newSetFromMap(new IdentityHashMap<>()); - { - Collection strings = PrefixInfo.NO_PREFIX.getStrings(info); - for (String string : strings) { - this.noPrefixSearchable.put(string, index); - } + PrefixInfo> prefixInfo = tokenInfo.prefixInfo(); + if (prefixInfo == ElementPrefixParser.NO_PREFIX) { + combinedSearchables.getSearchResults(token, results); + return results; + } + final ISearchable> searchable = this.prefixedSearchables.get(prefixInfo); + if (searchable == null || searchable.getMode() == SearchMode.DISABLED) { + combinedSearchables.getSearchResults(token, results); + return results; } + searchable.getSearchResults(token, results); + return results; + } - for (PrefixedSearchable prefixedSearchable : this.prefixedSearchables.values()) { + @Override + public void add(IIngredientListElementInfo info) { + for (PrefixedSearchable> prefixedSearchable : this.prefixedSearchables.values()) { SearchMode searchMode = prefixedSearchable.getMode(); if (searchMode != SearchMode.DISABLED) { Collection strings = prefixedSearchable.getStrings(info); - GeneralizedSuffixTree searchable = prefixedSearchable.getSearchable(); + ISearchStorage> searchable = prefixedSearchable.getSearchStorage(); for (String string : strings) { - searchable.put(string, index); + searchable.put(string, info); } } } } - @SuppressWarnings("unchecked") @Override - public IIngredientListElementInfo get(int index) { - return (IIngredientListElementInfo) this.elementInfoList.get(index); + public Set> getAllIngredients() { + Set> results = Collections.newSetFromMap(new IdentityHashMap<>()); + this.prefixedSearchables.get(ElementPrefixParser.NO_PREFIX).getAllElements(results); + return results; } @Override - public int indexOf(IIngredientListElementInfo ingredient) { - return this.elementInfoList.indexOf(ingredient); - } - - @Override - public int size() { - return this.elementInfoList.size(); - } - - @Override - public List> getAllIngredients() { - return Collections.unmodifiableList(this.elementInfoList); - } - - @Override - public void registerPrefix(PrefixInfo prefixInfo) { - final GeneralizedSuffixTree searchable = new GeneralizedSuffixTree(); - final PrefixedSearchable prefixedSearchable = new PrefixedSearchable<>(searchable, prefixInfo); - this.prefixedSearchables.put(prefixInfo, prefixedSearchable); - this.combinedSearchables.addSearchable(prefixedSearchable); + public void logStatistics() { + this.prefixedSearchables.forEach((prefixInfo, value) -> { + if (prefixInfo.getMode() != SearchMode.DISABLED) { + ISearchStorage> storage = value.getSearchStorage(); + LOGGER.info("ElementSearch {} Storage Stats: {}", prefixInfo, storage.statistics()); + } + }); } } diff --git a/src/main/java/mezz/jei/search/ElementSearchLowMem.java b/src/main/java/mezz/jei/search/ElementSearchLowMem.java index 3762c71fb..809004070 100644 --- a/src/main/java/mezz/jei/search/ElementSearchLowMem.java +++ b/src/main/java/mezz/jei/search/ElementSearchLowMem.java @@ -1,43 +1,41 @@ package mezz.jei.search; -import it.unimi.dsi.fastutil.ints.IntArraySet; -import it.unimi.dsi.fastutil.ints.IntSet; +import com.google.common.collect.ImmutableSet; import mezz.jei.gui.ingredients.IIngredientListElement; import mezz.jei.ingredients.IIngredientListElementInfo; import net.minecraft.util.NonNullList; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; -import javax.annotation.Nullable; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.stream.IntStream; +import java.util.Set; +import java.util.stream.Collectors; public class ElementSearchLowMem implements IElementSearch { + private static final Logger LOGGER = LogManager.getLogger(); + private final NonNullList> elementInfoList; public ElementSearchLowMem() { this.elementInfoList = NonNullList.create(); } - @Nullable @Override - public IntSet getSearchResults(String token, PrefixInfo prefixInfo) { + public Set> getSearchResults(ElementPrefixParser.TokenInfo tokenInfo) { + String token = tokenInfo.token(); if (token.isEmpty()) { - return null; + return ImmutableSet.of(); } - int[] results = IntStream.range(0, elementInfoList.size()) - .parallel() - .filter(i -> { - IIngredientListElementInfo elementInfo = elementInfoList.get(i); - return matches(token, prefixInfo, elementInfo); - }) - .toArray(); - - return new IntArraySet(results); + PrefixInfo> prefixInfo = tokenInfo.prefixInfo(); + return this.elementInfoList.stream() + .filter(elementInfo -> matches(token, prefixInfo, elementInfo)) + .collect(Collectors.toSet()); } - private static boolean matches(String word, PrefixInfo prefixInfo, IIngredientListElementInfo elementInfo) { + private static boolean matches(String word, PrefixInfo> prefixInfo, IIngredientListElementInfo elementInfo) { IIngredientListElement element = elementInfo.getElement(); if (element.isVisible()) { Collection strings = prefixInfo.getStrings(elementInfo); @@ -51,39 +49,17 @@ private static boolean matches(String word, PrefixInfo prefixInfo, IIngredientLi } @Override - public void add(IIngredientListElementInfo info) { + public void add(IIngredientListElementInfo info) { this.elementInfoList.add(info); } - @SuppressWarnings("unchecked") - @Override - public IIngredientListElementInfo get(int index) { - IIngredientListElementInfo info = this.elementInfoList.get(index); - return (IIngredientListElementInfo) info; - } - - @Override - public int indexOf(IIngredientListElementInfo ingredient) { - return this.elementInfoList.indexOf(ingredient); - } - - @Override - public int size() { - return this.elementInfoList.size(); - } - @Override public List> getAllIngredients() { return Collections.unmodifiableList(this.elementInfoList); } @Override - public void start() { - // noop - } - - @Override - public void registerPrefix(PrefixInfo prefixInfo) { - // noop + public void logStatistics() { + LOGGER.info("ElementSearchLowMem Element Count: {}", this.elementInfoList.size()); } } diff --git a/src/main/java/mezz/jei/search/IElementSearch.java b/src/main/java/mezz/jei/search/IElementSearch.java index 72da95e6d..a612d70ea 100644 --- a/src/main/java/mezz/jei/search/IElementSearch.java +++ b/src/main/java/mezz/jei/search/IElementSearch.java @@ -1,26 +1,17 @@ package mezz.jei.search; -import it.unimi.dsi.fastutil.ints.IntSet; import mezz.jei.ingredients.IIngredientListElementInfo; -import javax.annotation.Nullable; -import java.util.List; +import java.util.Collection; +import java.util.Set; public interface IElementSearch { - void add(IIngredientListElementInfo info); + void add(IIngredientListElementInfo info); - IIngredientListElementInfo get(int index); + Collection> getAllIngredients(); - int indexOf(IIngredientListElementInfo ingredient); + Set> getSearchResults(ElementPrefixParser.TokenInfo tokenInfo); - int size(); - - List> getAllIngredients(); - - @Nullable - IntSet getSearchResults(String token, PrefixInfo prefixInfo); - - void registerPrefix(PrefixInfo prefixInfo); - - void start(); -} + @SuppressWarnings("unused") // used for debugging + void logStatistics(); +} \ No newline at end of file diff --git a/src/main/java/mezz/jei/search/ISearchStorage.java b/src/main/java/mezz/jei/search/ISearchStorage.java new file mode 100644 index 000000000..7a5097c05 --- /dev/null +++ b/src/main/java/mezz/jei/search/ISearchStorage.java @@ -0,0 +1,13 @@ +package mezz.jei.search; + +import java.util.Set; + +public interface ISearchStorage { + void getSearchResults(String token, Set results); + + void getAllElements(Set results); + + void put(String key, T value); + + String statistics(); +} diff --git a/src/main/java/mezz/jei/search/ISearchable.java b/src/main/java/mezz/jei/search/ISearchable.java index aedfbdf8b..64e7249b4 100644 --- a/src/main/java/mezz/jei/search/ISearchable.java +++ b/src/main/java/mezz/jei/search/ISearchable.java @@ -1,10 +1,13 @@ package mezz.jei.search; -import it.unimi.dsi.fastutil.ints.IntSet; import mezz.jei.config.SearchMode; -public interface ISearchable { - IntSet search(String word); +import java.util.Set; + +public interface ISearchable { + void getSearchResults(String token, Set results); + + void getAllElements(Set results); default SearchMode getMode() { return SearchMode.ENABLED; diff --git a/src/main/java/mezz/jei/search/LimitedStringStorage.java b/src/main/java/mezz/jei/search/LimitedStringStorage.java new file mode 100644 index 000000000..f2ae28029 --- /dev/null +++ b/src/main/java/mezz/jei/search/LimitedStringStorage.java @@ -0,0 +1,52 @@ +package mezz.jei.search; + +import mezz.jei.collect.SetMultiMap; +import mezz.jei.search.suffixtree.GeneralizedSuffixTree; + +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +/** + * This is more memory-efficient than {@link GeneralizedSuffixTree} + * when there are many values for each key. + * + * It stores a map of keys to a set of values. + * The set values are shared with the internal {@link GeneralizedSuffixTree} to index and find them. + * The sets values are modified directly when values with the same key are added. + */ +public class LimitedStringStorage implements ISearchStorage { + private final SetMultiMap multiMap = new SetMultiMap<>(() -> Collections.newSetFromMap(new IdentityHashMap<>())); + private final GeneralizedSuffixTree> generalizedSuffixTree = new GeneralizedSuffixTree<>(); + + @Override + public void getSearchResults(String token, Set results) { + Set> intermediateResults = Collections.newSetFromMap(new IdentityHashMap<>()); + generalizedSuffixTree.getSearchResults(token, intermediateResults); + for (Set set : intermediateResults) { + results.addAll(set); + } + } + + @Override + public void getAllElements(Set results) { + Collection values = multiMap.allValues(); + results.addAll(values); + } + + @Override + public void put(String key, T value) { + boolean isNewKey = !multiMap.containsKey(key); + multiMap.put(key, value); + if (isNewKey) { + Set set = multiMap.get(key); + generalizedSuffixTree.put(key, set); + } + } + + @Override + public String statistics() { + return "LimitedStringStorage: " + generalizedSuffixTree.statistics(); + } +} diff --git a/src/main/java/mezz/jei/search/PrefixInfo.java b/src/main/java/mezz/jei/search/PrefixInfo.java index bfe384f3e..fad31d4bf 100644 --- a/src/main/java/mezz/jei/search/PrefixInfo.java +++ b/src/main/java/mezz/jei/search/PrefixInfo.java @@ -1,39 +1,51 @@ package mezz.jei.search; import mezz.jei.config.SearchMode; -import mezz.jei.ingredients.IIngredientListElementInfo; import java.util.Collection; -import java.util.Collections; +import java.util.function.Supplier; -public class PrefixInfo { - public static final PrefixInfo NO_PREFIX = new PrefixInfo( - () -> SearchMode.ENABLED, - i -> Collections.singleton(i.getName()) - ); +public class PrefixInfo { + private final char prefix; private final IModeGetter modeGetter; - private final IStringsGetter stringsGetter; + private final IStringsGetter stringsGetter; + private final Supplier> storageSupplier; - public PrefixInfo(IModeGetter modeGetter, IStringsGetter stringsGetter) { + public PrefixInfo(char prefix, IModeGetter modeGetter, IStringsGetter stringsGetter, Supplier> storageSupplier) { + this.prefix = prefix; this.modeGetter = modeGetter; this.stringsGetter = stringsGetter; + this.storageSupplier = storageSupplier; + } + + public char getPrefix() { + return prefix; } public SearchMode getMode() { return modeGetter.getMode(); } - public Collection getStrings(IIngredientListElementInfo element) { + public ISearchStorage createStorage() { + return this.storageSupplier.get(); + } + + public Collection getStrings(T element) { return this.stringsGetter.getStrings(element); } @FunctionalInterface - public interface IStringsGetter { - Collection getStrings(IIngredientListElementInfo element); + public interface IStringsGetter { + Collection getStrings(T element); } @FunctionalInterface public interface IModeGetter { SearchMode getMode(); } + + @Override + public String toString() { + return "PrefixInfo{" + prefix + '}'; + } } diff --git a/src/main/java/mezz/jei/search/PrefixedSearchable.java b/src/main/java/mezz/jei/search/PrefixedSearchable.java new file mode 100644 index 000000000..3cbf5be20 --- /dev/null +++ b/src/main/java/mezz/jei/search/PrefixedSearchable.java @@ -0,0 +1,39 @@ +package mezz.jei.search; + +import mezz.jei.config.SearchMode; + +import java.util.Collection; +import java.util.Set; + +public class PrefixedSearchable implements ISearchable { + private final ISearchStorage searchStorage; + private final PrefixInfo prefixInfo; + + public PrefixedSearchable(ISearchStorage searchStorage, PrefixInfo prefixInfo) { + this.searchStorage = searchStorage; + this.prefixInfo = prefixInfo; + } + + public ISearchStorage getSearchStorage() { + return searchStorage; + } + + public Collection getStrings(T element) { + return prefixInfo.getStrings(element); + } + + @Override + public SearchMode getMode() { + return prefixInfo.getMode(); + } + + @Override + public void getSearchResults(String token, Set results) { + searchStorage.getSearchResults(token, results); + } + + @Override + public void getAllElements(Set results) { + searchStorage.getAllElements(results); + } +} diff --git a/src/main/java/mezz/jei/search/suffixtree/Edge.java b/src/main/java/mezz/jei/search/suffixtree/Edge.java index d5efb5ff7..2a7ad2759 100644 --- a/src/main/java/mezz/jei/search/suffixtree/Edge.java +++ b/src/main/java/mezz/jei/search/suffixtree/Edge.java @@ -15,36 +15,24 @@ */ package mezz.jei.search.suffixtree; +import mezz.jei.util.SubString; + /** * Represents an Edge in the Suffix Tree. * It has a label and a destination Node *

* Edited by mezz: - * - formatting + * - optimized with SubString */ -class Edge { - private String label; - private final Node dest; - - public String getLabel() { - return label; - } - - public void setLabel(String label) { - this.label = label; - } +public class Edge extends SubString { + private final Node dest; - public Node getDest() { - return dest; - } - - public Edge(String label, Node dest) { - this.label = label; + public Edge(SubString subString, Node dest) { + super(subString); this.dest = dest; } - @Override - public String toString() { - return "Edge: " + label; + public Node getDest() { + return dest; } } diff --git a/src/main/java/mezz/jei/search/suffixtree/GeneralizedSuffixTree.java b/src/main/java/mezz/jei/search/suffixtree/GeneralizedSuffixTree.java index 64a3cc559..ec92306c7 100644 --- a/src/main/java/mezz/jei/search/suffixtree/GeneralizedSuffixTree.java +++ b/src/main/java/mezz/jei/search/suffixtree/GeneralizedSuffixTree.java @@ -15,16 +15,16 @@ */ package mezz.jei.search.suffixtree; -import javax.annotation.Nullable; -import java.util.Objects; +import mezz.jei.search.ISearchStorage; +import mezz.jei.util.Pair; +import mezz.jei.util.SubString; -import it.unimi.dsi.fastutil.ints.IntOpenHashSet; -import it.unimi.dsi.fastutil.ints.IntSet; -import mezz.jei.search.ISearchable; +import javax.annotation.Nullable; +import java.util.Set; /** * A Generalized Suffix Tree, based on the Ukkonen's paper "On-line construction of suffix trees" - * http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf + * On-line construction of suffix trees *

* Allows for fast storage and fast(er) retrieval by creating a tree-based index out of a set of strings. * Unlike common suffix trees, which are generally used to build an index out of one (very) long string, @@ -60,18 +60,17 @@ * - only allow full searches * - add nullable/nonnull annotations * - formatting + * - refactored and optimized */ -public class GeneralizedSuffixTree implements ISearchable { - - private int highestIndex = -1; +public class GeneralizedSuffixTree implements ISearchStorage { /** * The root of the suffix tree */ - private final Node root = new Node(); + private final RootNode root = new RootNode<>(); /** * The last leaf that was added during the update operation */ - private Node activeLeaf = root; + private Node activeLeaf = root; /** * Searches for the given word within the GST. @@ -80,56 +79,56 @@ public class GeneralizedSuffixTree implements ISearchable { * supplied as input. * * @param word the key to search for - * @return the collection of indexes associated with the input word + * @param results the indexes associated with the input word */ @Override - public IntSet search(String word) { - Node tmpNode = searchNode(word); + public void getSearchResults(String word, Set results) { + Node tmpNode = searchNode(root, word); if (tmpNode == null) { - return new IntOpenHashSet(); + return; } - IntSet ret = new IntOpenHashSet(1000); - tmpNode.getData(ret); - return ret; + tmpNode.getData(results); + } + + @Override + public void getAllElements(Set results) { + root.getData(results); } /** + * Verifies if exists a path from the root to a node such that the concatenation + * of all the labels on the path is a superstring of the given word. + * If such a path is found, the last node on it is returned. + *

* Returns the tree node (if present) that corresponds to the given string. */ @Nullable - private Node searchNode(String word) { - /* - * Verifies if exists a path from the root to a node such that the concatenation - * of all the labels on the path is a superstring of the given word. - * If such a path is found, the last node on it is returned. - */ - Node currentNode = root; - Edge currentEdge; - - for (int i = 0; i < word.length(); ++i) { - char ch = word.charAt(i); + private static Node searchNode(final Node root, final String word) { + Node currentNode = root; + SubString wordSubString = new SubString(word); + + while (!wordSubString.isEmpty()) { // follow the edge corresponding to this char - currentEdge = currentNode.getEdge(ch); - if (null == currentEdge) { + Edge currentEdge = currentNode.getEdge(wordSubString); + if (currentEdge == null) { // there is no edge starting with this char return null; - } else { - String label = currentEdge.getLabel(); - int lenToMatch = Math.min(word.length() - i, label.length()); - if (!word.regionMatches(i, label, 0, lenToMatch)) { - // the label on the edge does not correspond to the one in the string to search - return null; - } - - if (label.length() >= word.length() - i) { - return currentEdge.getDest(); - } else { - // advance to next node - currentNode = currentEdge.getDest(); - i += lenToMatch - 1; - } } + + int lenToMatch = Math.min(wordSubString.length(), currentEdge.length()); + if (!currentEdge.regionMatches(wordSubString, lenToMatch)) { + // the label on the edge does not correspond to the one in the string to search + return null; + } + if (lenToMatch == wordSubString.length()) { + // we found the edge we're looking for + return currentEdge.getDest(); + } + + // advance to next node + currentNode = currentEdge.getDest(); + wordSubString = wordSubString.substring(lenToMatch); } return null; @@ -137,41 +136,33 @@ private Node searchNode(String word) { /** * Adds the specified index to the GST under the given key. - *

- * Entries must be inserted so that their indexes are in non-decreasing order, - * otherwise an IllegalStateException will be raised. * * @param key the string key that will be added to the index - * @param index the value that will be added to the index + * @param value the value that will be added */ - public void put(String key, int index) throws IllegalStateException { - if (index < highestIndex) { - throw new IllegalStateException("The input index must not be less than any of the previously inserted ones. Got " + index + ", expected at least " + highestIndex); - } else { - highestIndex = index; - } - + @Override + public void put(String key, T value) { // reset activeLeaf activeLeaf = root; - Node s = root; + Node s = root; // proceed with tree construction (closely related to procedure in Ukkonen's paper) - String text = ""; + SubString text = new SubString(key, 0, 0); // iterate over the string, one char at a time for (int i = 0; i < key.length(); i++) { // line 6, line 7: update the tree with the new transitions due to this new char - Pair active = update(s, text, key.charAt(i), key.substring(i), index); + SubString rest = new SubString(key, i); + Pair, SubString> active = update(s, text, key.charAt(i), rest, value); - s = active.getFirst(); - text = active.getSecond(); + s = active.first(); + text = active.second(); } - // add leaf suffix link, is necessary + // add leaf suffix link, if necessary if (null == activeLeaf.getSuffix() && activeLeaf != root && activeLeaf != s) { activeLeaf.setSuffix(s); } - } /** @@ -184,107 +175,101 @@ public void put(String key, int index) throws IllegalStateException { * Then g will be split in two different edges, one having $end as label, and the other one * having rest as label. * - * @param inputs the starting node - * @param stringPart the string to search - * @param t the following character - * @param remainder the remainder of the string to add to the index - * @param value the value to add to the index + * @param startNode the starting node + * @param searchString the string to search + * @param t the following character + * @param remainder the remainder of the string to add to the index + * @param value the value to add to the index * @return a pair containing - * true/false depending on whether (stringPart + t) is contained in the subtree starting in inputs - * the last node that can be reached by following the path denoted by stringPart starting from inputs + * true/false depending on whether (stringPart + t) is contained in the subtree starting in inputNode + * the last node that can be reached by following the path denoted by stringPart starting from inputNode */ - private Pair testAndSplit(final Node inputs, final String stringPart, final char t, final String remainder, final int value) { - // descend the tree as far as possible - Pair ret = canonize(inputs, stringPart); - Node s = ret.getFirst(); - String str = ret.getSecond(); - - if (!"".equals(str)) { - Edge g = s.getEdge(str.charAt(0)); - Objects.requireNonNull(g); - String label = g.getLabel(); - // must see whether "str" is substring of the label of an edge - if (label.length() > str.length() && label.charAt(str.length()) == t) { - return new Pair<>(true, s); - } else { - // need to split the edge - String newlabel = label.substring(str.length()); - assert (label.startsWith(str)); - - // build a new node - Node r = new Node(); - // build a new edge - Edge newedge = new Edge(str, r); - - g.setLabel(newlabel); - - // link s -> r - r.addEdge(newlabel.charAt(0), g); - s.addEdge(str.charAt(0), newedge); + private static Pair> testAndSplit( + Node startNode, + SubString searchString, + final char t, + final SubString remainder, + final T value + ) { + assert !remainder.isEmpty(); + assert remainder.charAt(0) == t; - return new Pair<>(false, r); + // descend the tree as far as possible + Pair, SubString> canonizeResult = canonize(startNode, searchString); + startNode = canonizeResult.first(); + searchString = canonizeResult.second(); + + if (!searchString.isEmpty()) { + Edge g = startNode.getEdge(searchString); + assert g != null; + // must see whether "searchString" is substring of the label of an edge + if (g.length() > searchString.length() && g.charAt(searchString.length()) == t) { + return new Pair<>(true, startNode); } + Node newNode = splitNode(startNode, g, searchString); + return new Pair<>(false, newNode); + } - } else { - Edge e = s.getEdge(t); - if (null == e) { - // if there is no t-transtion from s - return new Pair<>(false, s); + Edge e = startNode.getEdge(remainder); + if (e == null) { + // if there is no t-transition from s + return new Pair<>(false, startNode); + } + + if (e.startsWith(remainder)) { + if (e.length() == remainder.length()) { + // update payload of destination node + Node dest = e.getDest(); + dest.addRef(value); + return new Pair<>(true, startNode); } else { - if (remainder.equals(e.getLabel())) { - // update payload of destination node - e.getDest().addRef(value); - return new Pair<>(true, s); - } else if (remainder.startsWith(e.getLabel())) { - return new Pair<>(true, s); - } else if (e.getLabel().startsWith(remainder)) { - // need to split as above - Node newNode = new Node(); - newNode.addRef(value); - - Edge newEdge = new Edge(remainder, newNode); - - e.setLabel(e.getLabel().substring(remainder.length())); - - newNode.addEdge(e.getLabel().charAt(0), e); - - s.addEdge(t, newEdge); - - return new Pair<>(false, s); - } else { - // they are different words. No prefix. but they may still share some common substr - return new Pair<>(true, s); - } + Node newNode = splitNode(startNode, e, remainder); + newNode.addRef(value); + return new Pair<>(false, startNode); } + } else { + return new Pair<>(true, startNode); } + } + + private static Node splitNode(Node s, Edge e, SubString splitFirstPart) { + assert e == s.getEdge(splitFirstPart); + assert e.startsWith(splitFirstPart); + assert e.length() > splitFirstPart.length(); + + // need to split the edge + SubString splitSecondPart = e.substring(splitFirstPart.length()); + + // build a new node r in between s and e.dest + Node r = new Node<>(); + // replace e with new first part pointing to r + s.addEdge(new Edge<>(splitFirstPart, r)); + // r is the new node sitting in between s and the original destination + r.addEdge(new Edge<>(splitSecondPart, e.getDest())); + return r; } /** - * Return a (Node, String) (n, remainder) pair such that n is a farthest descendant of + * Return a (Node, String) (n, remainder) pair such that n is the farthest descendant of * s (the input node) that can be reached by following a path of edges denoting - * a prefix of inputstr and remainder will be string that must be - * appended to the concatenation of labels from s to n to get inpustr. + * a prefix of input and remainder will be string that must be + * appended to the concatenation of labels from s to n to get input. */ - private Pair canonize(final Node s, final String inputstr) { - - if ("".equals(inputstr)) { - return new Pair<>(s, inputstr); - } else { - Node currentNode = s; - String str = inputstr; - Edge g = s.getEdge(str.charAt(0)); - // descend the tree as long as a proper label is found - while (g != null && str.startsWith(g.getLabel())) { - str = str.substring(g.getLabel().length()); - currentNode = g.getDest(); - if (str.length() > 0) { - g = currentNode.getEdge(str.charAt(0)); - } + private static Pair, SubString> canonize(Node currentNode, final SubString input) { + // descend the tree as long as a proper label is found + SubString remainder = input; + + while (!remainder.isEmpty()) { + Edge nextEdge = currentNode.getEdge(remainder); + if (nextEdge == null || !nextEdge.isPrefix(remainder)) { + break; } - - return new Pair<>(currentNode, str); + currentNode = nextEdge.getDest(); + remainder = remainder.substring(nextEdge.length()); } + + return new Pair<>(currentNode, remainder); } /** @@ -298,39 +283,45 @@ private Pair canonize(final Node s, final String inputstr) { * - the String will be the remainder that must be added to S1 to get the string * added so far. * - * @param inputNode the node to start from + * @param s the node to start from * @param stringPart the string to add to the tree * @param rest the rest of the string - * @param value the value to add to the index + * @param value the value to add */ - private Pair update(final Node inputNode, final String stringPart, final char newChar, final String rest, final int value) { - Node s = inputNode; - String tempstr = stringPart + newChar; + private Pair, SubString> update( + Node s, + final SubString stringPart, + final char newChar, + final SubString rest, + final T value + ) { + assert !rest.isEmpty(); + assert rest.charAt(0) == newChar; + + SubString k = stringPart.append(newChar); // line 1 - Node oldroot = root; + Node oldRoot = root; // line 1b - Pair ret = testAndSplit(s, stringPart, newChar, rest, value); + Pair> ret = testAndSplit(s, stringPart, newChar, rest, value); + Node r = ret.second(); + boolean endpoint = ret.first(); - Node r = ret.getSecond(); - boolean endpoint = ret.getFirst(); - - Node leaf; + Node leaf; // line 2 while (!endpoint) { // line 3 - Edge tempEdge = r.getEdge(newChar); - if (null != tempEdge) { + Edge tempEdge = r.getEdge(newChar); + if (tempEdge != null) { // such a node is already present. This is one of the main differences from Ukkonen's case: // the tree can contain deeper nodes at this stage because different strings were added by previous iterations. leaf = tempEdge.getDest(); } else { // must build a new leaf - leaf = new Node(); + leaf = new Node<>(); leaf.addRef(value); - Edge newedge = new Edge(rest, leaf); - r.addEdge(newChar, newedge); + r.addEdge(new Edge<>(rest, leaf)); } // update suffix link for newly created leaf @@ -340,75 +331,51 @@ private Pair update(final Node inputNode, final String stringPart, activeLeaf = leaf; // line 4 - if (oldroot != root) { - oldroot.setSuffix(r); + if (oldRoot != root) { + oldRoot.setSuffix(r); } // line 5 - oldroot = r; + oldRoot = r; // line 6 if (null == s.getSuffix()) { // root node assert (root == s); // this is a special case to handle what is referred to as node _|_ on the paper - tempstr = tempstr.substring(1); + k = k.substring(1); } else { - Pair canret = canonize(s.getSuffix(), safeCutLastChar(tempstr)); - s = canret.getFirst(); - tempstr = (canret.getSecond() + tempstr.charAt(tempstr.length() - 1)); + Pair, SubString> canonized = canonize(s.getSuffix(), safeCutLastChar(k)); + char nextChar = k.charAt(k.length() - 1); + s = canonized.first(); + k = canonized.second().append(nextChar); } // line 7 - ret = testAndSplit(s, safeCutLastChar(tempstr), newChar, rest, value); - r = ret.getSecond(); - endpoint = ret.getFirst(); - + ret = testAndSplit(s, safeCutLastChar(k), newChar, rest, value); + endpoint = ret.first(); + r = ret.second(); } // line 8 - if (oldroot != root) { - oldroot.setSuffix(r); + if (oldRoot != root) { + oldRoot.setSuffix(r); } // make sure the active pair is canonical - return canonize(s, tempstr); + return canonize(s, k); } - private static String safeCutLastChar(String seq) { - if (seq.length() == 0) { - return ""; + private static SubString safeCutLastChar(SubString subString) { + if (subString.length() == 0) { + return subString; } - return seq.substring(0, seq.length() - 1); + return subString.shorten(1); } - public int getHighestIndex() { - return highestIndex; - } - - /** - * A private class used to return a tuples of two elements - */ - private static class Pair { - - private final A first; - private final B second; - - public Pair(A first, B second) { - this.first = first; - this.second = second; - } - - public A getFirst() { - return first; - } - - public B getSecond() { - return second; - } - - @Override - public String toString() { - return "Pair (" + first + ", " + second + ")"; - } + @Override + public String statistics() { + return "GeneralizedSuffixTree:" + + "\nNode size stats: \n" + this.root.nodeSizeStats() + + "\nNode edge stats: \n" + this.root.nodeEdgeStats(); } } diff --git a/src/main/java/mezz/jei/search/suffixtree/Node.java b/src/main/java/mezz/jei/search/suffixtree/Node.java index 89daa36fd..7d19b82bc 100644 --- a/src/main/java/mezz/jei/search/suffixtree/Node.java +++ b/src/main/java/mezz/jei/search/suffixtree/Node.java @@ -17,11 +17,21 @@ import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableList; import it.unimi.dsi.fastutil.chars.Char2ObjectMap; +import it.unimi.dsi.fastutil.chars.Char2ObjectMaps; import it.unimi.dsi.fastutil.chars.Char2ObjectOpenHashMap; -import it.unimi.dsi.fastutil.ints.IntArrayList; -import it.unimi.dsi.fastutil.ints.IntList; -import it.unimi.dsi.fastutil.ints.IntSet; +import mezz.jei.util.SubString; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.IntSummaryStatistics; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; /** * Represents a node of the generalized suffix tree graph @@ -34,19 +44,20 @@ * - only allow full searches * - add nullable/nonnull annotations * - formatting + * - refactored and optimized */ -class Node { +class Node { /** * The payload array used to store the data (indexes) associated with this node. * In this case, it is used to store all property indexes. */ - private final IntList data; + private Collection data; /** * The set of edges starting from this node */ - private final Char2ObjectMap edges; + private Char2ObjectMap> edges; /** * The suffix link as described in Ukkonen's paper. @@ -54,26 +65,26 @@ class Node { * is the node denoted by the path that corresponds to str without the first char. */ @Nullable - private Node suffix; + private Node suffix; /** * Creates a new Node */ Node() { - edges = new Char2ObjectOpenHashMap<>(); + edges = Char2ObjectMaps.emptyMap(); + data = Collections.emptyList(); suffix = null; - data = new IntArrayList(0); } /** * Gets data from the payload of both this node and its children, the string representation * of the path to this node is a substring of the one of the children nodes. */ - void getData(final IntSet ret) { - ret.addAll(data); - - for (Edge e : edges.values()) { - e.getDest().getData(ret); + public void getData(Set results) { + results.addAll(this.data); + for (Edge edge : edges.values()) { + Node dest = edge.getDest(); + dest.getData(results); } } @@ -81,61 +92,129 @@ void getData(final IntSet ret) { * Adds the given index to the set of indexes associated with this * returns false if this node already contains the ref */ - boolean addRef(int index) { - if (contains(index)) { - return false; + void addRef(T value) { + if (contains(value)) { + return; } - addIndex(index); + addValue(value); // add this reference to all the suffixes as well - Node iter = this.suffix; + Node iter = this.suffix; while (iter != null) { - if (!iter.contains(index)) { - iter.addIndex(index); + if (!iter.contains(value)) { + iter.addValue(value); iter = iter.suffix; } else { break; } } - - return true; } /** * Tests whether a node contains a reference to the given index. * - * @param index the index to look for + * @param value the value to look for * @return true this contains a reference to index */ - private boolean contains(int index) { - return data.contains(index); + protected boolean contains(T value) { + return data.contains(value); } - void addEdge(char ch, Edge e) { - edges.put(ch, e); + void addEdge(Edge edge) { + char firstChar = edge.charAt(0); + + int size = edges.size(); + if (size == 0) { + edges = Char2ObjectMaps.singleton(firstChar, edge); + } else if (size == 1) { + Char2ObjectMap> newEdges = new Char2ObjectOpenHashMap<>(edges); + newEdges.put(firstChar, edge); + edges = newEdges; + } else { + edges.put(firstChar, edge); + } } @Nullable - Edge getEdge(char ch) { + Edge getEdge(char ch) { return edges.get(ch); } @Nullable - Node getSuffix() { + Edge getEdge(SubString string) { + if (string.isEmpty()) { + return null; + } + char ch = string.charAt(0); + return edges.get(ch); + } + + @Nullable + Node getSuffix() { return suffix; } - void setSuffix(Node suffix) { + void setSuffix(Node suffix) { this.suffix = suffix; } - private void addIndex(int index) { - data.add(index); + protected void addValue(T value) { + if (data.size() == 0) { + data = ImmutableList.of(value); + } else if (data.size() == 1) { + data = ImmutableList.of(data.iterator().next(), value); + } else if (data.size() == 2) { + List newData = new ArrayList<>(4); + newData.addAll(data); + newData.add(value); + data = newData; + } else if (data.size() == 16) { + // "upgrade" data to a Set once it's getting bigger, + // to improve its `contains` performance. + Collection newData = Collections.newSetFromMap(new IdentityHashMap<>()); + newData.addAll(data); + newData.add(value); + data = newData; + } else { + data.add(value); + } } @Override public String toString() { return "Node: size:" + data.size() + " Edges: " + edges.toString(); } + + public IntSummaryStatistics nodeSizeStats() { + return nodeSizes().summaryStatistics(); + } + + private IntStream nodeSizes() { + return IntStream.concat( + IntStream.of(data.size()), + edges.values().stream().flatMapToInt(e -> e.getDest().nodeSizes()) + ); + } + + public String nodeEdgeStats() { + IntSummaryStatistics edgeCounts = nodeEdgeCounts().summaryStatistics(); + IntSummaryStatistics edgeLengths = nodeEdgeLengths().summaryStatistics(); + return "Edge counts: " + edgeCounts + + "\nEdge lengths: " + edgeLengths; + } + + private IntStream nodeEdgeCounts() { + return IntStream.concat( + IntStream.of(edges.size()), + edges.values().stream().map(Edge::getDest).flatMapToInt(Node::nodeEdgeCounts) + ); + } + + private IntStream nodeEdgeLengths() { + return IntStream.concat( + edges.values().stream().mapToInt(Edge::length), + edges.values().stream().map(Edge::getDest).flatMapToInt(Node::nodeEdgeLengths) + ); + } } diff --git a/src/main/java/mezz/jei/search/suffixtree/RootNode.java b/src/main/java/mezz/jei/search/suffixtree/RootNode.java new file mode 100644 index 000000000..cc6af1dc8 --- /dev/null +++ b/src/main/java/mezz/jei/search/suffixtree/RootNode.java @@ -0,0 +1,18 @@ +package mezz.jei.search.suffixtree; + +/** + * The root node can have a lot of values added to it because so many suffix links point to it. + * The values are never read from here though. + * This class makes sure we don't accumulate a ton of useless values in the root node. + */ +public class RootNode extends Node { + @Override + protected boolean contains(T value) { + return true; + } + + @Override + protected void addValue(T value) { + // noop + } +} diff --git a/src/main/java/mezz/jei/util/Pair.java b/src/main/java/mezz/jei/util/Pair.java new file mode 100644 index 000000000..5e119a420 --- /dev/null +++ b/src/main/java/mezz/jei/util/Pair.java @@ -0,0 +1,19 @@ +package mezz.jei.util; + +public class Pair { + private final A first; + private final B second; + + public Pair(A first, B second) { + this.first = first; + this.second = second; + } + + public A first() { + return first; + } + + public B second() { + return second; + } +} diff --git a/src/main/java/mezz/jei/util/SubString.java b/src/main/java/mezz/jei/util/SubString.java new file mode 100644 index 000000000..a4e20627b --- /dev/null +++ b/src/main/java/mezz/jei/util/SubString.java @@ -0,0 +1,92 @@ +package mezz.jei.util; + +import javax.annotation.Nonnegative; + +public class SubString { + private final String string; + private final int offset; + private final int length; + + public SubString(String string) { + this(string, 0, string.length()); + } + + public SubString(SubString subString) { + this(subString.string, subString.offset, subString.length); + } + + public SubString(String string, int offset) { + this(string, offset, string.length() - offset); + } + + @SuppressWarnings("ConstantConditions") + public SubString(String string, @Nonnegative int offset, @Nonnegative int length) { + assert length >= 0; + assert offset >= 0; + assert offset + length <= string.length(); + + this.string = string; + this.offset = offset; + this.length = length; + } + + public SubString substring(int offset) { + return new SubString(string, this.offset + offset, this.length - offset); + } + + public SubString shorten(int amount) { + return new SubString(string, this.offset, this.length - amount); + } + + public SubString append(char newChar) { + assert this.offset + this.length < this.string.length(); + assert charAt(this.length) == newChar; + + return new SubString(string, this.offset, this.length + 1); + } + + public boolean isEmpty() { + return this.length == 0; + } + + public char charAt(int index) { + return this.string.charAt(this.offset + index); + } + + public boolean regionMatches(int toffset, String other, int ooffset, int len) { + //noinspection StringEquality + if (this.string == other) { + if (this.length >= len && (this.offset + toffset == ooffset)) { + return true; + } + } + return this.string.regionMatches(this.offset + toffset, other, ooffset, len); + } + + public boolean regionMatches(SubString word, int lenToMatch) { + if (lenToMatch > this.length) { + return false; + } + return word.regionMatches(0, this.string, this.offset, lenToMatch); + } + + public boolean isPrefix(SubString other) { + return other.startsWith(this); + } + + public boolean startsWith(SubString other) { + return regionMatches(other, other.length()); + } + + @Nonnegative + public int length() { + return length; + } + + @Override + public String toString() { + return this.getClass().getSimpleName() + ": \"" + + string.substring(this.offset, this.offset + this.length) + + "\"\nBacking string: \"" + string + "\""; + } +} diff --git a/src/test/java/mezz/jei/test/search/suffixtree/GeneralizedSuffixTreeTest.java b/src/test/java/mezz/jei/test/search/suffixtree/GeneralizedSuffixTreeTest.java new file mode 100644 index 000000000..ee1e05eca --- /dev/null +++ b/src/test/java/mezz/jei/test/search/suffixtree/GeneralizedSuffixTreeTest.java @@ -0,0 +1,154 @@ +package mezz.jei.test.search.suffixtree; + +import com.google.common.collect.ImmutableSet; +import mezz.jei.search.suffixtree.GeneralizedSuffixTree; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +public class GeneralizedSuffixTreeTest { + + private static Set search(GeneralizedSuffixTree tree, String word) { + Set results = new HashSet<>(); + tree.getSearchResults(word, results); + return results; + } + + @Test + public void testSearch() { + GeneralizedSuffixTree tree = new GeneralizedSuffixTree<>(); + + tree.put("a", 0); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "a")); + + tree.put("ab", 1); + Assertions.assertEquals(ImmutableSet.of(1), search(tree, "ab")); + Assertions.assertEquals(ImmutableSet.of(1), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(0, 1), search(tree, "a")); + + tree.put("cab", 2); + Assertions.assertEquals(ImmutableSet.of(2), search(tree, "cab")); + Assertions.assertEquals(ImmutableSet.of(2), search(tree, "ca")); + Assertions.assertEquals(ImmutableSet.of(2), search(tree, "c")); + Assertions.assertEquals(ImmutableSet.of(1, 2), search(tree, "ab")); + Assertions.assertEquals(ImmutableSet.of(1, 2), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(0, 1, 2), search(tree, "a")); + + tree.put("abcabxabcd", 3); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abcabxabcd")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abcabxabc")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abcabxab")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abcabxa")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abcabx")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abcab")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abca")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abc")); + + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bcabxabcd")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bcabxabc")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bcabxab")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bcabxa")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bcabx")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bcab")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bca")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bc")); + + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "cabxabcd")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "cabxabc")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "cabxab")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "cabxa")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "cabx")); + + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abxabcd")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abxabc")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abxab")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abxa")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abx")); + + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bxabcd")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bxabc")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bxab")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bxa")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bx")); + + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "xabcd")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "xabc")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "xab")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "xa")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "x")); + + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abcd")); + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "abc")); + + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "bcd")); + + Assertions.assertEquals(ImmutableSet.of(3), search(tree, "d")); + + Assertions.assertEquals(ImmutableSet.of(2, 3), search(tree, "cab")); + Assertions.assertEquals(ImmutableSet.of(2, 3), search(tree, "ca")); + Assertions.assertEquals(ImmutableSet.of(2, 3), search(tree, "c")); + + Assertions.assertEquals(ImmutableSet.of(1, 2, 3), search(tree, "ab")); + Assertions.assertEquals(ImmutableSet.of(1, 2, 3), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(0, 1, 2, 3), search(tree, "a")); + } + + @Test + public void testPuttingSameString() { + GeneralizedSuffixTree tree = new GeneralizedSuffixTree<>(); + + tree.put("ab", 0); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "a")); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "ab")); + + tree.put("ab", 1); + Assertions.assertEquals(ImmutableSet.of(0, 1), search(tree, "a")); + Assertions.assertEquals(ImmutableSet.of(0, 1), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(0, 1), search(tree, "ab")); + } + + @Test + public void testPuttingShorterString() { + GeneralizedSuffixTree tree = new GeneralizedSuffixTree<>(); + + tree.put("ab", 0); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "a")); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "ab")); + + tree.put("a", 1); + Assertions.assertEquals(ImmutableSet.of(0, 1), search(tree, "a")); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(0), search(tree, "ab")); + } + + @Test + public void testNonMatchingSearches() { + GeneralizedSuffixTree tree = new GeneralizedSuffixTree<>(); + + tree.put("ab", 0); + Assertions.assertEquals(ImmutableSet.of(), search(tree, "")); + Assertions.assertEquals(ImmutableSet.of(), search(tree, "abc")); + Assertions.assertEquals(ImmutableSet.of(), search(tree, "ac")); + Assertions.assertEquals(ImmutableSet.of(), search(tree, "ba")); + Assertions.assertEquals(ImmutableSet.of(), search(tree, "c")); + } + + @Test + public void testIndexWorksOutOfOrder() { + GeneralizedSuffixTree tree = new GeneralizedSuffixTree<>(); + + tree.put("ab", 10); + Assertions.assertEquals(ImmutableSet.of(10), search(tree, "a")); + Assertions.assertEquals(ImmutableSet.of(10), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(10), search(tree, "ab")); + + tree.put("a", 5); + Assertions.assertEquals(ImmutableSet.of(10, 5), search(tree, "a")); + Assertions.assertEquals(ImmutableSet.of(10), search(tree, "b")); + Assertions.assertEquals(ImmutableSet.of(10), search(tree, "ab")); + } +} diff --git a/src/test/java/mezz/jei/test/util/SubStringTest.java b/src/test/java/mezz/jei/test/util/SubStringTest.java new file mode 100644 index 000000000..43cf288dd --- /dev/null +++ b/src/test/java/mezz/jei/test/util/SubStringTest.java @@ -0,0 +1,234 @@ +package mezz.jei.test.util; + +import com.google.common.collect.ImmutableList; +import mezz.jei.util.SubString; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.List; + +public class SubStringTest { + @Test + public void testEmptyString() { + SubString subString = new SubString(""); + Assertions.assertTrue(subString.isEmpty()); + } + + @Test + public void testEmptyBySubStringOffset() { + SubString subString = new SubString("a", 1); + Assertions.assertTrue(subString.isEmpty()); + } + + @Test + public void testEmptyBySubStringLength() { + SubString subString = new SubString("a", 0, 0); + Assertions.assertTrue(subString.isEmpty()); + } + + @Test + public void testCharAt() { + String string = "abcdefg"; + SubString subString = new SubString(string); + for (int i = 0; i < string.length(); i++) { + Assertions.assertEquals(string.charAt(i), subString.charAt(i)); + } + } + + @Test + public void testRegionMatchesSameString() { + String string = "abcdefg"; + SubString subString = new SubString(string); + for (int start = 0; start < string.length(); start++) { + for (int end = string.length() - start; end >= 0; end--) { + Assertions.assertTrue(subString.regionMatches(start, string, start, end)); + } + } + } + + @Test + public void testRegionMatchesWithZeroLength() { + String string = "abcdefg"; + + // make sure we follow the same rules as regular strings + Assertions.assertTrue(string.regionMatches(0, "", 0, 0)); + Assertions.assertTrue("".regionMatches(0, string, 0, 0)); + + SubString subString = new SubString(string); + for (int start = 0; start < string.length(); start++) { + Assertions.assertTrue(subString.regionMatches(start, string, start, 0)); + Assertions.assertTrue(subString.regionMatches(start, "", 0, 0)); + } + + SubString emptySubString = new SubString(string, 0, 0); + for (int start = 0; start < string.length(); start++) { + Assertions.assertTrue(emptySubString.regionMatches(start, string, start, 0)); + } + } + + @Test + public void testRegionMatchesSameSubString() { + String string = "abcdefg"; + SubString subString = new SubString(string); + SubString copy = new SubString(subString); + for (int end = string.length(); end >= 0; end--) { + Assertions.assertTrue(subString.regionMatches(copy, end)); + } + } + + @Test + public void testRegionMatchesDifferentString() { + String string = "abcdefg"; + SubString subString = new SubString("123abcdefg456"); + for (int start = 0; start < string.length(); start++) { + for (int end = string.length() - start; end >= 0; end--) { + Assertions.assertTrue(subString.regionMatches(start + 3, string, start, end)); + } + } + } + + @Test + public void testRegionMatchesWithOffset() { + String string = "abcdefg"; + SubString subString = new SubString("123abcdefg", 3); + for (int start = 0; start < string.length(); start++) { + for (int end = string.length() - start; end >= 0; end--) { + Assertions.assertTrue(subString.regionMatches(start, string, start, end)); + } + } + } + + @Test + public void testRegionMatchesFails() { + String string = "abcdefg"; + SubString subString = new SubString("abcdef3"); + Assertions.assertFalse(subString.regionMatches(0, string, 0, string.length())); + } + + @Test + public void testSubstringOffset() { + String string = "abcdefg"; + SubString subString = new SubString(string); + for (int offsetAmount = 0; offsetAmount < string.length(); offsetAmount++) { + SubString offsetString = subString.substring(offsetAmount); + Assertions.assertEquals(offsetString.length(), subString.length() - offsetAmount); + + for (int i = 0; i < offsetString.length(); i++) { + Assertions.assertEquals(string.charAt(i + offsetAmount), offsetString.charAt(i)); + } + } + } + + @Test + public void testInvalidSubstringOffset() { + String string = "abcdefg"; + SubString subString = new SubString(string); + Assertions.assertThrows(AssertionError.class, () -> subString.substring(string.length() + 1)); + } + + @Test + public void testAppend() { + String string = "abcdefg"; + SubString subString = new SubString(string, 0, 0); + for (int i = 0; i < string.length(); i++) { + char c = string.charAt(i); + subString = subString.append(c); + Assertions.assertEquals(c, subString.charAt(i)); + } + } + + @Test + public void testInvalidAppend() { + SubString subString = new SubString("abc"); + Assertions.assertThrows(AssertionError.class, () -> subString.append('c')); + } + + @Test + public void testShorten() { + String string = "abc"; + SubString subString = new SubString(string); + Assertions.assertEquals(3, subString.length()); + Assertions.assertTrue(subString.regionMatches(0, string, 0, 3)); + + subString = subString.shorten(1); + Assertions.assertEquals(2, subString.length()); + Assertions.assertTrue(subString.regionMatches(0, string, 0, 2)); + + subString = subString.shorten(2); + Assertions.assertTrue(subString.isEmpty()); + } + + @Test + public void testIsPrefixAndStartsWith() { + List subStrings = ImmutableList.of( + new SubString("abcdefg"), + new SubString("1abcdefg", 1), + new SubString("1abcdefg2", 1, 7), + new SubString("abcdefg12", 0, 7) + ); + List validPrefixes = ImmutableList.of( + new SubString("abcd123", 0, 4), + new SubString("abcd"), + new SubString("abcdefg"), + new SubString("1abcd23", 1, 4) + ); + for (SubString subString : subStrings) { + for (SubString validPrefix : validPrefixes) { + Assertions.assertTrue(subString.startsWith(validPrefix), subString + "\n" + validPrefix); + Assertions.assertTrue(validPrefix.isPrefix(subString), subString + "\n" + validPrefix); + } + } + } + + @Test + public void testNotIsPrefixAndStartsWith() { + List subStrings = ImmutableList.of( + new SubString("abcdefg"), + new SubString("1abcdefg", 1), + new SubString("1abcdefg2", 1, 7), + new SubString("abcdefg12", 0, 7) + ); + List invalidPrefixes = ImmutableList.of( + new SubString("abcdefgh"), + new SubString("abcd123", 0, 5), + new SubString("abcdf"), + new SubString("1abcd23", 0, 4) + ); + for (SubString subString : subStrings) { + for (SubString invalidPrefix : invalidPrefixes) { + Assertions.assertFalse(subString.startsWith(invalidPrefix), subString + "\n" + invalidPrefix); + Assertions.assertFalse(invalidPrefix.isPrefix(subString), subString + "\n" + invalidPrefix); + } + } + } + + @Test + public void testEmptyPrefix() { + // make sure we follow the same rules as regular strings + //noinspection ConstantConditions + Assertions.assertTrue("abcdefg".startsWith("")); + //noinspection ConstantConditions,MismatchedStringCase + Assertions.assertFalse("".startsWith("abcdefg")); + + List subStrings = ImmutableList.of( + new SubString("abcdefg"), + new SubString("1abcdefg", 1), + new SubString("1abcdefg2", 1, 7), + new SubString("abcdefg12", 0, 7) + ); + List emptyPrefixes = ImmutableList.of( + new SubString(""), + new SubString("abcd", 0, 0), + new SubString("abcd", 1, 0) + ); + for (SubString subString : subStrings){ + for (SubString emptyPrefix : emptyPrefixes) { + Assertions.assertTrue(subString.startsWith(emptyPrefix), subString + "\n" + emptyPrefix); + Assertions.assertFalse(emptyPrefix.startsWith(subString), subString + "\n" + emptyPrefix); + + Assertions.assertFalse(subString.isPrefix(emptyPrefix), subString + "\n" + emptyPrefix); + Assertions.assertTrue(emptyPrefix.isPrefix(subString), subString + "\n" + emptyPrefix); + } + } + } +}