diff --git a/gradle.properties b/gradle.properties index f0b00eda..d3c8a17b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,7 @@ version=3.0.0-SNAPSHOT springBootVersion=3.0.0-M5 nativeBuildToolsVersion=0.9.14 +commonsIoVersion=2.11.0 jlineVersion=3.21.0 st4Version=4.3.1 jimfsVersion=1.2 diff --git a/spring-shell-core/spring-shell-core.gradle b/spring-shell-core/spring-shell-core.gradle index bc3c8dcf..b265c03b 100644 --- a/spring-shell-core/spring-shell-core.gradle +++ b/spring-shell-core/spring-shell-core.gradle @@ -12,6 +12,7 @@ dependencies { api('org.springframework:spring-messaging') api('org.jline:jline') api('org.antlr:ST4') + api('commons-io:commons-io') compileOnly 'com.google.code.findbugs:jsr305' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.awaitility:awaitility' diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/PathSearch.java b/spring-shell-core/src/main/java/org/springframework/shell/component/PathSearch.java new file mode 100644 index 00000000..b4cb3271 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/PathSearch.java @@ -0,0 +1,661 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.component; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.commons.io.file.AccumulatorPathVisitor; +import org.apache.commons.io.file.Counters; +import org.apache.commons.io.filefilter.IOFileFilter; +import org.apache.commons.io.filefilter.NotFileFilter; +import org.apache.commons.io.filefilter.WildcardFileFilter; +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; +import org.jline.utils.InfoCmp.Capability; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.shell.component.PathSearch.PathSearchContext; +import org.springframework.shell.component.PathSearch.PathSearchContext.PathViewItem; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.AbstractTextComponent; +import org.springframework.shell.component.support.Nameable; +import org.springframework.shell.component.support.SelectorList; +import org.springframework.shell.component.support.AbstractTextComponent.TextComponentContext; +import org.springframework.shell.component.support.AbstractTextComponent.TextComponentContext.MessageLevel; +import org.springframework.shell.style.PartsText; +import org.springframework.shell.style.PartsText.PartText; +import org.springframework.shell.support.search.SearchMatch; +import org.springframework.shell.support.search.SearchMatchResult; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import static org.jline.keymap.KeyMap.ctrl; +import static org.jline.keymap.KeyMap.key; + +/** + * Component resolving {@link Path} based on base path and optional search term. + * User is expected to type a base path and then delimited by space and a search + * term. + * + * Based on algorithms i.e. from https://github.com/junegunn/fzf and other + * sources. + * + * @author Janne Valkealahti + */ +public class PathSearch extends AbstractTextComponent { + + private final static Logger log = LoggerFactory.getLogger(PathSearch.class); + private final static String DEFAULT_TEMPLATE_LOCATION = "classpath:org/springframework/shell/component/path-search-default.stg"; + private final PathSearchConfig config; + private PathSearchContext currentContext; + private Function pathProvider = (path) -> Paths.get(path); + private final SelectorList selectorList; + + public PathSearch(Terminal terminal) { + this(terminal, null); + } + + public PathSearch(Terminal terminal, String name) { + this(terminal, name, null); + } + + public PathSearch(Terminal terminal, String name, PathSearchConfig config) { + this(terminal, name, config, null); + } + + public PathSearch(Terminal terminal, String name, PathSearchConfig config, + Function> renderer) { + super(terminal, name, null); + setRenderer(renderer != null ? renderer : new DefaultRenderer()); + setTemplateLocation(DEFAULT_TEMPLATE_LOCATION); + this.config = config != null ? config : new PathSearchConfig(); + this.selectorList = SelectorList.of(this.config.getMaxPathsShow()); + } + + @Override + protected void bindKeyMap(KeyMap keyMap) { + super.bindKeyMap(keyMap); + + // additional binding what parent gives us + keyMap.bind(OPERATION_DOWN, ctrl('E'), key(getTerminal(), Capability.key_down)); + keyMap.bind(OPERATION_UP, ctrl('Y'), key(getTerminal(), Capability.key_up)); + } + + @Override + public PathSearchContext getThisContext(ComponentContext context) { + if (context != null && currentContext == context) { + return currentContext; + } + currentContext = PathSearchContext.empty(); + currentContext.setName(getName()); + currentContext.setTerminalWidth(getTerminal().getWidth()); + currentContext.setPathSearchConfig(this.config); + currentContext.setMessage("Type ' ' to search", MessageLevel.INFO); + context.stream().forEach(e -> { + currentContext.put(e.getKey(), e.getValue()); + }); + return currentContext; + } + + @Override + protected boolean read(BindingReader bindingReader, KeyMap keyMap, PathSearchContext context) { + String operation = bindingReader.readBinding(keyMap); + log.debug("Binding read result {}", operation); + if (operation == null) { + return true; + } + String input; + switch (operation) { + case OPERATION_CHAR: + String lastBinding = bindingReader.getLastBinding(); + input = context.getInput(); + if (input == null) { + input = lastBinding; + } + else { + input = input + lastBinding; + } + context.setInput(input); + inputUpdated(context, input); + break; + case OPERATION_BACKSPACE: + input = context.getInput(); + if (StringUtils.hasLength(input)) { + input = input.length() > 1 ? input.substring(0, input.length() - 1) : null; + } + context.setInput(input); + inputUpdated(context, input); + break; + case OPERATION_EXIT: + PathViewItem selected = selectorList.getSelected(); + if (selected != null) { + context.setResultValue(selected.getPath()); + } + return true; + case OPERATION_UP: + selectorList.scrollUp(); + selectorListUpdated(context); + break; + case OPERATION_DOWN: + selectorList.scrollDown(); + selectorListUpdated(context); + break; + default: + break; + } + return false; + } + + /** + * Sets a path provider. + * + * @param pathProvider the path provider + */ + public void setPathProvider(Function pathProvider) { + this.pathProvider = pathProvider; + } + + /** + * Resolves a {@link Path} from a given raw {@code path}. + * + * @param path the raw path + * @return a resolved path + */ + protected Path resolvePath(String path) { + return this.pathProvider.apply(path); + } + + private void inputUpdated(PathSearchContext context, String input) { + context.setMessage("Type ' ' to search", MessageLevel.INFO); + updateSelectorList(input, context); + selectorListUpdated(context); + } + + private void selectorListUpdated(PathSearchContext context) { + List pathViews = selectorList.getProjection().stream() + .map(i -> { + return new PathViewItem(i.getItem().getPath(), i.getItem().getPartsText(), i.isSelected()); + }) + .collect(Collectors.toList()); + context.setPathViewItems(pathViews); + } + + private void updateSelectorList(String path, PathSearchContext context) { + if (path == null) { + // when user removes all input + this.selectorList.reset(Collections.emptyList()); + return; + } + PathScannerResult result = this.config.pathScanner.get().apply(path, context); + List items = result.getScoredPaths().stream() + .filter(scoredPath -> { + if (result.hasFilter()) { + return scoredPath.result.getScore() > 0; + } + else { + return true; + } + }) + .map(scoredPath -> { + int[] positions = scoredPath.getResult().getPositions(); + String text = scoredPath.getPath().toString(); + if (!StringUtils.hasText(text)) { + text = "."; + } + PartsText partsText = PathSearchContext.ofNameMatchPartsx(text, positions); + PathViewItem item = new PathViewItem(scoredPath.getPath(), partsText, false); + return item; + }) + .collect(Collectors.toList()); + + long total = result.getDirCount() + result.getFileCount(); + if (total > -1) { + int found = items.size(); + String message = String.format(", %s/%s", found, total); + context.setMessage("Type ' ' to search" + message, MessageLevel.INFO); + } + + selectorList.reset(items); + } + + /** + * Class defining configuration for path search. + */ + public static class PathSearchConfig { + + private int maxPathsShow = 5; + private int maxPathsSearch = 20; + private boolean searchForward = true; + private boolean searchCaseSensitive = false; + private boolean searchNormalize = false; + private Supplier> pathScanner = () -> DefaultPathScanner.of(); + + public int getMaxPathsShow() { + return this.maxPathsShow; + } + + public void setMaxPathsShow(int maxPathsShow) { + Assert.state(maxPathsShow > 0 || maxPathsShow < 33, "maxPathsShow has to be between 1 and 32"); + this.maxPathsShow = maxPathsShow; + } + + public int getMaxPathsSearch() { + return this.maxPathsSearch; + } + + public void setMaxPathsSearch(int maxPathsSearch) { + Assert.state(maxPathsSearch > 0, "maxPathsSearch has to be more than 0"); + this.maxPathsSearch = maxPathsSearch; + } + + public void setPathScanner(Supplier> pathScanner) { + Assert.notNull(pathScanner, "pathScanner supplier cannot be null"); + this.pathScanner = pathScanner; + } + + public boolean isSearchForward() { + return searchForward; + } + + public void setSearchForward(boolean searchForward) { + this.searchForward = searchForward; + } + + public boolean isSearchCaseSensitive() { + return searchCaseSensitive; + } + + public void setSearchCaseSensitive(boolean searchCaseSensitive) { + this.searchCaseSensitive = searchCaseSensitive; + } + + public boolean isSearchNormalize() { + return searchNormalize; + } + + public void setSearchNormalize(boolean searchNormalize) { + this.searchNormalize = searchNormalize; + } + } + + /** + * Result from a path scanning. + */ + public static class PathScannerResult { + + private final List scoredPaths; + private long dirCount = -1; + private long fileCount = -1; + private boolean hasFilter = false; + + PathScannerResult(List scoredPaths, long dirCount, long fileCount, boolean hasFilter) { + Assert.notNull(scoredPaths, "Scored paths cannot be null"); + this.scoredPaths = scoredPaths; + this.dirCount = dirCount; + this.fileCount = fileCount; + this.hasFilter = hasFilter; + } + + public static PathScannerResult of(List scoredPaths, boolean hasFilter) { + return new PathScannerResult(scoredPaths, -1, -1, hasFilter); + } + + public static PathScannerResult of(List scoredPaths, long dirCount, long fileCount, boolean hasFilter) { + return new PathScannerResult(scoredPaths, dirCount, fileCount, hasFilter); + } + + public List getScoredPaths() { + return scoredPaths; + } + + public long getDirCount() { + return dirCount; + } + + public long getFileCount() { + return fileCount; + } + + public boolean hasFilter() { + return hasFilter; + } + } + + /** + * Context for {@link PathSearch}. + */ + public interface PathSearchContext extends TextComponentContext { + + /** + * Gets a path view items. + * + * @return path view items + */ + List getPathViewItems(); + + /** + * Sets a path view items. + * + * @param items the path view items + */ + void setPathViewItems(List items); + + /** + * Get path search config. + * + * @return a path search config + */ + PathSearchConfig getPathSearchConfig(); + + /** + * Sets a path search config. + * + * @param config a path search config + */ + void setPathSearchConfig(PathSearchConfig config); + + /** + * Gets an empty {@link PathSearchContext}. + * + * @return empty path search context + */ + public static PathSearchContext empty() { + return new DefaultPathSearchContext(); + } + + /** + * Domain class for path view item. Having its index, name(path), ref to cursor + * row index and list of name match parts. + */ + public static class PathViewItem implements Nameable { + + private Path path; + private PartsText partsText; + private boolean selected; + + public PathViewItem(Path path, PartsText partsText, boolean selected) { + this.path = path; + this.partsText = partsText; + this.selected = selected; + } + + @Override + public String getName() { + return path.toString(); + } + + public Path getPath() { + return path; + } + + public boolean isSelected() { + return selected; + } + + public PartsText getPartsText() { + return partsText; + } + } + + + /** + * Split given text into {@link PartText}'s by given positions. + * + * @param text the text to split + * @param positions the positions array, expected to be ordered and no duplicates + * @return parts text + */ + public static PartsText ofNameMatchPartsx(String text, int[] positions) { + List parts = new ArrayList<>(); + if (positions.length == 0) { + parts.addAll(nameMatchPartsx(text, -1)); + } + else if (positions.length == 1 && positions[0] == text.length()) { + parts.addAll(nameMatchPartsx(text, text.length() - 1)); + } + else { + int sidx = 0; + int eidx = 0; + for (int i = 0; i < positions.length; i++) { + eidx = positions[i]; + if (sidx < text.length()) { + String partText = text.substring(sidx, eidx + 1); + parts.addAll(nameMatchPartsx(partText, eidx - sidx)); + } + else { + parts.addAll(nameMatchPartsx(String.valueOf(text.charAt(text.length() - 1)), 0)); + } + sidx = eidx + 1; + } + if (sidx < text.length()) { + String partText = text.substring(sidx, text.length()); + parts.addAll(nameMatchPartsx(partText, -1)); + } + } + return PartsText.of(parts); + } + + static List nameMatchPartsx(String text, int position) { + List parts = new ArrayList<>(); + if (position < 0) { + parts.add(PartText.of(text, false)); + } + else { + if (position == 0) { + if (text.length() == 1) { + parts.add(PartText.of(String.valueOf(text.charAt(0)), true)); + } + else { + parts.add(PartText.of(String.valueOf(text.charAt(0)), true)); + parts.add(PartText.of(text.substring(1, text.length()), false)); + } + } + else if (position == text.length() - 1) { + parts.add(PartText.of(text.substring(0, text.length() - 1), false)); + parts.add(PartText.of(String.valueOf(text.charAt(text.length() - 1)), true)); + } + else { + parts.add(PartText.of(text.substring(0, position), false)); + parts.add(PartText.of(String.valueOf(text.charAt(position)), true)); + parts.add(PartText.of(text.substring(position + 1, text.length()), false)); + } + } + return parts; + } + } + + private static class DefaultPathSearchContext extends BaseTextComponentContext + implements PathSearchContext { + + private List pathViewItems; + private PathSearchConfig pathSearchConfig; + + @Override + public List getPathViewItems() { + return this.pathViewItems; + } + + @Override + public void setPathViewItems(List pathViewItems) { + this.pathViewItems = pathViewItems; + } + + @Override + public PathSearchConfig getPathSearchConfig() { + return this.pathSearchConfig; + } + + @Override + public void setPathSearchConfig(PathSearchConfig config) { + this.pathSearchConfig = config; + } + + @Override + public Map toTemplateModel() { + Map attributes = super.toTemplateModel(); + attributes.put("pathViewItems", getPathViewItems()); + Map model = new HashMap<>(); + model.put("model", attributes); + return model; + } + } + + private class DefaultRenderer implements Function> { + + @Override + public List apply(PathSearchContext context) { + return renderTemplateResource(context.toTemplateModel()); + } + } + + /** + * Holder class keeping {@link Path} and {@link SearchMatchResult}. + */ + public static class ScoredPath implements Comparable { + + private final Path path; + private final SearchMatchResult result; + + ScoredPath(Path path, SearchMatchResult result) { + this.path = path; + this.result = result; + } + + public static ScoredPath of(Path path, SearchMatchResult result) { + return new ScoredPath(path, result); + } + + public Path getPath() { + return this.path; + } + + public SearchMatchResult getResult() { + return this.result; + } + + @Override + public int compareTo(ScoredPath other) { + int scoreCompare = Integer.compare(other.result.getScore(), this.result.getScore()); + if (scoreCompare == 0) { + // secondary sort by path length + return -Integer.compare(other.getPath().toString().length(), this.getPath().toString().length()); + } + return scoreCompare; + } + } + + private static class DefaultPathScanner implements BiFunction { + + static DefaultPathScanner of() { + return new DefaultPathScanner(); + } + + @Override + public PathScannerResult apply(String input, PathSearchContext context) { + + // input format + String[] split = input.split(" ", 2); + String match = split.length == 2 ? split[1] : null; + + // walk files to find candidates + PathSearchPathVisitor visitor = new PathSearchPathVisitor(context.getPathSearchConfig().getMaxPathsSearch()); + try { + String p = split[0]; + if (".".equals(p)) { + p = ""; + } + Path path = Path.of(p); + log.debug("Walking input {} for path {}", input, path); + Files.walkFileTree(path, visitor); + log.debug("walked files {} dirs {}", visitor.getPathCounters().getFileCounter().get(), + visitor.getPathCounters().getDirectoryCounter().get()); + } catch (Exception e) { + log.debug("PathSearchPathVisitor caused exception", e); + } + + // match and score candidates + Set treeSet = new HashSet(); + Stream.concat(visitor.getFileList().stream(), visitor.getDirList().stream()) + .forEach(p -> { + SearchMatchResult result; + if (StringUtils.hasText(match)) { + SearchMatch searchMatch = SearchMatch.builder() + .caseSensitive(context.getPathSearchConfig().isSearchCaseSensitive()) + .normalize(context.getPathSearchConfig().isSearchNormalize()) + .forward(context.getPathSearchConfig().searchForward) + .build(); + result = searchMatch.match(p.toString(), match); + } + else { + result = SearchMatchResult.ofMinus(); + } + treeSet.add(ScoredPath.of(p, result)); + }); + + // sort and limit + return treeSet.stream() + .sorted() + .limit(context.getPathSearchConfig().getMaxPathsSearch()) + .collect(Collectors.collectingAndThen(Collectors.toList(), + list -> PathScannerResult.of(list, visitor.getPathCounters().getDirectoryCounter().get(), + visitor.getPathCounters().getFileCounter().get(), StringUtils.hasText(match)))); + } + } + + /** + * Extension to AccumulatorPathVisitor which allows to break out from scanning + * when enough results are found. + */ + private static class PathSearchPathVisitor extends AccumulatorPathVisitor { + + private final int limitFiles; + private final static IOFileFilter DNFILTER = new NotFileFilter(new WildcardFileFilter(".*")); + + PathSearchPathVisitor(int limitFiles) { + super(Counters.longPathCounters(), DNFILTER, DNFILTER); + this.limitFiles = limitFiles; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + FileVisitResult result = super.visitFile(file, attributes); + if (getPathCounters().getFileCounter().get() >= this.limitFiles) { + return FileVisitResult.TERMINATE; + } + return result; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/SelectorList.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/SelectorList.java new file mode 100644 index 00000000..2a2ab9cc --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/SelectorList.java @@ -0,0 +1,147 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.component.support; + +import java.util.ArrayList; +import java.util.List; + +public interface SelectorList { + + void reset(List items); + T getSelected(); + void scrollUp(); + void scrollDown(); + List> getProjection(); + + public static SelectorList of(int max) { + return new DefaultSelectorList(max); + } + + public interface ProjectionItem extends Nameable, Selectable { + T getItem(); + } + + static class DefaultSelectorList implements SelectorList { + + private final List items = new ArrayList<>(); + private final int max; + private int start; + private int position; + + public DefaultSelectorList(int max) { + this.max = max; + } + + @Override + public void reset(List items) { + this.items.clear(); + this.items.addAll(items); + this.start = 0; + this.position = 0; + } + + @Override + public T getSelected() { + int index = start + position; + if (this.items.isEmpty()) { + return null; + } + return this.items.get(index); + } + + @Override + public void scrollUp() { + // at highest position in page, scroll up + if (start > 0 && position == 0) { + start--; + } + // at highest position, can't go furter, start over from bottom + else if (start + position <= 0) { + if (items.size() < max) { + start = 0; + position = items.size() - 1; + } + else { + start = items.size() - max; + position = max - 1; + } + } + // moving up in same page + else { + position--; + } + } + + @Override + public void scrollDown() { + // moving down in same page + if (start + position + 1 < Math.min(items.size(), max)) { + position++; + } + // at lowest position in page, can't go further, start over from top + else if(start + position + 1 >= items.size()) { + start = 0; + position = 0; + } + // in middle of a page, nor highest or lowest + else if(position < max - 1) { + position++; + } + // at lowest position in page, scroll down + else { + start++; + } + } + + @Override + public List> getProjection() { + List> projection = new ArrayList<>(); + for (int i = start; i < start + Math.min(items.size(), max); i++) { + boolean selected = i == start + position; + BaseProjectionItem item = new BaseProjectionItem<>(items.get(i), selected); + projection.add(item); + } + return projection; + } + + } + + static class BaseProjectionItem implements ProjectionItem { + + private final T item; + private final boolean selected; + + BaseProjectionItem(T item, boolean selected) { + this.item = item; + this.selected = selected; + } + + @Override + public T getItem() { + return item; + } + + @Override + public String getName() { + return item.getName(); + } + + @Override + public boolean isSelected() { + return this.selected; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/style/PartsText.java b/spring-shell-core/src/main/java/org/springframework/shell/style/PartsText.java new file mode 100644 index 00000000..f1c3b6a4 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/style/PartsText.java @@ -0,0 +1,63 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.style; + +import java.util.Arrays; +import java.util.List; + +public class PartsText { + + private List parts; + + PartsText(List parts) { + this.parts = parts; + } + + public static PartsText of(PartText... parts) { + return new PartsText(Arrays.asList(parts)); + } + + public static PartsText of(List parts) { + return new PartsText(parts); + } + + public List getParts() { + return parts; + } + + public static class PartText { + + private String text; + private boolean match; + + public PartText(String text, boolean match) { + this.text = text; + this.match = match; + } + + public static PartText of(String text, boolean match) { + return new PartText(text, match); + } + + public String getText() { + return text; + } + + public boolean isMatch() { + return match; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/style/PartsTextRenderer.java b/spring-shell-core/src/main/java/org/springframework/shell/style/PartsTextRenderer.java new file mode 100644 index 00000000..695cd2fc --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/style/PartsTextRenderer.java @@ -0,0 +1,124 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.style; + +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +import org.stringtemplate.v4.AttributeRenderer; + +import org.springframework.shell.style.PartsText.PartText; +import org.springframework.util.Assert; + +public class PartsTextRenderer implements AttributeRenderer { + + private final ThemeResolver themeResolver; + + public PartsTextRenderer(ThemeResolver themeResolver) { + Assert.notNull(themeResolver, "themeResolver must be set"); + this.themeResolver = themeResolver; + } + + @Override + public String toString(PartsText value, String formatString, Locale locale) { + StringBuilder buf = new StringBuilder(); + Values values = mapValues(formatString); + + int len = 0; + int dots = 2; + int prefix = values.prefix; + int width = values.width; + int max = width - prefix; + List parts = value.getParts(); + for (int i = 0; i < parts.size(); i++) { + PartText pt = parts.get(i); + String text; + boolean doBreak = false; + + int newLen = len + pt.getText().length(); + + // if current would take over max length + if (newLen > max) { + int l = max - len - dots; + text = String.format(locale, "%1." + l + "s.." , pt.getText()); + doBreak = true; + } + // if next would take over max length + else if (i + 1 < parts.size() && newLen + parts.get(i + 1).getText().length() > max) { + int l = max - len - dots; + text = String.format(locale, "%1." + l + "s.." , pt.getText()); + doBreak = true; + } + // we're fine as is + else { + text = pt.getText(); + } + String tag = pt.isMatch() ? values.matchStyle : values.textStyle; + buf.append(String.format("@{%s %s}", themeResolver.resolveStyleTag(tag), text)); + len += pt.getText().length(); + if (doBreak) { + break; + } + } + + return buf.toString(); + } + + private static class Values { + Integer width; + Integer prefix; + String textStyle; + String matchStyle; + + public void setWidth(Integer width) { + this.width = width; + } + public void setPrefix(Integer prefix) { + this.prefix = prefix; + } + public void setTextStyle(String textStyle) { + this.textStyle = textStyle; + } + public void setMatchStyle(String matchStyle) { + this.matchStyle = matchStyle; + } + } + + private static Values mapValues(String expression) { + Values values = new Values(); + Stream.of(expression.split(",")) + .map(String::trim) + .forEach(v -> { + String[] split = v.split(":", 2); + if (split.length == 2) { + if ("width".equals(split[0])) { + values.setWidth(Integer.parseInt(split[1])); + } + else if ("prefix".equals(split[0])) { + values.setPrefix(Integer.parseInt(split[1])); + } + else if ("textStyle".equals(split[0])) { + values.setTextStyle(split[1]); + } + else if ("matchStyle".equals(split[0])) { + values.setMatchStyle(split[1]); + } + } + }); + return values; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/style/TemplateExecutor.java b/spring-shell-core/src/main/java/org/springframework/shell/style/TemplateExecutor.java index 6e695797..14cfe5f0 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/style/TemplateExecutor.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/style/TemplateExecutor.java @@ -38,11 +38,13 @@ public class TemplateExecutor { private final static Logger log = LoggerFactory.getLogger(TemplateExecutor.class); private final static STErrorListener ERROR_LISTENER = new LoggingSTErrorListener(); private final ThemeResolver themeResolver; - private StringToStyleExpressionRenderer renderer; + private StringToStyleExpressionRenderer renderer1; + private PartsTextRenderer renderer2; public TemplateExecutor(ThemeResolver themeResolver) { this.themeResolver = themeResolver; - renderer = new StringToStyleExpressionRenderer(themeResolver); + renderer1 = new StringToStyleExpressionRenderer(themeResolver); + renderer2 = new PartsTextRenderer(themeResolver); } /** @@ -55,7 +57,8 @@ public class TemplateExecutor { public AttributedString render(String template, Map attributes) { STGroup group = new STGroup(); group.setListener(ERROR_LISTENER); - group.registerRenderer(String.class, renderer); + group.registerRenderer(String.class, renderer1); + group.registerRenderer(PartsText.class, renderer2); ST st = new ST(group, template); if (attributes != null) { @@ -76,7 +79,8 @@ public class TemplateExecutor { public AttributedString renderGroup(String template, Map attributes) { STGroup group = new STGroupString(template); group.setListener(ERROR_LISTENER); - group.registerRenderer(String.class, renderer); + group.registerRenderer(String.class, renderer1); + group.registerRenderer(PartsText.class, renderer2); // define styled figures as dictionary Map figureDict = Stream.of(FigureSettings.tags()) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/search/AbstractSearchMatchAlgorithm.java b/spring-shell-core/src/main/java/org/springframework/shell/support/search/AbstractSearchMatchAlgorithm.java new file mode 100644 index 00000000..61f9e24d --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/search/AbstractSearchMatchAlgorithm.java @@ -0,0 +1,278 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.util.StringUtils; + +/** + * Base class for common search algorithms mostly based on {@code fzf} + * algorithms. + * + * @author Janne Valkealahti + */ +abstract class AbstractSearchMatchAlgorithm implements SearchMatchAlgorithm { + + // points given to matches + public static final int SCORE_MATCH = 16; + public static final int SCORE_GAP_START = -3; + public static final int SCORE_GAP_EXTENSION = -1; + public static final int BONUS_BOUNDARY = SCORE_MATCH / 2; + public static final int BONUS_NON_WORD = SCORE_MATCH / 2; + public static final int BONUS_CAMEL123 = BONUS_BOUNDARY + SCORE_GAP_EXTENSION; + public static final int BONUS_CONSECUTIVE = -(SCORE_GAP_START + SCORE_GAP_EXTENSION); + public static final int BONUS_FIRST_CHAR_MULTIPLIER = 2; + public static final int BONUS_BOUNDARY_WHITE = BONUS_BOUNDARY + 2; // default +2 + public static final int BONUS_BOUNDARY_DELIMITER = BONUS_BOUNDARY + 1 + 1; // default +1 + + /** + * Enumeration of matched characters. + */ + static enum CharClass { + WHITE, + NONWORD, + DELIMITER, + LOWER, + UPPER, + LETTER, + NUMBER + } + + static class CalculateScore { + int score; + int pos[]; + + CalculateScore(int score, int[] pos) { + this.score = score; + this.pos = pos; + } + } + + static int indexAt(int index, int max, boolean forward) { + if (forward) { + return index; + } + return max - index - 1; + } + + private final static String DELIMITER_CHARS = "/,:;|"; + + static CharClass charClassOfAscii(char c) { + if (c >= 'a' && c <= 'z') { + return CharClass.LOWER; + } + else if (c >= 'A' && c <= 'Z') { + return CharClass.UPPER; + } + else if (c >= '0' && c <= '9') { + return CharClass.NUMBER; + } + else if (Character.isWhitespace(c)) { + return CharClass.WHITE; + } + else if (DELIMITER_CHARS.indexOf(c) >= 0) { + return CharClass.DELIMITER; + } + return CharClass.NONWORD; + } + + static CharClass charClassOfNonAscii(char c) { + if (Character.isLowerCase(c)) { + return CharClass.LOWER; + } + else if (Character.isUpperCase(c)) { + return CharClass.UPPER; + } + else if (Character.isDigit(c)) { + return CharClass.NUMBER; + } + else if (Character.isLetter(c)) { + return CharClass.LETTER; + } + else if (Character.isWhitespace(c)) { + return CharClass.WHITE; + } + else if (DELIMITER_CHARS.indexOf(c) >= 0) { + return CharClass.DELIMITER; + } + return CharClass.NONWORD; + } + + static int bonusFor(CharClass prevClass, CharClass clazz) { + if (clazz.ordinal() > CharClass.NONWORD.ordinal()) { + if (prevClass == CharClass.WHITE) { + return BONUS_BOUNDARY_WHITE; + } + else if (prevClass == CharClass.DELIMITER) { + return BONUS_BOUNDARY_DELIMITER; + } + else if (prevClass == CharClass.NONWORD) { + return BONUS_BOUNDARY; + } + } + if (prevClass == CharClass.LOWER && clazz == CharClass.UPPER || + prevClass != CharClass.NUMBER && clazz == CharClass.NUMBER) { + return BONUS_CAMEL123; + } + else if (clazz == CharClass.NONWORD) { + return BONUS_NON_WORD; + } + else if (clazz == CharClass.WHITE) { + return BONUS_BOUNDARY_WHITE; + } + return 0; + } + + static int bonusAt(String input, int idx) { + if (idx == 0) { + return BONUS_BOUNDARY_WHITE; + } + return bonusFor(charClassOfAscii(input.charAt(idx - 1)), charClassOfAscii(input.charAt(idx))); + } + + static CalculateScore calculateScore(boolean caseSensitive, boolean normalize, String text, String pattern, + int sidx, int eidx) { + int pidx = 0; + int score = 0; + boolean inGap = false; + int consecutive = 0; + int firstBonus = 0; + List positions = new ArrayList<>(); + + CharClass prevClass = CharClass.WHITE; + if (sidx > 0) { + prevClass = charClassOfAscii(text.charAt(sidx - 1)); + } + + for (int idx = sidx; idx < eidx; idx++) { + char c = text.charAt(idx); + CharClass clazz = charClassOfAscii(c); + + if (!caseSensitive) { + if (c >= 'A' && c <= 'Z') { + c += 32; + } + } + + if (normalize) { + c = normalizeRune(c); + } + + if (c == pattern.charAt(pidx)) { + positions.add(idx); + score += SCORE_MATCH; + int bonus = bonusFor(prevClass, clazz); + if (consecutive == 0) { + firstBonus = bonus; + } + else { + if (bonus >= BONUS_BOUNDARY && bonus > firstBonus) { + firstBonus = bonus; + } + bonus = Math.max(Math.max(bonus, firstBonus), BONUS_CONSECUTIVE); + } + if (pidx == 0) { + score += bonus * BONUS_FIRST_CHAR_MULTIPLIER; + } + else { + score += bonus; + } + inGap = false; + consecutive++; + pidx++; + } + else { + if (inGap) { + score += SCORE_GAP_EXTENSION; + } + else { + score += SCORE_GAP_START; + } + inGap = true; + consecutive = 0; + firstBonus = 0; + } + prevClass = clazz; + + } + return new CalculateScore(score, positions.stream().mapToInt(Integer::intValue).toArray()); + } + + static int trySkip(String input, boolean caseSensitive, char b, int from) { + String byteArray = input.substring(from); + int idx = byteArray.indexOf(b); + + if (idx == 0) { + return from; + } + + if (!caseSensitive && b >= 'a' && b <= 'z') { + if (idx > 0) { + byteArray = byteArray.substring(idx); + } + int uidx = byteArray.indexOf(b - 32); + if (uidx >= 0) { + idx = uidx; + } + } + + if (idx < 0) { + return -1; + } + + return from + idx; + } + + static int asciiFuzzyIndex(String input, String pattern, boolean caseSensitive) { + if (!StringUtils.hasText(input)) { + return 0; + } + + if (!Charset.forName("US-ASCII").newEncoder().canEncode(input)) { + return 0; + } + + int firstIndex = 0; + int idx = 0; + + for (int pidx = 0; pidx < pattern.length(); pidx++) { + idx = trySkip(input, caseSensitive, pattern.charAt(pidx), idx); + if (idx < 0) { + return -1; + } + if (pidx == 0 && idx > 0) { + firstIndex = idx - 1; + } + idx++; + } + + return firstIndex; + } + + static char normalizeRune(char r) { + if (r < 0x00C0 || r > 0x2184) { + return r; + } + Character n = Normalize.normalized.get(r); + if (n != null && n > 0) { + return n; + } + return r; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/search/ExactMatchNaiveSearchMatchAlgorithm.java b/spring-shell-core/src/main/java/org/springframework/shell/support/search/ExactMatchNaiveSearchMatchAlgorithm.java new file mode 100644 index 00000000..8f9daace --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/search/ExactMatchNaiveSearchMatchAlgorithm.java @@ -0,0 +1,104 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import org.springframework.util.StringUtils; + +/** + * Port of {@code fzf} {@code ExactMatchNaive} algorithm. + * + * @author Janne Valkealahti + */ +class ExactMatchNaiveSearchMatchAlgorithm extends AbstractSearchMatchAlgorithm { + + @Override + public SearchMatchResult match(boolean caseSensitive, boolean normalize, boolean forward, String text, + String pattern) { + + if (!StringUtils.hasText(pattern)) { + return SearchMatchResult.ofZeros(); + } + + int lenRunes = text.length(); + int lenPattern = pattern.length(); + + if (lenRunes < lenPattern) { + return SearchMatchResult.ofMinus(); + } + + int pidx = 0; + int bestPos = -1; + int bonus = 0; + int bestBonus = -1; + + for (int index = 0; index < lenRunes; index++) { + int index_ = indexAt(index, lenRunes, forward); + char c = text.charAt(index_); + + if (!caseSensitive) { + if (c >= 'A' && c <= 'Z') { + c += 32; + } + if (normalize) { + c = normalizeRune(c); + } + } + + int pidx_ = indexAt(pidx, lenPattern, forward); + char pchar = pattern.charAt(pidx_); + + if (c == pchar) { + if (pidx_ == 0) { + bonus = bonusAt(text, index_); + } + pidx++; + if (pidx == lenPattern) { + if (bonus > bestBonus) { + bestPos = index; + bestBonus = bonus; + } + if (bonus >= BONUS_BOUNDARY) { + break; + } + index -= pidx - 1; + pidx = 0; + bonus = 0; + } + } + else { + index -= pidx; + pidx = 0; + bonus = 0; + } + } + + if (bestPos >= 0) { + int sidx; + int eidx; + if (forward) { + sidx = bestPos - lenPattern + 1; + eidx = bestPos + 1; + } + else { + sidx = lenRunes - (bestPos + 1); + eidx = lenRunes - (bestPos - lenPattern + 1); + } + CalculateScore score = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx); + return SearchMatchResult.of(sidx, eidx, score.score, score.pos, this); + } + return SearchMatchResult.ofMinus(); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/search/FuzzyMatchV1SearchMatchAlgorithm.java b/spring-shell-core/src/main/java/org/springframework/shell/support/search/FuzzyMatchV1SearchMatchAlgorithm.java new file mode 100644 index 00000000..7a1b61c4 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/search/FuzzyMatchV1SearchMatchAlgorithm.java @@ -0,0 +1,91 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +/** + * Port of {@code fzf} {@code FuzzyMatchV1} algorithm. + * + * @author Janne Valkealahti + */ +class FuzzyMatchV1SearchMatchAlgorithm extends AbstractSearchMatchAlgorithm { + + @Override + public SearchMatchResult match(boolean caseSensitive, boolean normalize, boolean forward, String text, String pattern) { + int pidx = 0; + int sidx = -1; + int eidx = -1; + int lenRunes = text.length(); + int lenPattern = pattern.length(); + + for (int index = 0; index < lenRunes; index++) { + char c = text.charAt(indexAt(index, lenRunes, forward)); + + if (!caseSensitive) { + if (c >= 'A' && c <= 'Z') { + c += 32; + } + } + + if (normalize) { + c = normalizeRune(c); + } + + char pchar = pattern.charAt(indexAt(pidx, lenPattern, forward)); + + if (c == pchar) { + if (sidx < 0) { + sidx = index; + } + + pidx++; + if (pidx == lenPattern) { + eidx = index + 1; + break; + } + } + } + + if (sidx >= 0 && eidx >= 0) { + pidx--; + for (int i = eidx - 1; i >= sidx; i--) { + int tidx = indexAt(i, lenRunes, forward); + char c = text.charAt(tidx); + if (!caseSensitive) { + if (c >= 'A' && c <= 'Z') { + c += 32; + } + } + int pidx_ = indexAt(pidx, lenPattern, forward); + char pchar = pattern.charAt(pidx_); + if (c == pchar) { + pidx--; + if (pidx < 0) { + sidx = i; + break; + } + } + } + if (!forward) { + int sidxOrig = sidx; + sidx = lenRunes - eidx; + eidx = lenRunes - sidxOrig; + } + CalculateScore calculateScore = calculateScore(caseSensitive, normalize, text, pattern, sidx, eidx); + return SearchMatchResult.of(sidx, eidx, calculateScore.score, calculateScore.pos, this); + } + return SearchMatchResult.of(-1, -1, 0, new int[0], this); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/search/FuzzyMatchV2SearchMatchAlgorithm.java b/spring-shell-core/src/main/java/org/springframework/shell/support/search/FuzzyMatchV2SearchMatchAlgorithm.java new file mode 100644 index 00000000..0467b138 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/search/FuzzyMatchV2SearchMatchAlgorithm.java @@ -0,0 +1,265 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.util.StringUtils; + +/** + * Port of {@code fzf} {@code FuzzyMatchV2} algorithm. + * + * @author Janne Valkealahti + */ +class FuzzyMatchV2SearchMatchAlgorithm extends AbstractSearchMatchAlgorithm { + + @Override + public SearchMatchResult match(boolean caseSensitive, boolean normalize, boolean forward, String text, + String pattern) { + if (!StringUtils.hasText(pattern)) { + return SearchMatchResult.ofZeros(); + } + int M = pattern.length(); + int N = text.length(); + + int idx = asciiFuzzyIndex(text, pattern, caseSensitive); + if (idx < 0) { + return SearchMatchResult.ofMinus(); + } + + List H0 = create(N); + List C0 = create(N); + List B = create(N); + List F = create(M); + String T = text; + + // Phase 2. Calculate bonus for each point + int maxScore = 0; + int maxScorePos = 0; + int pidx = 0; + int lastIdx = 0; + + char pchar0 = pattern.charAt(0); + char pchar = pattern.charAt(0); + int prevH0 = 0; + CharClass prevClass = CharClass.WHITE; + boolean inGap = false; + + int TsubIdxRestore = idx; + String Tsub = T.substring(idx); + + List H0sub = slicex(H0, idx, Tsub.length()); + List C0sub = slicex(C0, idx, Tsub.length()); + List Bsub = slicex(B, idx, Tsub.length()); + + for (int off = 0; off < Tsub.length(); off++) { + char c = Tsub.charAt(off); + CharClass clazz; + + if (c >= 32 && c < 127) { + clazz = charClassOfAscii(c); + if (!caseSensitive && clazz == CharClass.UPPER) { + c += 32; + } + } + else { + clazz = charClassOfNonAscii(c); + if (!caseSensitive && clazz == CharClass.UPPER) { + c = Character.toLowerCase(c); + } + if (normalize) { + c = normalizeRune(c); + } + } + + // TODO: potential speed increase as go can directly modify underlying array/slice + // by access via for loop variables and so on. we create a lot of garbage here. + Tsub = Tsub.substring(0, off) + c + Tsub.substring(off + 1); + int bonus = bonusFor(prevClass, clazz); + Bsub.set(off, bonus); + prevClass = clazz; + + if (c == pchar) { + if (pidx < M) { + F.set(pidx, idx + off); + pidx++; + pchar = pattern.charAt(Math.min(pidx, M - 1)); + } + lastIdx = idx + off; + } + + if (c == pchar0) { + int score = SCORE_MATCH + bonus * BONUS_FIRST_CHAR_MULTIPLIER; + H0sub.set(off, score); + C0sub.set(off, 1); + if (M == 1 && (forward && score > maxScore || !forward && score >= maxScore)) { + maxScore = score; + maxScorePos = idx + off; + if (forward && bonus >= BONUS_BOUNDARY) { + break; + } + } + inGap = false; + } + else { + if (inGap) { + H0sub.set(off, Math.max(prevH0 + SCORE_GAP_EXTENSION, 0)); + } + else { + H0sub.set(off, Math.max(prevH0 + SCORE_GAP_START, 0)); + } + C0sub.set(off, 0); + inGap = true; + } + prevH0 = H0sub.get(off); + } + + T = T.substring(0, TsubIdxRestore) + Tsub; + if (pidx != M) { + return SearchMatchResult.ofMinus(); + } + if (M == 1) { + return SearchMatchResult.of(maxScorePos, maxScorePos + 1, maxScore, new int[] { maxScorePos }, this); + } + + // Phase 3. Fill in score matrix (H) + int f0 = F.get(0); + int width = lastIdx - f0 + 1; + List H = create(width * M); + copy(H, H0, f0, lastIdx + 1); + + List C = create(width * M); + copy(C, C0, f0, lastIdx + 1); + + List Fsub = F.subList(1, F.size()); + String Psub = pattern.substring(1); + Psub = Psub.substring(0, Fsub.size()); + + for (int off = 0; off < Fsub.size(); off++) { + int f = Fsub.get(off); + char pchar2 = Psub.charAt(off); + int pidx2 = off + 1; + int row = pidx2 * width; + boolean inGap2 = false; + String Tsub2 = T.substring(f, lastIdx + 1); + List Bsub2 = slicex(B, f, Tsub2.length()); + List Csub2 = slicex(C, row + f - f0, Tsub2.length()); + List Cdiag = slicex(C, row + f - f0 - 1 - width, Tsub2.length()); + List Hsub2 = slicex(H, row + f - f0, Tsub2.length()); + List Hdiag = slicex(H, row + f - f0 - 1 - width, Tsub2.length()); + List Hleft = slicex(H, row + f - f0 - 1, Tsub2.length()); + Hleft.set(0, 0); + + for (int off2 = 0; off2 < Tsub2.length(); off2++) { + char c = Tsub2.charAt(off2); + int col = off2 + f; + int s1 = 0; + int s2 = 0; + int consecutive = 0; + + if (inGap2) { + s2 = Hleft.get(off2) + SCORE_GAP_EXTENSION; + } + else { + s2 = Hleft.get(off2) + SCORE_GAP_START; + } + + if (pchar2 == c) { + s1 = Hdiag.get(off2) + SCORE_MATCH; + int b = Bsub2.get(off2); + consecutive = Cdiag.get(off2) + 1; + + if (consecutive > 1) { + int fb = B.get(col - consecutive + 1); + if (b >= BONUS_BOUNDARY && b > fb) { + consecutive = 1; + } + else { + b = Math.max(b, Math.max(BONUS_CONSECUTIVE, fb)); + } + } + if (s1 + b < s2) { + s1 += Bsub2.get(off2); + consecutive = 0; + } + else { + s1 += b; + } + } + Csub2.set(off2, consecutive); + inGap2 = s1 < s2; + int score = Math.max(Math.max(s1, s2), 0); + if (pidx2 == M - 1 && (forward && score > maxScore) || !forward && score >= maxScore) { + maxScore = score; + maxScorePos = col; + } + Hsub2.set(off2, score); + } + } + + // Phase 4. (Optional) Backtrace to find character positions + int[] pos = new int[M]; + int j = f0; + int i = M - 1; + j = maxScorePos; + boolean preferMatch = true; + int posidx = pos.length - 1; + for(;;) { + int I = i * width; + int j0 = j - f0; + int s = H.get(I + j0); + int s1 = 0; + int s2 = 0; + if (i > 0 && j >= F.get(i)) { + s1 = H.get(I - width + j0 - 1); + } + if (j > F.get(i)) { + s2 = H.get(I + j0 - 1); + } + if (s > s1 && (s > s2 || s == s2 && preferMatch)) { + pos[posidx--] = j; + if (i == 0) { + break; + } + i--; + } + preferMatch = C.get(I + j0) > 1 || I + width + j0 + 1 < C.size() && C.get(I + width + j0 + 1) > 0; + j--; + } + + return SearchMatchResult.of(j, maxScorePos + 1, maxScore, pos, this); + } + + private static List slicex(List from, int start, int length) { + return from.subList(start, from.size()).subList(0, length); + } + + private static void copy(List dst, List src, int start, int end) { + int x = 0; + for (int i = start; i < end; i++) { + dst.set(x++, src.get(i)); + } + } + + private static List create(int size) { + List list = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + list.add(0); + } + return list; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/search/Normalize.java b/spring-shell-core/src/main/java/org/springframework/shell/support/search/Normalize.java new file mode 100644 index 00000000..9430c554 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/search/Normalize.java @@ -0,0 +1,503 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import java.util.HashMap; +import java.util.Map; + +public class Normalize { + + public static Map normalized = new HashMap<>(); + + static { + normalized.put((char)0x00E1, 'a'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x0103, 'a'); // WITH BREVE, LATIN SMALL LETTER + normalized.put((char)0x01CE, 'a'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x00E2, 'a'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x00E4, 'a'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x0227, 'a'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1EA1, 'a'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0201, 'a'); // WITH DOUBLE GRAVE, LATIN SMALL LETTER + normalized.put((char)0x00E0, 'a'); // WITH GRAVE, LATIN SMALL LETTER + normalized.put((char)0x1EA3, 'a'); // WITH HOOK ABOVE, LATIN SMALL LETTER + normalized.put((char)0x0203, 'a'); // WITH INVERTED BREVE, LATIN SMALL LETTER + normalized.put((char)0x0101, 'a'); // WITH MACRON, LATIN SMALL LETTER + normalized.put((char)0x0105, 'a'); // WITH OGONEK, LATIN SMALL LETTER + normalized.put((char)0x1E9A, 'a'); // WITH RIGHT HALF RING, LATIN SMALL LETTER + normalized.put((char)0x00E5, 'a'); // WITH RING ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E01, 'a'); // WITH RING BELOW, LATIN SMALL LETTER + normalized.put((char)0x00E3, 'a'); // WITH TILDE, LATIN SMALL LETTER + normalized.put((char)0x0363, 'a'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x0250, 'a'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x1E03, 'b'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E05, 'b'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0253, 'b'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E07, 'b'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x0180, 'b'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x0183, 'b'); // WITH TOPBAR, LATIN SMALL LETTER + normalized.put((char)0x0107, 'c'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x010D, 'c'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x00E7, 'c'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x0109, 'c'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x0255, 'c'); // WITH CURL, LATIN SMALL LETTER + normalized.put((char)0x010B, 'c'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x0188, 'c'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x023C, 'c'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x0368, 'c'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x0297, 'c'); // , LATIN LETTER STRETCHED + normalized.put((char)0x2184, 'c'); // , LATIN SMALL LETTER REVERSED + normalized.put((char)0x010F, 'd'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x1E11, 'd'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x1E13, 'd'); // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER + normalized.put((char)0x0221, 'd'); // WITH CURL, LATIN SMALL LETTER + normalized.put((char)0x1E0B, 'd'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E0D, 'd'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0257, 'd'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E0F, 'd'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x0111, 'd'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x0256, 'd'); // WITH TAIL, LATIN SMALL LETTER + normalized.put((char)0x018C, 'd'); // WITH TOPBAR, LATIN SMALL LETTER + normalized.put((char)0x0369, 'd'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x00E9, 'e'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x0115, 'e'); // WITH BREVE, LATIN SMALL LETTER + normalized.put((char)0x011B, 'e'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x0229, 'e'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x1E19, 'e'); // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER + normalized.put((char)0x00EA, 'e'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x00EB, 'e'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x0117, 'e'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1EB9, 'e'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0205, 'e'); // WITH DOUBLE GRAVE, LATIN SMALL LETTER + normalized.put((char)0x00E8, 'e'); // WITH GRAVE, LATIN SMALL LETTER + normalized.put((char)0x1EBB, 'e'); // WITH HOOK ABOVE, LATIN SMALL LETTER + normalized.put((char)0x025D, 'e'); // WITH HOOK, LATIN SMALL LETTER REVERSED OPEN + normalized.put((char)0x0207, 'e'); // WITH INVERTED BREVE, LATIN SMALL LETTER + normalized.put((char)0x0113, 'e'); // WITH MACRON, LATIN SMALL LETTER + normalized.put((char)0x0119, 'e'); // WITH OGONEK, LATIN SMALL LETTER + normalized.put((char)0x0247, 'e'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x1E1B, 'e'); // WITH TILDE BELOW, LATIN SMALL LETTER + normalized.put((char)0x1EBD, 'e'); // WITH TILDE, LATIN SMALL LETTER + normalized.put((char)0x0364, 'e'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x029A, 'e'); // , LATIN SMALL LETTER CLOSED OPEN + normalized.put((char)0x025E, 'e'); // , LATIN SMALL LETTER CLOSED REVERSED OPEN + normalized.put((char)0x025B, 'e'); // , LATIN SMALL LETTER OPEN + normalized.put((char)0x0258, 'e'); // , LATIN SMALL LETTER REVERSED + normalized.put((char)0x025C, 'e'); // , LATIN SMALL LETTER REVERSED OPEN + normalized.put((char)0x01DD, 'e'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x1D08, 'e'); // , LATIN SMALL LETTER TURNED OPEN + normalized.put((char)0x1E1F, 'f'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x0192, 'f'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x01F5, 'g'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x011F, 'g'); // WITH BREVE, LATIN SMALL LETTER + normalized.put((char)0x01E7, 'g'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x0123, 'g'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x011D, 'g'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x0121, 'g'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x0260, 'g'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E21, 'g'); // WITH MACRON, LATIN SMALL LETTER + normalized.put((char)0x01E5, 'g'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x0261, 'g'); // , LATIN SMALL LETTER SCRIPT + normalized.put((char)0x1E2B, 'h'); // WITH BREVE BELOW, LATIN SMALL LETTER + normalized.put((char)0x021F, 'h'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x1E29, 'h'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x0125, 'h'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x1E27, 'h'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x1E23, 'h'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E25, 'h'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x02AE, 'h'); // WITH FISHHOOK, LATIN SMALL LETTER TURNED + normalized.put((char)0x0266, 'h'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E96, 'h'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x0127, 'h'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x036A, 'h'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x0265, 'h'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x2095, 'h'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x00ED, 'i'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x012D, 'i'); // WITH BREVE, LATIN SMALL LETTER + normalized.put((char)0x01D0, 'i'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x00EE, 'i'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x00EF, 'i'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x1ECB, 'i'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0209, 'i'); // WITH DOUBLE GRAVE, LATIN SMALL LETTER + normalized.put((char)0x00EC, 'i'); // WITH GRAVE, LATIN SMALL LETTER + normalized.put((char)0x1EC9, 'i'); // WITH HOOK ABOVE, LATIN SMALL LETTER + normalized.put((char)0x020B, 'i'); // WITH INVERTED BREVE, LATIN SMALL LETTER + normalized.put((char)0x012B, 'i'); // WITH MACRON, LATIN SMALL LETTER + normalized.put((char)0x012F, 'i'); // WITH OGONEK, LATIN SMALL LETTER + normalized.put((char)0x0268, 'i'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x1E2D, 'i'); // WITH TILDE BELOW, LATIN SMALL LETTER + normalized.put((char)0x0129, 'i'); // WITH TILDE, LATIN SMALL LETTER + normalized.put((char)0x0365, 'i'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x0131, 'i'); // , LATIN SMALL LETTER DOTLESS + normalized.put((char)0x1D09, 'i'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x1D62, 'i'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x2071, 'i'); // , SUPERSCRIPT LATIN SMALL LETTER + normalized.put((char)0x01F0, 'j'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x0135, 'j'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x029D, 'j'); // WITH CROSSED-TAIL, LATIN SMALL LETTER + normalized.put((char)0x0249, 'j'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x025F, 'j'); // WITH STROKE, LATIN SMALL LETTER DOTLESS + normalized.put((char)0x0237, 'j'); // , LATIN SMALL LETTER DOTLESS + normalized.put((char)0x1E31, 'k'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x01E9, 'k'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x0137, 'k'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x1E33, 'k'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0199, 'k'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E35, 'k'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x029E, 'k'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x2096, 'k'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x013A, 'l'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x019A, 'l'); // WITH BAR, LATIN SMALL LETTER + normalized.put((char)0x026C, 'l'); // WITH BELT, LATIN SMALL LETTER + normalized.put((char)0x013E, 'l'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x013C, 'l'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x1E3D, 'l'); // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER + normalized.put((char)0x0234, 'l'); // WITH CURL, LATIN SMALL LETTER + normalized.put((char)0x1E37, 'l'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x1E3B, 'l'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x0140, 'l'); // WITH MIDDLE DOT, LATIN SMALL LETTER + normalized.put((char)0x026B, 'l'); // WITH MIDDLE TILDE, LATIN SMALL LETTER + normalized.put((char)0x026D, 'l'); // WITH RETROFLEX HOOK, LATIN SMALL LETTER + normalized.put((char)0x0142, 'l'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x2097, 'l'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x1E3F, 'm'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x1E41, 'm'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E43, 'm'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0271, 'm'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x0270, 'm'); // WITH LONG LEG, LATIN SMALL LETTER TURNED + normalized.put((char)0x036B, 'm'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x1D1F, 'm'); // , LATIN SMALL LETTER SIDEWAYS TURNED + normalized.put((char)0x026F, 'm'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x2098, 'm'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x0144, 'n'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x0148, 'n'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x0146, 'n'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x1E4B, 'n'); // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER + normalized.put((char)0x0235, 'n'); // WITH CURL, LATIN SMALL LETTER + normalized.put((char)0x1E45, 'n'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E47, 'n'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x01F9, 'n'); // WITH GRAVE, LATIN SMALL LETTER + normalized.put((char)0x0272, 'n'); // WITH LEFT HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E49, 'n'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x019E, 'n'); // WITH LONG RIGHT LEG, LATIN SMALL LETTER + normalized.put((char)0x0273, 'n'); // WITH RETROFLEX HOOK, LATIN SMALL LETTER + normalized.put((char)0x00F1, 'n'); // WITH TILDE, LATIN SMALL LETTER + normalized.put((char)0x2099, 'n'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x00F3, 'o'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x014F, 'o'); // WITH BREVE, LATIN SMALL LETTER + normalized.put((char)0x01D2, 'o'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x00F4, 'o'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x00F6, 'o'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x022F, 'o'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1ECD, 'o'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0151, 'o'); // WITH DOUBLE ACUTE, LATIN SMALL LETTER + normalized.put((char)0x020D, 'o'); // WITH DOUBLE GRAVE, LATIN SMALL LETTER + normalized.put((char)0x00F2, 'o'); // WITH GRAVE, LATIN SMALL LETTER + normalized.put((char)0x1ECF, 'o'); // WITH HOOK ABOVE, LATIN SMALL LETTER + normalized.put((char)0x01A1, 'o'); // WITH HORN, LATIN SMALL LETTER + normalized.put((char)0x020F, 'o'); // WITH INVERTED BREVE, LATIN SMALL LETTER + normalized.put((char)0x014D, 'o'); // WITH MACRON, LATIN SMALL LETTER + normalized.put((char)0x01EB, 'o'); // WITH OGONEK, LATIN SMALL LETTER + normalized.put((char)0x00F8, 'o'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x1D13, 'o'); // WITH STROKE, LATIN SMALL LETTER SIDEWAYS + normalized.put((char)0x00F5, 'o'); // WITH TILDE, LATIN SMALL LETTER + normalized.put((char)0x0366, 'o'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x0275, 'o'); // , LATIN SMALL LETTER BARRED + normalized.put((char)0x1D17, 'o'); // , LATIN SMALL LETTER BOTTOM HALF + normalized.put((char)0x0254, 'o'); // , LATIN SMALL LETTER OPEN + normalized.put((char)0x1D11, 'o'); // , LATIN SMALL LETTER SIDEWAYS + normalized.put((char)0x1D12, 'o'); // , LATIN SMALL LETTER SIDEWAYS OPEN + normalized.put((char)0x1D16, 'o'); // , LATIN SMALL LETTER TOP HALF + normalized.put((char)0x1E55, 'p'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x1E57, 'p'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x01A5, 'p'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x209A, 'p'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x024B, 'q'); // WITH HOOK TAIL, LATIN SMALL LETTER + normalized.put((char)0x02A0, 'q'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x0155, 'r'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x0159, 'r'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x0157, 'r'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x1E59, 'r'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E5B, 'r'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0211, 'r'); // WITH DOUBLE GRAVE, LATIN SMALL LETTER + normalized.put((char)0x027E, 'r'); // WITH FISHHOOK, LATIN SMALL LETTER + normalized.put((char)0x027F, 'r'); // WITH FISHHOOK, LATIN SMALL LETTER REVERSED + normalized.put((char)0x027B, 'r'); // WITH HOOK, LATIN SMALL LETTER TURNED + normalized.put((char)0x0213, 'r'); // WITH INVERTED BREVE, LATIN SMALL LETTER + normalized.put((char)0x1E5F, 'r'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x027C, 'r'); // WITH LONG LEG, LATIN SMALL LETTER + normalized.put((char)0x027A, 'r'); // WITH LONG LEG, LATIN SMALL LETTER TURNED + normalized.put((char)0x024D, 'r'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x027D, 'r'); // WITH TAIL, LATIN SMALL LETTER + normalized.put((char)0x036C, 'r'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x0279, 'r'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x1D63, 'r'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x015B, 's'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x0161, 's'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x015F, 's'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x015D, 's'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x0219, 's'); // WITH COMMA BELOW, LATIN SMALL LETTER + normalized.put((char)0x1E61, 's'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E9B, 's'); // WITH DOT ABOVE, LATIN SMALL LETTER LONG + normalized.put((char)0x1E63, 's'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0282, 's'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x023F, 's'); // WITH SWASH TAIL, LATIN SMALL LETTER + normalized.put((char)0x017F, 's'); // , LATIN SMALL LETTER LONG + normalized.put((char)0x00DF, 's'); // , LATIN SMALL LETTER SHARP + normalized.put((char)0x209B, 's'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x0165, 't'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x0163, 't'); // WITH CEDILLA, LATIN SMALL LETTER + normalized.put((char)0x1E71, 't'); // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER + normalized.put((char)0x021B, 't'); // WITH COMMA BELOW, LATIN SMALL LETTER + normalized.put((char)0x0236, 't'); // WITH CURL, LATIN SMALL LETTER + normalized.put((char)0x1E97, 't'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x1E6B, 't'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E6D, 't'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x01AD, 't'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E6F, 't'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x01AB, 't'); // WITH PALATAL HOOK, LATIN SMALL LETTER + normalized.put((char)0x0288, 't'); // WITH RETROFLEX HOOK, LATIN SMALL LETTER + normalized.put((char)0x0167, 't'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x036D, 't'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x0287, 't'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x209C, 't'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x0289, 'u'); // BAR, LATIN SMALL LETTER + normalized.put((char)0x00FA, 'u'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x016D, 'u'); // WITH BREVE, LATIN SMALL LETTER + normalized.put((char)0x01D4, 'u'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x1E77, 'u'); // WITH CIRCUMFLEX BELOW, LATIN SMALL LETTER + normalized.put((char)0x00FB, 'u'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x1E73, 'u'); // WITH DIAERESIS BELOW, LATIN SMALL LETTER + normalized.put((char)0x00FC, 'u'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x1EE5, 'u'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0171, 'u'); // WITH DOUBLE ACUTE, LATIN SMALL LETTER + normalized.put((char)0x0215, 'u'); // WITH DOUBLE GRAVE, LATIN SMALL LETTER + normalized.put((char)0x00F9, 'u'); // WITH GRAVE, LATIN SMALL LETTER + normalized.put((char)0x1EE7, 'u'); // WITH HOOK ABOVE, LATIN SMALL LETTER + normalized.put((char)0x01B0, 'u'); // WITH HORN, LATIN SMALL LETTER + normalized.put((char)0x0217, 'u'); // WITH INVERTED BREVE, LATIN SMALL LETTER + normalized.put((char)0x016B, 'u'); // WITH MACRON, LATIN SMALL LETTER + normalized.put((char)0x0173, 'u'); // WITH OGONEK, LATIN SMALL LETTER + normalized.put((char)0x016F, 'u'); // WITH RING ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E75, 'u'); // WITH TILDE BELOW, LATIN SMALL LETTER + normalized.put((char)0x0169, 'u'); // WITH TILDE, LATIN SMALL LETTER + normalized.put((char)0x0367, 'u'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x1D1D, 'u'); // , LATIN SMALL LETTER SIDEWAYS + normalized.put((char)0x1D1E, 'u'); // , LATIN SMALL LETTER SIDEWAYS DIAERESIZED + normalized.put((char)0x1D64, 'u'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x1E7F, 'v'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x028B, 'v'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E7D, 'v'); // WITH TILDE, LATIN SMALL LETTER + normalized.put((char)0x036E, 'v'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x028C, 'v'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x1D65, 'v'); // , LATIN SUBSCRIPT SMALL LETTER + normalized.put((char)0x1E83, 'w'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x0175, 'w'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x1E85, 'w'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x1E87, 'w'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E89, 'w'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x1E81, 'w'); // WITH GRAVE, LATIN SMALL LETTER + normalized.put((char)0x1E98, 'w'); // WITH RING ABOVE, LATIN SMALL LETTER + normalized.put((char)0x028D, 'w'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x1E8D, 'x'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x1E8B, 'x'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x036F, 'x'); // , COMBINING LATIN SMALL LETTER + normalized.put((char)0x00FD, 'y'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x0177, 'y'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x00FF, 'y'); // WITH DIAERESIS, LATIN SMALL LETTER + normalized.put((char)0x1E8F, 'y'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1EF5, 'y'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x1EF3, 'y'); // WITH GRAVE, LATIN SMALL LETTER + normalized.put((char)0x1EF7, 'y'); // WITH HOOK ABOVE, LATIN SMALL LETTER + normalized.put((char)0x01B4, 'y'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x0233, 'y'); // WITH MACRON, LATIN SMALL LETTER + normalized.put((char)0x1E99, 'y'); // WITH RING ABOVE, LATIN SMALL LETTER + normalized.put((char)0x024F, 'y'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x1EF9, 'y'); // WITH TILDE, LATIN SMALL LETTER + normalized.put((char)0x028E, 'y'); // , LATIN SMALL LETTER TURNED + normalized.put((char)0x017A, 'z'); // WITH ACUTE, LATIN SMALL LETTER + normalized.put((char)0x017E, 'z'); // WITH CARON, LATIN SMALL LETTER + normalized.put((char)0x1E91, 'z'); // WITH CIRCUMFLEX, LATIN SMALL LETTER + normalized.put((char)0x0291, 'z'); // WITH CURL, LATIN SMALL LETTER + normalized.put((char)0x017C, 'z'); // WITH DOT ABOVE, LATIN SMALL LETTER + normalized.put((char)0x1E93, 'z'); // WITH DOT BELOW, LATIN SMALL LETTER + normalized.put((char)0x0225, 'z'); // WITH HOOK, LATIN SMALL LETTER + normalized.put((char)0x1E95, 'z'); // WITH LINE BELOW, LATIN SMALL LETTER + normalized.put((char)0x0290, 'z'); // WITH RETROFLEX HOOK, LATIN SMALL LETTER + normalized.put((char)0x01B6, 'z'); // WITH STROKE, LATIN SMALL LETTER + normalized.put((char)0x0240, 'z'); // WITH SWASH TAIL, LATIN SMALL LETTER + normalized.put((char)0x0251, 'a'); // , latin small letter script + normalized.put((char)0x00C1, 'A'); // WITH ACUTE, LATIN CAPITAL LETTER + normalized.put((char)0x00C2, 'A'); // WITH CIRCUMFLEX, LATIN CAPITAL LETTER + normalized.put((char)0x00C4, 'A'); // WITH DIAERESIS, LATIN CAPITAL LETTER + normalized.put((char)0x00C0, 'A'); // WITH GRAVE, LATIN CAPITAL LETTER + normalized.put((char)0x00C5, 'A'); // WITH RING ABOVE, LATIN CAPITAL LETTER + normalized.put((char)0x023A, 'A'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x00C3, 'A'); // WITH TILDE, LATIN CAPITAL LETTER + normalized.put((char)0x1D00, 'A'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x0181, 'B'); // WITH HOOK, LATIN CAPITAL LETTER + normalized.put((char)0x0243, 'B'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x0299, 'B'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x1D03, 'B'); // , LATIN LETTER SMALL CAPITAL BARRED + normalized.put((char)0x00C7, 'C'); // WITH CEDILLA, LATIN CAPITAL LETTER + normalized.put((char)0x023B, 'C'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x1D04, 'C'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x018A, 'D'); // WITH HOOK, LATIN CAPITAL LETTER + normalized.put((char)0x0189, 'D'); // , LATIN CAPITAL LETTER AFRICAN + normalized.put((char)0x1D05, 'D'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x00C9, 'E'); // WITH ACUTE, LATIN CAPITAL LETTER + normalized.put((char)0x00CA, 'E'); // WITH CIRCUMFLEX, LATIN CAPITAL LETTER + normalized.put((char)0x00CB, 'E'); // WITH DIAERESIS, LATIN CAPITAL LETTER + normalized.put((char)0x00C8, 'E'); // WITH GRAVE, LATIN CAPITAL LETTER + normalized.put((char)0x0246, 'E'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x0190, 'E'); // , LATIN CAPITAL LETTER OPEN + normalized.put((char)0x018E, 'E'); // , LATIN CAPITAL LETTER REVERSED + normalized.put((char)0x1D07, 'E'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x0193, 'G'); // WITH HOOK, LATIN CAPITAL LETTER + normalized.put((char)0x029B, 'G'); // WITH HOOK, LATIN LETTER SMALL CAPITAL + normalized.put((char)0x0262, 'G'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x029C, 'H'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x00CD, 'I'); // WITH ACUTE, LATIN CAPITAL LETTER + normalized.put((char)0x00CE, 'I'); // WITH CIRCUMFLEX, LATIN CAPITAL LETTER + normalized.put((char)0x00CF, 'I'); // WITH DIAERESIS, LATIN CAPITAL LETTER + normalized.put((char)0x0130, 'I'); // WITH DOT ABOVE, LATIN CAPITAL LETTER + normalized.put((char)0x00CC, 'I'); // WITH GRAVE, LATIN CAPITAL LETTER + normalized.put((char)0x0197, 'I'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x026A, 'I'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x0248, 'J'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x1D0A, 'J'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x1D0B, 'K'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x023D, 'L'); // WITH BAR, LATIN CAPITAL LETTER + normalized.put((char)0x1D0C, 'L'); // WITH STROKE, LATIN LETTER SMALL CAPITAL + normalized.put((char)0x029F, 'L'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x019C, 'M'); // , LATIN CAPITAL LETTER TURNED + normalized.put((char)0x1D0D, 'M'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x019D, 'N'); // WITH LEFT HOOK, LATIN CAPITAL LETTER + normalized.put((char)0x0220, 'N'); // WITH LONG RIGHT LEG, LATIN CAPITAL LETTER + normalized.put((char)0x00D1, 'N'); // WITH TILDE, LATIN CAPITAL LETTER + normalized.put((char)0x0274, 'N'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x1D0E, 'N'); // , LATIN LETTER SMALL CAPITAL REVERSED + normalized.put((char)0x00D3, 'O'); // WITH ACUTE, LATIN CAPITAL LETTER + normalized.put((char)0x00D4, 'O'); // WITH CIRCUMFLEX, LATIN CAPITAL LETTER + normalized.put((char)0x00D6, 'O'); // WITH DIAERESIS, LATIN CAPITAL LETTER + normalized.put((char)0x00D2, 'O'); // WITH GRAVE, LATIN CAPITAL LETTER + normalized.put((char)0x019F, 'O'); // WITH MIDDLE TILDE, LATIN CAPITAL LETTER + normalized.put((char)0x00D8, 'O'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x00D5, 'O'); // WITH TILDE, LATIN CAPITAL LETTER + normalized.put((char)0x0186, 'O'); // , LATIN CAPITAL LETTER OPEN + normalized.put((char)0x1D0F, 'O'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x1D10, 'O'); // , LATIN LETTER SMALL CAPITAL OPEN + normalized.put((char)0x1D18, 'P'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x024A, 'Q'); // WITH HOOK TAIL, LATIN CAPITAL LETTER SMALL + normalized.put((char)0x024C, 'R'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x0280, 'R'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x0281, 'R'); // , LATIN LETTER SMALL CAPITAL INVERTED + normalized.put((char)0x1D19, 'R'); // , LATIN LETTER SMALL CAPITAL REVERSED + normalized.put((char)0x1D1A, 'R'); // , LATIN LETTER SMALL CAPITAL TURNED + normalized.put((char)0x023E, 'T'); // WITH DIAGONAL STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x01AE, 'T'); // WITH RETROFLEX HOOK, LATIN CAPITAL LETTER + normalized.put((char)0x1D1B, 'T'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x0244, 'U'); // BAR, LATIN CAPITAL LETTER + normalized.put((char)0x00DA, 'U'); // WITH ACUTE, LATIN CAPITAL LETTER + normalized.put((char)0x00DB, 'U'); // WITH CIRCUMFLEX, LATIN CAPITAL LETTER + normalized.put((char)0x00DC, 'U'); // WITH DIAERESIS, LATIN CAPITAL LETTER + normalized.put((char)0x00D9, 'U'); // WITH GRAVE, LATIN CAPITAL LETTER + normalized.put((char)0x1D1C, 'U'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x01B2, 'V'); // WITH HOOK, LATIN CAPITAL LETTER + normalized.put((char)0x0245, 'V'); // , LATIN CAPITAL LETTER TURNED + normalized.put((char)0x1D20, 'V'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x1D21, 'W'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x00DD, 'Y'); // WITH ACUTE, LATIN CAPITAL LETTER + normalized.put((char)0x0178, 'Y'); // WITH DIAERESIS, LATIN CAPITAL LETTER + normalized.put((char)0x024E, 'Y'); // WITH STROKE, LATIN CAPITAL LETTER + normalized.put((char)0x028F, 'Y'); // , LATIN LETTER SMALL CAPITAL + normalized.put((char)0x1D22, 'Z'); // , LATIN LETTER SMALL CAPITAL + normalized.put('Ắ', 'A'); + normalized.put('Ấ', 'A'); + normalized.put('Ằ', 'A'); + normalized.put('Ầ', 'A'); + normalized.put('Ẳ', 'A'); + normalized.put('Ẩ', 'A'); + normalized.put('Ẵ', 'A'); + normalized.put('Ẫ', 'A'); + normalized.put('Ặ', 'A'); + normalized.put('Ậ', 'A'); + normalized.put('ắ', 'a'); + normalized.put('ấ', 'a'); + normalized.put('ằ', 'a'); + normalized.put('ầ', 'a'); + normalized.put('ẳ', 'a'); + normalized.put('ẩ', 'a'); + normalized.put('ẵ', 'a'); + normalized.put('ẫ', 'a'); + normalized.put('ặ', 'a'); + normalized.put('ậ', 'a'); + normalized.put('Ế', 'E'); + normalized.put('Ề', 'E'); + normalized.put('Ể', 'E'); + normalized.put('Ễ', 'E'); + normalized.put('Ệ', 'E'); + normalized.put('ế', 'e'); + normalized.put('ề', 'e'); + normalized.put('ể', 'e'); + normalized.put('ễ', 'e'); + normalized.put('ệ', 'e'); + normalized.put('Ố', 'O'); + normalized.put('Ớ', 'O'); + normalized.put('Ồ', 'O'); + normalized.put('Ờ', 'O'); + normalized.put('Ổ', 'O'); + normalized.put('Ở', 'O'); + normalized.put('Ỗ', 'O'); + normalized.put('Ỡ', 'O'); + normalized.put('Ộ', 'O'); + normalized.put('Ợ', 'O'); + normalized.put('ố', 'o'); + normalized.put('ớ', 'o'); + normalized.put('ồ', 'o'); + normalized.put('ờ', 'o'); + normalized.put('ổ', 'o'); + normalized.put('ở', 'o'); + normalized.put('ỗ', 'o'); + normalized.put('ỡ', 'o'); + normalized.put('ộ', 'o'); + normalized.put('ợ', 'o'); + normalized.put('Ứ', 'U'); + normalized.put('Ừ', 'U'); + normalized.put('Ử', 'U'); + normalized.put('Ữ', 'U'); + normalized.put('Ự', 'U'); + normalized.put('ứ', 'u'); + normalized.put('ừ', 'u'); + normalized.put('ử', 'u'); + normalized.put('ữ', 'u'); + normalized.put('ự', 'u'); + } + + public static String normalizeRunes(String text) { + char[] chars = text.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char r = chars[i]; + if (r < 0x00C0 || r > 0x2184) { + continue; + } + Character n = normalized.get(r); + if (n != null && r > 0) { + chars[i] = n; + } + } + return new String(chars); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatch.java b/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatch.java new file mode 100644 index 00000000..155ce7d5 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatch.java @@ -0,0 +1,158 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +/** + * Interface defining a search match for {@code text} agains {@code pattern}. + * Resulting match result gives information in a context of {@code text} how and + * where matches happens and provides a {@code score} number which can be to + * sort results. + * + * @author Janne Valkealahti + */ +@FunctionalInterface +public interface SearchMatch { + + /** + * Match a pattern into a given text. + * + * @param text the text to search + * @param pattern the search pattern + * @return a result + */ + SearchMatchResult match(String text, String pattern); + + /** + * Gets an instance of a builder for a {@link SearchMatch}. + * + * @return builder for search match + */ + public static Builder builder() { + return new DefaultBuilder(); + } + + /** + * Defines an interface for {@link SearchMatch}. + */ + interface Builder { + + /** + * Set a flag for {@code caseSensitive}. + * + * @param caseSensitive the caseSensitive + * @return builder for chaining + */ + Builder caseSensitive(boolean caseSensitive); + + /** + * Set a flag for {@code normalize}. + * + * @param normalize the normalize + * @return builder for chaining + */ + Builder normalize(boolean normalize); + + /** + * Set a flag for {@code forward}. + * + * @param forward the forward + * @return builder for chaining + */ + Builder forward(boolean forward); + + /** + * Build instance of a {@link SearchMatch}. + * + * @return a build instance of {@link SearchMatch} + */ + SearchMatch build(); + } + + static class DefaultBuilder implements Builder { + + private boolean caseSensitive; + private boolean normalize; + private boolean forward; + + @Override + public Builder caseSensitive(boolean caseSensitive) { + this.caseSensitive = caseSensitive; + return this; + } + + @Override + public Builder normalize(boolean normalize) { + this.normalize = normalize; + return this; + } + + @Override + public Builder forward(boolean forward) { + this.forward = forward; + return this; + } + + @Override + public SearchMatch build() { + return new DefaultSearchMatch(this.caseSensitive, this.normalize, this.forward); + } + } + + static class DefaultSearchMatch implements SearchMatch { + + private boolean caseSensitive; + private boolean normalize; + private boolean forward; + + DefaultSearchMatch(boolean caseSensitive, boolean normalize, boolean forward) { + this.caseSensitive = caseSensitive; + this.normalize = normalize; + this.forward = forward; + } + + @Override + public SearchMatchResult match(String text, String pattern) { + SearchMatchAlgorithm algo = null; + if (pattern != null) { + // algos are currently expecting to pass pattern as lower case if + // case sensitivity is not enabled. + if (!caseSensitive) { + pattern = pattern.toLowerCase(); + } + // algos are currently expecting to pass pattern as normalized if + // it is enabled. + if (normalize) { + pattern = Normalize.normalizeRunes(pattern); + } + + // pick algorithm based on pattern + if (pattern.startsWith("'")) { + // exact match starting with "'" + algo = new ExactMatchNaiveSearchMatchAlgorithm(); + pattern = pattern.substring(1); + } + } + + // default algo is fuzzy match + if (algo == null) { + // algo = new FuzzyMatchV1SearchMatchAlgorithm(); + algo = new FuzzyMatchV2SearchMatchAlgorithm(); + } + + return algo.match(caseSensitive, normalize, forward, text, pattern); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatchAlgorithm.java b/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatchAlgorithm.java new file mode 100644 index 00000000..c1bf14eb --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatchAlgorithm.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +/** + * Interface to match given text with a pattern into a result. + * + * @author Janne Valkealahti + */ +@FunctionalInterface +public interface SearchMatchAlgorithm { + + /** + * Match given text with pattern as a result. + * + * @param caseSensitive the caseSensitive + * @param normalize the normalize + * @param forward the forward + * @param text the test + * @param pattern the pattern + * @return a search match result + */ + SearchMatchResult match(boolean caseSensitive, boolean normalize, boolean forward, String text, String pattern); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatchResult.java b/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatchResult.java new file mode 100644 index 00000000..0688cc64 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/search/SearchMatchResult.java @@ -0,0 +1,122 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +/** + * Interface defining result used in {@link SearchMatch}. + * + * @author Janne Valkealahti + */ +public interface SearchMatchResult { + + /** + * Get start of a match. + * + * @return a start of a match + */ + int getStart(); + + /** + * Get end of a match. + * + * @return a end of a matchh + */ + int getEnd(); + + /** + * Get score of a match. + * + * @return a score of a match + */ + int getScore(); + + /** + * Get positions of a match. + * + * @return a positions of a match + */ + int[] getPositions(); + + /** + * Get {@link SearchMatchAlgorithm} handling a search. + * + * @return {@link SearchMatchAlgorithm} handling a search + */ + SearchMatchAlgorithm getAlgorithm(); + + /** + * Construct {@link SearchMatchResult} with given parameters. + * + * @param start the start + * @param end the end + * @param score the score + * @param positions the positions + * @return a search match result + */ + public static SearchMatchResult of(int start, int end, int score, int[] positions, SearchMatchAlgorithm algo) { + return new DefaultResult(start, end, score, positions, algo); + } + + public static SearchMatchResult ofZeros() { + return of(0, 0, 0, new int[0], null); + } + + public static SearchMatchResult ofMinus() { + return of(-1, -1, 0, new int[0], null); + } + + static class DefaultResult implements SearchMatchResult { + + int start; + int end; + int score; + int[] positions; + SearchMatchAlgorithm algo; + + DefaultResult(int start, int end, int score, int[] positions, SearchMatchAlgorithm algo) { + this.start = start; + this.end = end; + this.score = score; + this.positions = positions; + this.algo = algo; + } + + @Override + public int getStart() { + return start; + } + + @Override + public int getEnd() { + return end; + } + + @Override + public int getScore() { + return score; + } + + @Override + public int[] getPositions() { + return positions; + } + + @Override + public SearchMatchAlgorithm getAlgorithm() { + return algo; + } + } +} diff --git a/spring-shell-core/src/main/resources/org/springframework/shell/component/path-search-default.stg b/spring-shell-core/src/main/resources/org/springframework/shell/component/path-search-default.stg new file mode 100644 index 00000000..f4e7f780 --- /dev/null +++ b/spring-shell-core/src/main/resources/org/springframework/shell/component/path-search-default.stg @@ -0,0 +1,53 @@ +// message +message(model) ::= <% + +<({}); format="style-level-error"> + +<({}); format="style-level-warn"> + +<({}); format="style-level-info"> + +%> + +// info section after '? xxx' +info(model) ::= <% + + + +%> + +// start '? xxx' shows both running and result +question_name(model) ::= << +<({}); format="style-list-value"> +>> + +// render path item +path_item(item,model) ::= <% + +<({ }); format="style-item-selector">,prefix:2,textStyle:style-item-selector,matchStyle:style-level-warn}> + +<(" ")>,prefix:2,textStyle:style-item-selector,matchStyle:style-level-warn}> + +%> + +// render paths +paths(model) ::= << +}; separator="\n"> +>> + +// component result +result(model) ::= << + +>> + +// component is running +running(model) ::= << + + + +>> + +// main +main(model) ::= << + +>> diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/PathSearchTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/PathSearchTests.java new file mode 100644 index 00000000..b5bbdf0a --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/PathSearchTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.component; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.shell.component.PathSearch.PathSearchContext; +import org.springframework.shell.style.PartsText; +import org.springframework.shell.style.PartsText.PartText; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PathSearchTests { + + static Stream testOfNameMatchParts() { + return Stream.of( + Arguments.of("0", new int[] { 0 }, + Arrays.asList(PartText.of("0", true))), + Arguments.of("01", new int[] { 0, 1 }, + Arrays.asList( + PartText.of("0", true), + PartText.of("1", true))), + Arguments.of("012", new int[] { 0, 1, 2 }, + Arrays.asList( + PartText.of("0", true), + PartText.of("1", true), + PartText.of("2", true))), + Arguments.of("0123456789", new int[0], + Arrays.asList(PartText.of("0123456789", false))), + Arguments.of("0123456789", new int[] { 0 }, + Arrays.asList( + PartText.of("0", true), + PartText.of("123456789", false))), + Arguments.of("0123456789", new int[] { 1 }, + Arrays.asList( + PartText.of("0", false), + PartText.of("1", true), + PartText.of("23456789", false))), + Arguments.of("0123456789", new int[] { 9 }, + Arrays.asList( + PartText.of("012345678", false), + PartText.of("9", true))), + Arguments.of("0123456789", new int[] { 2, 5 }, + Arrays.asList( + PartText.of("01", false), + PartText.of("2", true), + PartText.of("34", false), + PartText.of("5", true), + PartText.of("6789", false))), + Arguments.of("0123456789", new int[] { 2, 3 }, + Arrays.asList( + PartText.of("01", false), + PartText.of("2", true), + PartText.of("3", true), + PartText.of("456789", false))), + Arguments.of("0123456789", new int[] { 8, 9 }, + Arrays.asList( + PartText.of("01234567", false), + PartText.of("8", true), + PartText.of("9", true))) + ); + } + + @ParameterizedTest + @MethodSource + void testOfNameMatchParts(String text, int[] positions, List parts) { + PartsText ofNameMatchPartsx = PathSearchContext.ofNameMatchPartsx(text, positions); + List res = ofNameMatchPartsx.getParts(); + assertThat(res).hasSize(parts.size()); + for (int i = 0; i < parts.size(); i++) { + assertThat(res.get(i).getText()).isEqualTo(parts.get(i).getText()); + assertThat(res.get(i).isMatch()).isEqualTo(parts.get(i).isMatch()); + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/support/SelectorListTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/support/SelectorListTests.java new file mode 100644 index 00000000..d4a4b8e3 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/support/SelectorListTests.java @@ -0,0 +1,309 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.component.support; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.api.InstanceOfAssertFactory; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SelectorListTests { + + @Test + void hasCorrectInitialStateWhenResetZero() { + SelectorList list = SelectorList.of(5); + list.reset(Collections.emptyList()); + assertThat(list).asInstanceOf(SELECTOR_LIST).hasProjectionSize(0); + } + + @Test + void hasCorrectInitialState() { + SelectorList list = SelectorList.of(5); + list.reset(items(5)); + + assertThat(list.getSelected().getName()).isEqualTo("name0"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .hasProjectionSize(5) + .namesContainsExactly("name0", "name1", "name2", "name3", "name4") + .selectedContainsExactly(true, false, false, false, false); + } + + @Test + void scrollDownLessItemsThanMax() { + SelectorList list = SelectorList.of(3); + list.reset(items(3)); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(true, false, false); + + list.scrollDown(); + assertThat(list.getSelected().getName()).isEqualTo("name1"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(false, true, false); + + list.scrollDown(); + assertThat(list.getSelected().getName()).isEqualTo("name2"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(false, false, true); + + list.scrollDown(); + assertThat(list.getSelected().getName()).isEqualTo("name0"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(true, false, false); + + list.scrollDown(); + assertThat(list.getSelected().getName()).isEqualTo("name1"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(false, true, false); + + list.scrollDown(); + assertThat(list.getSelected().getName()).isEqualTo("name2"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(false, false, true); + } + + @Test + void scrollUpLessItemsThanMax() { + SelectorList list = SelectorList.of(3); + list.reset(items(3)); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(true, false, false); + + list.scrollUp(); + assertThat(list.getSelected().getName()).isEqualTo("name2"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(false, false, true); + + list.scrollUp(); + assertThat(list.getSelected().getName()).isEqualTo("name1"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(false, true, false); + + list.scrollUp(); + assertThat(list.getSelected().getName()).isEqualTo("name0"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(true, false, false); + + list.scrollUp(); + assertThat(list.getSelected().getName()).isEqualTo("name2"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(false, false, true); + + list.scrollUp(); + assertThat(list.getSelected().getName()).isEqualTo("name1"); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(false, true, false); + } + + @Test + void scrollDownMoreItemsThanMax() { + SelectorList list = SelectorList.of(3); + list.reset(items(5)); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(true, false, false); + + list.scrollDown(); + list.scrollDown(); + list.scrollDown(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name1", "name2", "name3") + .selectedContainsExactly(false, false, true); + + list.scrollDown(); + list.scrollDown(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(true, false, false); + } + + @Test + void scrollUpMoreItemsThanMax() { + SelectorList list = SelectorList.of(3); + list.reset(items(5)); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(true, false, false); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(false, false, true); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(false, true, false); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(true, false, false); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name1", "name2", "name3") + .selectedContainsExactly(true, false, false); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1", "name2") + .selectedContainsExactly(true, false, false); + } + + @Test + void scrollUpAndDownMoreItemsThanMax() { + SelectorList list = SelectorList.of(3); + list.reset(items(5)); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(false, false, true); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(false, true, false); + + list.scrollDown(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(false, false, true); + } + + @Test + void scrollUpAndDownMoreItemsThanMax2() { + SelectorList list = SelectorList.of(3); + list.reset(items(5)); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(false, false, true); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(false, true, false); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(true, false, false); + + list.scrollDown(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name2", "name3", "name4") + .selectedContainsExactly(false, true, false); + + } + + @Test + void upWhenLessItemsThanMax() { + SelectorList list = SelectorList.of(3); + list.reset(items(2)); + + list.scrollUp(); + assertThat(list).asInstanceOf(SELECTOR_LIST) + .namesContainsExactly("name0", "name1") + .selectedContainsExactly(false, true); + } + + private static class TestItem implements Nameable { + + String name; + TestItem(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + } + + List items(int count) { + return IntStream.range(0, count) + .mapToObj(i -> { + return new TestItem("name" + i); + }) + .collect(Collectors.toList()); + } + + @SuppressWarnings("rawtypes") + InstanceOfAssertFactory> SELECTOR_LIST = selectorList(TestItem.class); + + @SuppressWarnings("rawtypes") + static InstanceOfAssertFactory> selectorList(Class resultType) { + return new InstanceOfAssertFactory<>(SelectorList.class, SelectorListAssertions:: assertThat); + } + + static class SelectorListAssertions { + + public static SelectorListAssert assertThat(SelectorList actual) { + return new SelectorListAssert<>(actual); + } + } + + static class SelectorListAssert extends AbstractAssert, SelectorList> { + + public SelectorListAssert(SelectorList actual) { + super(actual, SelectorListAssert.class); + } + + public SelectorListAssert namesContainsExactly(String... names) { + isNotNull(); + List actualNames = actual.getProjection().stream() + .map(i -> i.getName()) + .collect(Collectors.toList()); + assertThat(actualNames).containsExactly(names); + return this; + } + + public SelectorListAssert selectedContainsExactly(Boolean... selected) { + isNotNull(); + List actualSelected = actual.getProjection().stream() + .map(i -> i.isSelected()) + .collect(Collectors.toList()); + assertThat(actualSelected).containsExactly(selected); + return this; + } + + public SelectorListAssert hasProjectionSize(int size) { + isNotNull(); + assertThat(actual.getProjection()).hasSize(size); + return this; + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/style/PartsTextRendererTests.java b/spring-shell-core/src/test/java/org/springframework/shell/style/PartsTextRendererTests.java new file mode 100644 index 00000000..f9f726c5 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/style/PartsTextRendererTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.style; + +import java.util.Locale; +import java.util.stream.Stream; + +import org.jline.utils.AttributedString; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.shell.style.PartsText.PartText; + +import static org.assertj.core.api.Assertions.assertThat; + +class PartsTextRendererTests { + + private static Locale LOCALE = Locale.getDefault(); + private static PartsTextRenderer renderer; + private static ThemeResolver themeResolver; + + @BeforeAll + static void setup() { + ThemeRegistry themeRegistry = new ThemeRegistry(); + themeRegistry.register(new Theme() { + @Override + public String getName() { + return "default"; + } + + @Override + public ThemeSettings getSettings() { + return ThemeSettings.defaults(); + } + }); + themeResolver = new ThemeResolver(themeRegistry, "default"); + renderer = new PartsTextRenderer(themeResolver); + } + + static PartsText of() { + return PartsText.of( + PartText.of("012", false), + PartText.of("3456", true), + PartText.of("789", false) + ); + } + + static Stream test() { + return Stream.of( + Arguments.of( + "width:10,prefix:2,textStyle:style-item-selector,matchStyle:style-level-warn", + PartsText.of( + PartText.of("01234567", false) + ), + "01234567"), + Arguments.of( + "width:10,prefix:2,textStyle:style-item-selector,matchStyle:style-level-warn", + PartsText.of( + PartText.of("0123456789", false) + ), + "012345.."), + Arguments.of( + "width:10,prefix:0,textStyle:style-item-selector,matchStyle:style-level-warn", + PartsText.of( + PartText.of("01234", false), + PartText.of("56789", true) + ), + "0123456789"), + Arguments.of( + "width:10,prefix:2,textStyle:style-item-selector,matchStyle:style-level-warn", + PartsText.of( + PartText.of("012", false), + PartText.of("3456", true), + PartText.of("789", false) + ), + "012345..") + ); + } + + @ParameterizedTest + @MethodSource + void test(String expression, PartsText text, String expected) { + String rendered = renderer.toString(text, expression, LOCALE); + AttributedString evaluated = themeResolver.evaluateExpression(rendered); + String raw = AttributedString.stripAnsi(evaluated.toAnsi()); + assertThat(raw).isEqualTo(expected); + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/support/search/ExactMatchNaiveSearchMatchAlgorithmTests.java b/spring-shell-core/src/test/java/org/springframework/shell/support/search/ExactMatchNaiveSearchMatchAlgorithmTests.java new file mode 100644 index 00000000..125a2d8d --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/support/search/ExactMatchNaiveSearchMatchAlgorithmTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_BOUNDARY; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_BOUNDARY_DELIMITER; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_CAMEL123; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_CONSECUTIVE; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_FIRST_CHAR_MULTIPLIER; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.SCORE_MATCH; + +class ExactMatchNaiveSearchMatchAlgorithmTests { + + static Stream testExactMatch() { + return Stream.of( + Arguments.of(true, false, true, "fooBarbaz", "oBA", false, -1, -1, new int[] {}, + 0), + Arguments.of(true, false, true, "fooBarbaz", "fooBarbazz", false, -1, -1, new int[] {}, + 0), + Arguments.of(false, false, true, "fooBarbaz", "oBA", false, 2, 5, new int[] { 2, 3, 4 }, + SCORE_MATCH * 3 + BONUS_CAMEL123 + BONUS_CONSECUTIVE), + Arguments.of(false, false, true, "/AutomatorDocument.icns", "rdoc", false, 9, 13, new int[] { 9, 10, 11, 12 }, + SCORE_MATCH * 4 + BONUS_CAMEL123 + BONUS_CONSECUTIVE * 2), + Arguments.of(false, false, true, "/man1/zshcompctl.1", "zshc", false, 6, 10, new int[] { 6, 7, 8, 9 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY_DELIMITER * (BONUS_FIRST_CHAR_MULTIPLIER + 3)), + Arguments.of(false, false, true, "/.oh-my-zsh/cache", "zsh/c", false, 8, 13, new int[] { 8, 9, 10, 11, 12 }, + SCORE_MATCH * 5 + BONUS_BOUNDARY * (BONUS_FIRST_CHAR_MULTIPLIER + 3) + BONUS_BOUNDARY_DELIMITER), + Arguments.of(false, false, true, "fooBarbaz", "o", false, 1, 2, new int[] { 1 }, + SCORE_MATCH * 1), + Arguments.of(false, false, true, "/tmp/test/11/file11.txt", "e", false, 6, 7, new int[] { 6 }, + SCORE_MATCH * 1), + Arguments.of(false, true, true, "Só Danço Samba", "So", false, 0, 2, new int[] { 0, 1 }, + 62), + Arguments.of(false, true, true, "Danço", "danco", false, 0, 5, new int[] { 0, 1, 2, 3, 4 }, + 140) + ); + } + + @ParameterizedTest + @MethodSource + void testExactMatch(boolean caseSensitive, boolean normalize, boolean forward, String text, String pattern, + boolean withPos, int start, int end, int[] positions, int score) { + if (!caseSensitive) { + pattern = pattern.toLowerCase(); + } + ExactMatchNaiveSearchMatchAlgorithm searchMatch = new ExactMatchNaiveSearchMatchAlgorithm(); + SearchMatchResult result = searchMatch.match(caseSensitive, normalize, forward, text, pattern); + assertThat(result.getStart()).isEqualTo(start); + assertThat(result.getEnd()).isEqualTo(end); + assertThat(result.getScore()).isEqualTo(score); + assertThat(result.getPositions()).containsExactly(positions); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/support/search/FuzzyMatchV1SearchMatchAlgorithmTests.java b/spring-shell-core/src/test/java/org/springframework/shell/support/search/FuzzyMatchV1SearchMatchAlgorithmTests.java new file mode 100644 index 00000000..8d71acd7 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/support/search/FuzzyMatchV1SearchMatchAlgorithmTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_BOUNDARY; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_BOUNDARY_DELIMITER; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_BOUNDARY_WHITE; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_CAMEL123; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_CONSECUTIVE; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_FIRST_CHAR_MULTIPLIER; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_NON_WORD; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.SCORE_GAP_EXTENSION; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.SCORE_GAP_START; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.SCORE_MATCH; + +class FuzzyMatchV1SearchMatchAlgorithmTests { + + private final FuzzyMatchV1SearchMatchAlgorithm searchMatch = new FuzzyMatchV1SearchMatchAlgorithm(); + + static Stream testFuzzyMatchV1() { + return Stream.of( + Arguments.of(false, false, "fooBarbaz1", "oBZ", true, 2, 9, new int[] { 2, 3, 8 }, + SCORE_MATCH * 3 + BONUS_CAMEL123 + SCORE_GAP_START + SCORE_GAP_EXTENSION * 3), + Arguments.of(false, false, "foo bar baz", "fbb", true, 0, 9, new int[] { 0, 4, 8 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE * 2 + + 2 * SCORE_GAP_START + 4 * SCORE_GAP_EXTENSION), + Arguments.of(false, false, "/AutomatorDocument.icns", "rdoc", true, 9, 13, new int[] { 9, 10, 11, 12 }, + SCORE_MATCH * 4 + BONUS_CAMEL123 + BONUS_CONSECUTIVE * 2), + Arguments.of(false, false, "/man1/zshcompctl.1", "zshc", true, 6, 10, new int[] { 6, 7, 8, 9 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY_DELIMITER * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 3), + Arguments.of(false, false, "/.oh-my-zsh/cache", "zshc", true, 8, 13, new int[] { 8, 9, 10 ,12 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY * 2 + SCORE_GAP_START + BONUS_BOUNDARY_DELIMITER), + Arguments.of(false, false, "ab0123 456", "12356", true, 3, 10, new int[] { 3, 4, 5, 8, 9 }, + SCORE_MATCH * 5 + BONUS_CONSECUTIVE * 3 + SCORE_GAP_START + SCORE_GAP_EXTENSION), + Arguments.of(false, false, "abc123 456", "12356", true, 3, 10, new int[] { 3, 4, 5, 8, 9 }, + SCORE_MATCH * 5 + BONUS_CAMEL123 * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2 + BONUS_CONSECUTIVE + SCORE_GAP_START + SCORE_GAP_EXTENSION), + Arguments.of(false, false, "foo/bar/baz", "fbb", true, 0, 9, new int[] { 0, 4, 8 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 2 + SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 4), + Arguments.of(false, false, "fooBarBaz", "fbb", true, 0, 7, new int[] { 0, 3, 6 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2 + SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 2), + Arguments.of(false, false, "foo barbaz", "fbb", true, 0, 8, new int[] { 0, 4, 7 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE +SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 3), + Arguments.of(false, false, "fooBar Baz", "foob", true, 0, 4, new int[] { 0, 1, 2, 3 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE * 3), + Arguments.of(false, false, "xFoo-Bar Baz", "foo-b", true, 1, 6, new int[] { 1, 2, 3, 4, 5 }, + SCORE_MATCH * 5 + BONUS_CAMEL123 * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2 + BONUS_NON_WORD + BONUS_BOUNDARY), + Arguments.of(true, false, "fooBarbaz", "oBz", true, 2, 9, new int[] { 2, 3, 8 }, + SCORE_MATCH * 3 + BONUS_CAMEL123 + SCORE_GAP_START + SCORE_GAP_EXTENSION * 3), + Arguments.of(true, false, "Foo/Bar/Baz", "FBB", true, 0, 9, new int[] { 0, 4, 8 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 2 + SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 4), + Arguments.of(true, false, "FooBarBaz", "FBB", true, 0, 7, new int[] { 0, 3, 6 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2 + SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 2), + Arguments.of(true, false, "FooBar Baz", "FooB", true, 0, 4, new int[] { 0, 1, 2, 3 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE * 2 + Math.max(BONUS_CAMEL123, BONUS_BOUNDARY_WHITE)), + Arguments.of(true, false, "foo-bar", "o-ba", true, 2, 6, new int[] { 2, 3, 4, 5 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY * 3), + Arguments.of(true, false, "fooBarbaz", "oBZ", true, -1, -1, new int[0], + 0), + Arguments.of(true, false, "Foo Bar Baz", "fbb", true, -1, -1, new int[0], + 0), + Arguments.of(true, false, "fooBarbaz", "fooBarbazz", true, -1, -1, new int[0], + 0) + ); + } + + @ParameterizedTest + @MethodSource + void testFuzzyMatchV1(boolean caseSensitive, boolean normalize, String text, String pattern, boolean withPos, + int start, int end, int[] positions, int score) { + if (!caseSensitive) { + pattern = pattern.toLowerCase(); + } + SearchMatchResult result; + + result = searchMatch.match(caseSensitive, normalize, true, text, pattern); + assertThat(result.getStart()).isEqualTo(start); + assertThat(result.getEnd()).isEqualTo(end); + assertThat(result.getScore()).isEqualTo(score); + assertThat(result.getPositions()).containsExactly(positions); + + result = searchMatch.match(caseSensitive, normalize, false, text, pattern); + assertThat(result.getStart()).isEqualTo(start); + assertThat(result.getEnd()).isEqualTo(end); + assertThat(result.getScore()).isEqualTo(score); + assertThat(result.getPositions()).containsExactly(positions); + } + + + static Stream testFuzzyMatchV1Backward() { + return Stream.of( + Arguments.of(false, true, "foobar fb", "fb", 0, 4, new int[] { 0, 3 }, + SCORE_MATCH * 2 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + SCORE_GAP_START + SCORE_GAP_EXTENSION), + Arguments.of(false, false, "foobar fb", "fb", 7, 9, new int[] { 7, 8 }, + SCORE_MATCH * 2 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE) + ); + } + + @ParameterizedTest + @MethodSource + void testFuzzyMatchV1Backward(boolean caseSensitive, boolean forward, String text, String pattern, int start, + int end, int[] positions, int score) { + if (!caseSensitive) { + pattern = pattern.toLowerCase(); + } + SearchMatchResult result; + + result = searchMatch.match(caseSensitive, false, forward, text, pattern); + assertThat(result.getStart()).isEqualTo(start); + assertThat(result.getEnd()).isEqualTo(end); + assertThat(result.getScore()).isEqualTo(score); + assertThat(result.getPositions()).containsExactly(positions); + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/support/search/FuzzyMatchV2SearchMatchAlgorithmTests.java b/spring-shell-core/src/test/java/org/springframework/shell/support/search/FuzzyMatchV2SearchMatchAlgorithmTests.java new file mode 100644 index 00000000..bd615c19 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/support/search/FuzzyMatchV2SearchMatchAlgorithmTests.java @@ -0,0 +1,171 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_BOUNDARY; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_BOUNDARY_DELIMITER; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_BOUNDARY_WHITE; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_CAMEL123; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_CONSECUTIVE; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_FIRST_CHAR_MULTIPLIER; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.BONUS_NON_WORD; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.SCORE_GAP_EXTENSION; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.SCORE_GAP_START; +import static org.springframework.shell.support.search.AbstractSearchMatchAlgorithm.SCORE_MATCH; + +class FuzzyMatchV2SearchMatchAlgorithmTests { + + private final FuzzyMatchV2SearchMatchAlgorithm searchMatch = new FuzzyMatchV2SearchMatchAlgorithm(); + + static Stream testFuzzyMatchV2() { + return Stream.of( + Arguments.of(false, false, "fooBarbaz1", "oBZ", true, 2, 9, new int[] { 2, 3, 8 }, + SCORE_MATCH * 3 + BONUS_CAMEL123 + SCORE_GAP_START + SCORE_GAP_EXTENSION * 3), + Arguments.of(false, false, "foo bar baz", "fbb", true, 0, 9, new int[] { 0, 4, 8 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE * 2 + + 2 * SCORE_GAP_START + 4 * SCORE_GAP_EXTENSION), + Arguments.of(false, false, "/AutomatorDocument.icns", "rdoc", true, 9, 13, new int[] { 9, 10, 11, 12 }, + SCORE_MATCH * 4 + BONUS_CAMEL123 + BONUS_CONSECUTIVE * 2), + Arguments.of(false, false, "/man1/zshcompctl.1", "zshc", true, 6, 10, new int[] { 6, 7, 8, 9 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY_DELIMITER * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 3), + Arguments.of(false, false, "/.oh-my-zsh/cache", "zshc", true, 8, 13, new int[] { 8, 9, 10 ,12 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY * 2 + SCORE_GAP_START + BONUS_BOUNDARY_DELIMITER), + Arguments.of(false, false, "ab0123 456", "12356", true, 3, 10, new int[] { 3, 4, 5, 8, 9 }, + SCORE_MATCH * 5 + BONUS_CONSECUTIVE * 3 + SCORE_GAP_START + SCORE_GAP_EXTENSION), + Arguments.of(false, false, "abc123 456", "12356", true, 3, 10, new int[] { 3, 4, 5, 8, 9 }, + SCORE_MATCH * 5 + BONUS_CAMEL123 * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2 + BONUS_CONSECUTIVE + SCORE_GAP_START + SCORE_GAP_EXTENSION), + Arguments.of(false, false, "foo/bar/baz", "fbb", true, 0, 9, new int[] { 0, 4, 8 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 2 + SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 4), + Arguments.of(false, false, "fooBarBaz", "fbb", true, 0, 7, new int[] { 0, 3, 6 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2 + SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 2), + Arguments.of(false, false, "foo barbaz", "fbb", true, 0, 8, new int[] { 0, 4, 7 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE +SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 3), + Arguments.of(false, false, "fooBar Baz", "foob", true, 0, 4, new int[] { 0, 1, 2, 3 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE * 3), + Arguments.of(false, false, "xFoo-Bar Baz", "foo-b", true, 1, 6, new int[] { 1, 2, 3, 4, 5 }, + SCORE_MATCH * 5 + BONUS_CAMEL123 * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2 + BONUS_NON_WORD + BONUS_BOUNDARY), + Arguments.of(true, false, "fooBarbaz", "oBz", true, 2, 9, new int[] { 2, 3, 8 }, + SCORE_MATCH * 3 + BONUS_CAMEL123 + SCORE_GAP_START + SCORE_GAP_EXTENSION * 3), + Arguments.of(true, false, "Foo/Bar/Baz", "FBB", true, 0, 9, new int[] { 0, 4, 8 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_DELIMITER * 2 + SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 4), + Arguments.of(true, false, "FooBarBaz", "FBB", true, 0, 7, new int[] { 0, 3, 6 }, + SCORE_MATCH * 3 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_CAMEL123 * 2 + SCORE_GAP_START * 2 + SCORE_GAP_EXTENSION * 2), + Arguments.of(true, false, "FooBar Baz", "FooB", true, 0, 4, new int[] { 0, 1, 2, 3 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY_WHITE * BONUS_FIRST_CHAR_MULTIPLIER + BONUS_BOUNDARY_WHITE * 2 + Math.max(BONUS_CAMEL123, BONUS_BOUNDARY_WHITE)), + Arguments.of(true, false, "foo-bar", "o-ba", true, 2, 6, new int[] { 2, 3, 4, 5 }, + SCORE_MATCH * 4 + BONUS_BOUNDARY * 3), + Arguments.of(true, false, "fooBarbaz", "oBZ", true, -1, -1, new int[0], + 0), + Arguments.of(true, false, "Foo Bar Baz", "fbb", true, -1, -1, new int[0], + 0), + Arguments.of(true, false, "fooBarbaz", "fooBarbazz", true, -1, -1, new int[0], + 0) + ); + } + + @ParameterizedTest + @MethodSource + void testFuzzyMatchV2(boolean caseSensitive, boolean normalize, String text, String pattern, boolean withPos, + int start, int end, int[] positions, int score) { + if (!caseSensitive) { + pattern = pattern.toLowerCase(); + } + SearchMatchResult result; + + result = searchMatch.match(caseSensitive, normalize, true, text, pattern); + assertThat(result.getStart()).isEqualTo(start); + assertThat(result.getEnd()).isEqualTo(end); + assertThat(result.getScore()).isEqualTo(score); + assertThat(result.getPositions()).containsExactly(positions); + + result = searchMatch.match(caseSensitive, normalize, false, text, pattern); + assertThat(result.getStart()).isEqualTo(start); + assertThat(result.getEnd()).isEqualTo(end); + assertThat(result.getScore()).isEqualTo(score); + assertThat(result.getPositions()).containsExactly(positions); + } + + static Stream testFuzzyMatchV2Normalize() { + return Stream.of( + Arguments.of(false, true, "Só Danço Samba", "So", false, 0, 2, new int[] { 0, 1 }, + 62), + Arguments.of(false, true, "Só Danço Samba", "sodc", false, 0, 7, new int[] { 0, 1, 3, 6 }, + 97), + Arguments.of(false, false, "So Danco Samba", "sodc", false, 0, 7, new int[] { 0, 1, 3, 6 }, + 97), + Arguments.of(false, true, "Danço", "danco", false, 0, 5, new int[] { 0, 1, 2, 3, 4 }, + 140) + ); + } + + @ParameterizedTest + @MethodSource + void testFuzzyMatchV2Normalize(boolean caseSensitive, boolean normalize, String text, String pattern, boolean withPos, + int start, int end, int[] positions, int score) { + if (!caseSensitive) { + pattern = pattern.toLowerCase(); + } + if (normalize) { + pattern = Normalize.normalizeRunes(pattern); + } + SearchMatchResult result; + + result = searchMatch.match(caseSensitive, normalize, true, text, pattern); + assertThat(result.getStart()).isEqualTo(start); + assertThat(result.getEnd()).isEqualTo(end); + assertThat(result.getScore()).isEqualTo(score); + assertThat(result.getPositions()).containsExactly(positions); + } + + static Stream testFuzzyMatchV2Extra() { + return Stream.of( + Arguments.of(false, true, "/tmp/test/suomi/o.txt", "oo", false, 12, 17, new int[] { 12, 16 }, + SCORE_MATCH * 2 + BONUS_BOUNDARY_DELIMITER + SCORE_GAP_START + SCORE_GAP_EXTENSION * 2), + Arguments.of(false, true, "/tmp/test/suomi/ö.txt", "oo", false, 12, 17, new int[] { 12, 16 }, + SCORE_MATCH * 2 + BONUS_BOUNDARY_DELIMITER + SCORE_GAP_START + SCORE_GAP_EXTENSION * 2), + Arguments.of(false, true, "/tmp/test/suomi/O.txt", "oo", false, 12, 17, new int[] { 12, 16 }, + SCORE_MATCH * 2 + BONUS_BOUNDARY_DELIMITER + SCORE_GAP_START + SCORE_GAP_EXTENSION * 2), + Arguments.of(false, true, "/tmp/test/suomi/Ö.txt", "oo", false, 12, 17, new int[] { 12, 16 }, + SCORE_MATCH * 2 + BONUS_BOUNDARY_DELIMITER + SCORE_GAP_START + SCORE_GAP_EXTENSION * 2) + ); + } + + @ParameterizedTest + @MethodSource + void testFuzzyMatchV2Extra(boolean caseSensitive, boolean normalize, String text, String pattern, boolean withPos, + int start, int end, int[] positions, int score) { + if (!caseSensitive) { + pattern = pattern.toLowerCase(); + } + if (normalize) { + pattern = Normalize.normalizeRunes(pattern); + } + SearchMatchResult result; + + result = searchMatch.match(caseSensitive, normalize, true, text, pattern); + assertThat(result.getStart()).isEqualTo(start); + assertThat(result.getEnd()).isEqualTo(end); + assertThat(result.getScore()).isEqualTo(score); + assertThat(result.getPositions()).containsExactly(positions); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/support/search/NormalizeTests.java b/spring-shell-core/src/test/java/org/springframework/shell/support/search/NormalizeTests.java new file mode 100644 index 00000000..26372d12 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/support/search/NormalizeTests.java @@ -0,0 +1,34 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class NormalizeTests { + + @Test + void testNormalizeMap() { + assertThat(Normalize.normalized.get('á')).isEqualTo('a'); + assertThat(Normalize.normalized.get('ự')).isEqualTo('u'); + } + + @Test + void testNormalizeFunction() { + assertThat(Normalize.normalizeRunes("abcáự")).isEqualTo("abcau"); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/support/search/SearchMatchTests.java b/spring-shell-core/src/test/java/org/springframework/shell/support/search/SearchMatchTests.java new file mode 100644 index 00000000..78bafaf4 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/support/search/SearchMatchTests.java @@ -0,0 +1,40 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.support.search; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SearchMatchTests { + + @Test + void testAlgoType() { + SearchMatch searchMatch = SearchMatch.builder() + .caseSensitive(true) + .normalize(true) + .forward(true) + .build(); + assertThat(searchMatch).isNotNull(); + SearchMatchResult result = null; + + result = searchMatch.match("fake", "fake"); + assertThat(result.getAlgorithm()).isInstanceOf(FuzzyMatchV2SearchMatchAlgorithm.class); + + result = searchMatch.match("fake", "'fake"); + assertThat(result.getAlgorithm()).isInstanceOf(ExactMatchNaiveSearchMatchAlgorithm.class); + } +} diff --git a/spring-shell-management/spring-shell-management.gradle b/spring-shell-management/spring-shell-management.gradle index c19349e1..bdb30f86 100644 --- a/spring-shell-management/spring-shell-management.gradle +++ b/spring-shell-management/spring-shell-management.gradle @@ -15,6 +15,7 @@ dependencies { api "org.jline:jline-terminal-jna:$jlineVersion" api "org.jline:jline-terminal-jansi:$jlineVersion" api "org.antlr:ST4:$st4Version" + api "commons-io:commons-io:$commonsIoVersion" api "com.google.jimfs:jimfs:$jimfsVersion" api "com.google.code.findbugs:jsr305:$findbugsVersion" } diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java index 5136450d..29dda195 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java @@ -30,7 +30,10 @@ import org.springframework.shell.component.ConfirmationInput.ConfirmationInputCo import org.springframework.shell.component.MultiItemSelector; import org.springframework.shell.component.MultiItemSelector.MultiItemSelectorContext; import org.springframework.shell.component.PathInput; +import org.springframework.shell.component.PathSearch; import org.springframework.shell.component.PathInput.PathInputContext; +import org.springframework.shell.component.PathSearch.PathSearchConfig; +import org.springframework.shell.component.PathSearch.PathSearchContext; import org.springframework.shell.component.SingleItemSelector; import org.springframework.shell.component.SingleItemSelector.SingleItemSelectorContext; import org.springframework.shell.component.StringInput; @@ -57,7 +60,7 @@ public class ComponentCommands extends AbstractShellComponent { return "Got value " + context.getResultValue(); } - @ShellMethod(key = "component path", value = "Path input", group = "Components") + @ShellMethod(key = "component path input", value = "Path input", group = "Components") public String pathInput() { PathInput component = new PathInput(getTerminal(), "Enter value"); component.setResourceLoader(getResourceLoader()); @@ -66,6 +69,31 @@ public class ComponentCommands extends AbstractShellComponent { return "Got value " + context.getResultValue(); } + @ShellMethod(key = "component path search", value = "Path search", group = "Components") + public String pathSearch( + @ShellOption(defaultValue = ShellOption.NULL) Integer maxPathsShow, + @ShellOption(defaultValue = ShellOption.NULL) Integer maxPathsSearch, + @ShellOption(defaultValue = "true") boolean searchForward, + @ShellOption(defaultValue = "false") boolean searchCaseSensitive, + @ShellOption(defaultValue = "false") boolean searchNormalize + ) { + PathSearchConfig config = new PathSearch.PathSearchConfig(); + if (maxPathsShow != null) { + config.setMaxPathsShow(maxPathsShow); + } + if (maxPathsSearch != null) { + config.setMaxPathsSearch(maxPathsSearch); + } + config.setSearchForward(searchForward); + config.setSearchCaseSensitive(searchCaseSensitive); + config.setSearchNormalize(searchNormalize); + PathSearch component = new PathSearch(getTerminal(), "Enter value", config); + component.setResourceLoader(getResourceLoader()); + component.setTemplateExecutor(getTemplateExecutor()); + PathSearchContext context = component.run(PathSearchContext.empty()); + return "Got value " + context.getResultValue(); + } + @ShellMethod(key = "component confirmation", value = "Confirmation input", group = "Components") public String confirmationInput(boolean no) { ConfirmationInput component = new ConfirmationInput(getTerminal(), "Enter value", !no);