diff --git a/vassal-app/src/main/java/VASSAL/configure/ConfigureTree.java b/vassal-app/src/main/java/VASSAL/configure/ConfigureTree.java index 65ad2cff61..c3fa4eab04 100644 --- a/vassal-app/src/main/java/VASSAL/configure/ConfigureTree.java +++ b/vassal-app/src/main/java/VASSAL/configure/ConfigureTree.java @@ -141,6 +141,7 @@ import java.util.Objects; import java.util.function.Consumer; import java.util.function.Predicate; +import java.util.regex.Pattern; import java.util.stream.IntStream; /** @@ -2090,6 +2091,7 @@ private static class SearchAction extends AbstractAction { private final ConfigureTree configureTree; private final SearchParameters searchParameters; + private Pattern regexPattern; /** * Constructs a new {@link SearchAction} @@ -2176,11 +2178,23 @@ public void actionPerformed(ActionEvent e) { ConfigureTree.chat(Resources.getString("Editor.search_all_off")); } - if (!searchParameters.getSearchString().isEmpty() && (!searchParameters.isMatchRegex() || isValidRegex(searchParameters.getSearchString()))) { + if (!searchParameters.getSearchString().isEmpty()) { if (anyChanges) { - // Unless we're just continuing to the next match in an existing search, compute & display hit count - final int matches = getNumMatches(searchParameters.getSearchString()); - chat(matches + " " + Resources.getString("Editor.search_count") + noHTML(searchParameters.getSearchString())); + boolean regexError = Boolean.FALSE; + // Unless we're just continuing to the next match in an existing search, setup. + if (searchParameters.isMatchRegex()) { + regexPattern = setupRegexSearch(searchParameters.getSearchString()); + regexError = regexPattern == null; + } + else { + regexPattern = null; + } + if (!regexError) { + // Compute & display hit count as heading, no indent + final int matches = getNumMatches(searchParameters.getSearchString()); + // FIXME: For some reason leading spaces now being stripped from Resource strings, hence added here + chatter.show(matches + " " + (regexPattern == null ? Resources.getString("Editor.search_count") : Resources.getString("Editor.search_countRegex")) + ": " + noHTML(regexPattern == null ? searchParameters.getSearchString() : regexPattern.toString())); + } } // Find first match @@ -2196,7 +2210,8 @@ public void actionPerformed(ActionEvent e) { } } else { - chat(Resources.getString("Editor.search_none_found") + noHTML(searchParameters.getSearchString())); + // No need to display this on first pass, as we already said zero found. + if (!anyChanges) chat(regexPattern == null ? Resources.getString("Editor.search_none_found") + noHTML(searchParameters.getSearchString()) : Resources.getString("Editor.search_noRegex_match") + noHTML(regexPattern.toString())); } } }); @@ -2327,7 +2342,7 @@ private int getNumMatches(String searchString) { /** * @param st - Search target (usually Decorator or AbstractConfigurable) - * @param searchString - our search string + * @param searchString - our search string unless superseded by regexPattern * @return true if the node matches our searchString based on search configuration ("match" checkboxes) */ private boolean checkSearchTarget(SearchTarget st, String searchString) { @@ -2427,7 +2442,7 @@ private boolean checkNode(DefaultMutableTreeNode node, String searchString) { return false; } - // From here down we are only searching inside of SearchTarget objects (Piece/Prototypes, or searchable AbstractConfigurables) + // From here down we are only searching inside SearchTarget objects (Piece/Prototypes, or searchable AbstractConfigurables) GamePiece p; boolean protoskip; if (c instanceof GamePiece) { @@ -2654,39 +2669,58 @@ else if (c instanceof PrototypeDefinition) { } } - private boolean isValidRegex(String searchString) { // avoid exceptions by checking the Regex before use - try { - return "".matches(searchString) || true; - } - catch (java.util.regex.PatternSyntaxException e) { - chat("Search string is not a valid Regular Expression: " + e.getMessage()); //NON-NLS - return false; - } - } - /** * Checks a single string against our search parameters * @param target - string to check - * @param searchString - our search string + * @param searchString - our search string - unless superseded by regexPattern * @return true if this is a match based on our "matchCase" & "matchRegex"checkboxes. */ private boolean checkString(String target, String searchString) { - if (searchParameters.isMatchRegex()) { + if (regexPattern == null) { if (searchParameters.isMatchCase()) { - return target.matches(searchString); + return target.contains(searchString); } else { - return target.toLowerCase().matches(searchString.toLowerCase()); + return target.toLowerCase().contains(searchString.toLowerCase()); } } else { - if (searchParameters.isMatchCase()) { - return target.contains(searchString); + // Regular Expression check - match on pattern established in setupRegexPattern() + return regexPattern.matcher(target).find(); + } + } + + /** + * Initialise a Pattern for subsequent Matcher / Matches + * @param searchString - Regex search string + * @return Pattern for searches, with an applied default + */ + private Pattern setupRegexSearch(String searchString) { + + final String caseModifier = (!searchParameters.isMatchCase() ? "" : "(?i)"); + String regexSearchString = caseModifier + searchString; + + // If the string contains no Regex operands, establish a useful default + // FIXME: escape characters will be interpreted as regex, bypassing the default. This may be ok anyway. + if (!searchString.matches("\\[a-zA-Z]|\\*|\\.|\\?|\\^|\\$|\\(.*?\\)|\\[.*?\\]|\\{.*?\\}|\\|")) { + try { + Pattern.compile(".*\\b" + regexSearchString + ".*?"); // test default + regexSearchString = ".*\\b" + regexSearchString + ".*?"; // accept default + chat(Resources.getString("Editor.search_regexDefault") + noHTML(searchString)); // NON-NLS } - else { - return target.toLowerCase().contains(searchString.toLowerCase()); + catch (java.util.regex.PatternSyntaxException e) { + // tolerate the exception and carry on + logger.warn("Pattern Syntax Error in default Editor Search: " + noHTML(e.getMessage())); //NON-NLS } } + + try { + return Pattern.compile(regexSearchString); + } + catch (java.util.regex.PatternSyntaxException e) { + chat("Search string is not a valid Regular Expression: " + noHTML(e.getMessage())); //NON-NLS + return null; + } } } diff --git a/vassal-app/src/main/resources/VASSAL/i18n/Editor.properties b/vassal-app/src/main/resources/VASSAL/i18n/Editor.properties index 440d9f2aae..19a08405b4 100644 --- a/vassal-app/src/main/resources/VASSAL/i18n/Editor.properties +++ b/vassal-app/src/main/resources/VASSAL/i18n/Editor.properties @@ -72,6 +72,7 @@ Editor.search_string=String to find Editor.search_next=Find next Editor.search_case=Exact case Editor.search_regex=Regular Expression +Editor.search_regexDefault=No Regex special characters detected; searching for phrase: Editor.search_advanced=Advanced search Editor.search_names=Match names Editor.search_types=Match [Class names] @@ -83,7 +84,9 @@ Editor.search_menus=Match UI text Editor.search_messages=Match message formats Editor.search_all_off=Warning - all field flags unchecked - turning on 'Match Names' Editor.search_none_found=Search string not found: -Editor.search_count= Matches found for search string: +Editor.search_noRegex_match=No match found for Regular Expression: +Editor.search_count=matches found for search string +Editor.search_countRegex=matches found for Regular Expression Editor.edit_extension=Edit extension Editor.new_extension=New extension Editor.cant_cut_ancestor_to_child=Can't paste a cut ancestor to its own child. diff --git a/vassal-doc/src/main/readme-referencemanual/ReferenceManual/Search.adoc b/vassal-doc/src/main/readme-referencemanual/ReferenceManual/Search.adoc index 0a43bfd491..b2a4de6f91 100644 --- a/vassal-doc/src/main/readme-referencemanual/ReferenceManual/Search.adoc +++ b/vassal-doc/src/main/readme-referencemanual/ReferenceManual/Search.adoc @@ -9,7 +9,9 @@ VASSAL's Module Editor has a powerful Search facility to find components and pieces in a number of different ways. -The default search is to search for everything, but the Advanced Searh options can be used to limit the type of search performed. +The default search is to search for everything, but the Advanced Search options can be used to limit the type of search performed. + +Note that a search will continue from the last item found and will wrap-around, until you modify the search criteria. This applies even across a restart of editing sessions. ''''' @@ -22,7 +24,7 @@ a| Force an Exact case search (i.e. abc is different to ABC). ===== Regular Expression -The specified search string is a https://en.wikipedia.org/wiki/Regular_expression[Regular Expression]. In the simplest example, a search for ABC will find matches that equate wholly to "ABC", and lower or mixed case variants (unless _Exact Case_ is checked as well). +The specified search string is a https://en.wikipedia.org/wiki/Regular_expression[Regular Expression]. If the Search string lacks Regex special characters, a pattern of the form `.\*/b.*` will be attempted. This default will find matches on words or phrases starting with the search string. ===== Advanced Search Show the advanced search options. All search options are on in a simple search. The advanced options allow you to turn off options to narrow your search.