diff --git a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt index 41bb45ac6..3a12a09b7 100644 --- a/FitNesseRoot/FitNesse/ReleaseNotes/content.txt +++ b/FitNesseRoot/FitNesse/ReleaseNotes/content.txt @@ -1,4 +1,8 @@ !2 Pending Changes + * Search skips symbolic links by default (check ''Include symbolic links'' to search them too); search results show which pages are reached via a symbolic link and how long the search took ([[1636][https://github.com/unclebob/fitnesse/pull/1636]]). + * Faster title search: when symbolic links are skipped, page files are no longer read just to find child pages ([[1637][https://github.com/unclebob/fitnesse/pull/1637]]). + * Searches can read pages with up to 4 threads in parallel (''Threads'' on the search form, searchThreads input) ([[1637][https://github.com/unclebob/fitnesse/pull/1637]]). + * Text search supports * as wildcard when ''Use wildcards'' is checked (useWildcards input) ([[1638][https://github.com/unclebob/fitnesse/pull/1638]]). !2 20261101 * Refactor page shows tree structure ([[1617][https://github.com/unclebob/fitnesse/pull/1617]]). diff --git a/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt b/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt index 93c9a3c71..13f54f4f7 100644 --- a/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt +++ b/FitNesseRoot/FitNesse/UserGuide/AdministeringFitNesse/RestfulServices/content.txt @@ -133,7 +133,10 @@ | | searchString | The string to search for. For text search, this is a string, not a regular expression. For method search, it is a line from wiki which evaluates to a method or a scenario name. | | | searchType | May be "title" or "content". If absent or "title" then searches for pages whose names contain the ''searchString'', otherwise searches for pages whose content contains the ''searchString''. | | | isMethodSearch | If present, a method search is performed instead of a text search. Only works together with searchType=content. | +| | useWildcards | Text search only: if present, an asterisk (*) in ''searchString'' matches any characters within a line, and * alone finds all pages. Without it, the ''searchString'' is matched literally. | | | searchScope | Limits the search to the pages below the given ancestor page name, or the word "root" to search the whole wiki. Otherwise the selected page and its children are searched. | +| | includeSymLinks | Also search the pages behind symbolic links. Without it, symbolic links and the pages below them are skipped. | +| | searchThreads | Number of threads (1-4) reading pages during the search. 1, the default, searches sequentially; more threads read pages ahead in parallel, which makes searches on large wikis faster but puts more load on the server. | | searchForm | | Returns the search form that allows users to specify a search. | | searchProperties | | Returns a list of pages that match the search criteria | | | pageType | a comma separated list of page types to include in the search {Static, Test, Suite}. (The former name !-PageType-! is still accepted.) | @@ -143,6 +146,8 @@ | | Special | a list of !-{SetUp, TearDown}-! or the word "Any". The listed kinds of setup/teardown pages are included, the ones not listed are excluded from the search. | | | Prune | Only find pages with the "skipped" attribute selected. (The former name ''obsolete'' is still accepted.) | | | searchScope | Limits the search to the pages below the given ancestor page name, or the word "root" to search the whole wiki. | +| | includeSymLinks | Also search the pages behind symbolic links. Without it, symbolic links and the pages below them are skipped. | +| | searchThreads | Number of threads (1-4) reading pages during the search. 1, the default, searches sequentially; more threads read pages ahead in parallel, which makes searches on large wikis faster but puts more load on the server. | | shutdown | | Shuts !-FitNesse-! down. | | stoptest | | Stops running tests. | | | id | If present, is the id of the test run to stop, as returned in the ''X-FitNesse-Test-Id'' header of the ''test'' or ''suite'' response. Otherwise stops ''all'' running tests. | diff --git a/src/fitnesse/resources/css/fitnesse_pages.css b/src/fitnesse/resources/css/fitnesse_pages.css index 194f28783..c0242f324 100644 --- a/src/fitnesse/resources/css/fitnesse_pages.css +++ b/src/fitnesse/resources/css/fitnesse_pages.css @@ -298,6 +298,12 @@ span.error { font-style: italic; } +/** Search results **/ +.symlink { + font-style: italic; + font-weight: bold; +} + .CodeMirror { border: 1px solid #d7d7d7; } diff --git a/src/fitnesse/resources/templates/searchForm.vm b/src/fitnesse/resources/templates/searchForm.vm index d33c35c40..d660b28e4 100644 --- a/src/fitnesse/resources/templates/searchForm.vm +++ b/src/fitnesse/resources/templates/searchForm.vm @@ -27,6 +27,16 @@ #end $page.getName() +
+ + Include symbolic links + Use wildcards (*) + Threads +
@@ -54,6 +64,15 @@ #end $page.getName()
+
+ + Include symbolic links + Threads +
Back @@ -75,6 +94,15 @@ #end $page.getName()
+
+ + Include symbolic links + Threads +
diff --git a/src/fitnesse/resources/templates/searchResults.vm b/src/fitnesse/resources/templates/searchResults.vm index e49a83645..5ac6de8ab 100644 --- a/src/fitnesse/resources/templates/searchResults.vm +++ b/src/fitnesse/resources/templates/searchResults.vm @@ -12,7 +12,10 @@ Tags - LastModified + Via SymLink + + + LastModified @@ -31,12 +34,17 @@ #end + #if( $result.isSymbolicPage() ) + #foreach( $pathPage in $resultResponder.getPathPages($result) )#if( $resultResponder.isSymbolicLink($pathPage) )$pathPage.getName()#else$pathPage.getName()#end#if( $foreach.hasNext ).#end#end + #else $pagePath + #end #set ( $tags = $result.getData().getAttribute("Suites") ) + #if($result.isSymbolicPage())yes#{else}no#end $result.getData().getProperties().getLastModificationTime() @@ -60,9 +68,10 @@
[[Search below !-$page.getName()-! for <description>][$page.getPageCrawler().getFullPath()?$queryString]]
#if ($hits > 1) - + #elseif ($hits == 1) - + #else - + #end diff --git a/src/fitnesse/responders/refactoring/SearchReplaceResponder.java b/src/fitnesse/responders/refactoring/SearchReplaceResponder.java index f6dc8c3a5..ae1f24cf4 100644 --- a/src/fitnesse/responders/refactoring/SearchReplaceResponder.java +++ b/src/fitnesse/responders/refactoring/SearchReplaceResponder.java @@ -45,6 +45,18 @@ protected PageFinder getPageFinder(TraversalListener webOutputObserver } } + @Override + protected boolean includeSymbolicLinks() { + // replace has always changed the pages behind symbolic links too + return true; + } + + @Override + public int getSearchThreads() { + // replace changes pages while traversing, so pages must not be loaded ahead + return 1; + } + private boolean isMethodReplace() { return request.hasInput("isMethodReplace"); } diff --git a/src/fitnesse/responders/search/ResultResponder.java b/src/fitnesse/responders/search/ResultResponder.java index d19aa68c9..baac0f462 100644 --- a/src/fitnesse/responders/search/ResultResponder.java +++ b/src/fitnesse/responders/search/ResultResponder.java @@ -10,12 +10,20 @@ import fitnesse.html.template.HtmlPage; import fitnesse.html.template.PageTitle; import fitnesse.responders.ChunkingResponder; +import fitnesse.wiki.NoPruningStrategy; import fitnesse.wiki.PageCrawler; +import fitnesse.wiki.PagePruningStrategy; import fitnesse.wiki.PageType; +import fitnesse.wiki.SymbolicLinkPruningStrategy; +import fitnesse.wiki.SymbolicPage; import fitnesse.wiki.WikiPage; import fitnesse.wiki.search.PageFinder; +import fitnesse.wiki.search.WikiPageFinder; import java.io.IOException; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; import static fitnesse.wiki.PageData.PropertyEDIT; import static fitnesse.wiki.PageData.PropertyFILES; @@ -37,6 +45,11 @@ public abstract class ResultResponder extends ChunkingResponder implements static final String[] SEARCH_NAVIGATION_ATTRIBUTES = { PropertyRECENT_CHANGES, PropertyFILES, PropertySEARCH }; static final String SEARCH_ATTRIBUTE_SKIP = PropertyPRUNE; static final String[] SPECIAL_ATTRIBUTES = { "SetUp", "TearDown" }; + static final String INCLUDE_SYMBOLIC_LINKS = "includeSymLinks"; + static final String SEARCH_THREADS = "searchThreads"; + + private long searchStartTime; + private long searchEndTime; @Override protected PageCrawler getPageCrawler() { @@ -90,12 +103,85 @@ protected void doSending() throws IOException { @Override public final void traverse(TraversalListener observer) { + searchStartTime = System.nanoTime(); + try { + search(observer); + } finally { + searchEndTime = System.nanoTime(); + } + } + + /** + * @return the time the search took, in seconds with two decimals. + */ + public String getSearchTime() { + return String.format(Locale.ROOT, "%.2f", (searchEndTime - searchStartTime) / 1_000_000_000.0); + } + + private void search(TraversalListener observer) { PageFinder pageFinder = getPageFinder(observer); - if (pageFinder != null) { + if (pageFinder instanceof WikiPageFinder) { + PagePruningStrategy strategy = includeSymbolicLinks() + ? new NoPruningStrategy() : new SymbolicLinkPruningStrategy(); + ((WikiPageFinder) pageFinder).search(getSearchScope(), strategy, getSearchThreads()); + } else if (pageFinder != null) { pageFinder.search(getSearchScope()); } } + /** + * @return true to also search the pages behind symbolic links, as requested; false by default. + */ + protected boolean includeSymbolicLinks() { + return request.hasInput(INCLUDE_SYMBOLIC_LINKS); + } + + /** + * @return the number of threads to load pages with, as requested; 1 by default. + */ + public int getSearchThreads() { + try { + int threads = Integer.parseInt(request.getInput(SEARCH_THREADS)); + return Math.max(1, Math.min(threads, WikiPageFinder.MAX_THREADS)); + } catch (NumberFormatException e) { + return 1; + } + } + + /** + * @return the number of threads to preselect on the search form: as requested, or the most threads by default. + */ + public int getSelectedSearchThreads() { + return request.hasInput(SEARCH_THREADS) ? getSearchThreads() : WikiPageFinder.MAX_THREADS; + } + + /** + * @return the numbers of threads to choose from. + */ + public int[] getSearchThreadChoices() { + int[] choices = new int[WikiPageFinder.MAX_THREADS]; + for (int i = 0; i < choices.length; i++) { + choices[i] = i + 1; + } + return choices; + } + + /** + * @param page a search result + * @return the pages making up the full path of the page, from the top level page down to the page itself + */ + public List getPathPages(WikiPage page) { + LinkedList pathPages = new LinkedList<>(); + for (WikiPage current = page; !current.isRoot(); current = current.getParent()) { + pathPages.addFirst(current); + } + return pathPages; + } + + public boolean isSymbolicLink(WikiPage page) { + return SymbolicPage.isSymbolicLink(page); + } + protected abstract String getTemplate(); protected abstract String getTitle() ; diff --git a/src/fitnesse/responders/search/SearchResponder.java b/src/fitnesse/responders/search/SearchResponder.java index 802dd49e5..391ccbfd9 100644 --- a/src/fitnesse/responders/search/SearchResponder.java +++ b/src/fitnesse/responders/search/SearchResponder.java @@ -14,6 +14,7 @@ import static java.util.regex.Pattern.CASE_INSENSITIVE; import static java.util.regex.Pattern.LITERAL; +import static java.util.regex.Pattern.UNICODE_CASE; public class SearchResponder extends ResultResponder { @@ -53,13 +54,17 @@ protected PageFinder getPageFinder(TraversalListener observer) { String searchString = getSearchString(); if (!"".equals(searchString)) { String searchType = getSearchType(); - if ("Title".equals(searchType)) - return new TitleWikiPageFinder(searchString, observer); - else { + if ("Title".equals(searchType)) { + return useWildcards() + ? new TitleWikiPageFinder(wildcardPattern(searchString), observer) + : new TitleWikiPageFinder(searchString, observer); + } else { if (isMethodSearch()) { return new MethodWikiPageFinder(searchString, observer); } else { - Pattern regularExpression = Pattern.compile(searchString, CASE_INSENSITIVE + LITERAL); + Pattern regularExpression = useWildcards() + ? wildcardPattern(searchString) + : Pattern.compile(searchString, CASE_INSENSITIVE + LITERAL); return new RegularExpressionWikiPageFinder(regularExpression, observer); } } @@ -71,6 +76,28 @@ private boolean isMethodSearch() { return request.hasInput("isMethodSearch"); } + private boolean useWildcards() { + return request.hasInput("useWildcards"); + } + + /** + * @param searchString text to search for, where * matches any characters (within a line) + * @return case insensitive pattern matching the search string + */ + static Pattern wildcardPattern(String searchString) { + StringBuilder regularExpression = new StringBuilder(); + String[] parts = searchString.split("\\*", -1); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + regularExpression.append(".*"); + } + if (!parts[i].isEmpty()) { + regularExpression.append(Pattern.quote(parts[i])); + } + } + return Pattern.compile(regularExpression.toString(), CASE_INSENSITIVE | UNICODE_CASE); + } + @Override protected boolean shouldRespondWith404() { return false; diff --git a/src/fitnesse/wiki/PageCrawler.java b/src/fitnesse/wiki/PageCrawler.java index 8c20f4cd9..de4c0bdf0 100644 --- a/src/fitnesse/wiki/PageCrawler.java +++ b/src/fitnesse/wiki/PageCrawler.java @@ -4,6 +4,8 @@ import fitnesse.components.TraversalListener; +import java.util.List; + public class PageCrawler { private final WikiPage context; @@ -121,7 +123,8 @@ private void traverse(WikiPage page, TraversalListener listene if(pruningStrategy.skipPageAndChildren(page)){ return; } listener.process(page); - for (WikiPage wikiPage : page.getChildren()) { + List children = pruningStrategy.skipSymbolicLinks() ? page.getChildrenWithoutSymbolicLinks() : page.getChildren(); + for (WikiPage wikiPage : children) { traverse(wikiPage, listener, pruningStrategy); } } diff --git a/src/fitnesse/wiki/PagePruningStrategy.java b/src/fitnesse/wiki/PagePruningStrategy.java index 826135750..05828302e 100644 --- a/src/fitnesse/wiki/PagePruningStrategy.java +++ b/src/fitnesse/wiki/PagePruningStrategy.java @@ -2,4 +2,11 @@ public interface PagePruningStrategy { boolean skipPageAndChildren(WikiPage page); + + /** + * @return true to leave out symbolic links (and the pages below them) without resolving them. + */ + default boolean skipSymbolicLinks() { + return false; + } } diff --git a/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java b/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java new file mode 100644 index 000000000..57a55956d --- /dev/null +++ b/src/fitnesse/wiki/SymbolicLinkPruningStrategy.java @@ -0,0 +1,21 @@ +// Copyright (C) 2026 by netcare GmbH. All rights reserved. +// Released under the terms of the CPL Common Public License version 1.0. +package fitnesse.wiki; + +/** + * Leaves out symbolic links (and everything below them) found while traversing from a start page. + * The start page itself is never left out, so traversals that start at a symbolic link still + * cover the linked pages. + */ +public class SymbolicLinkPruningStrategy implements PagePruningStrategy { + + @Override + public boolean skipPageAndChildren(WikiPage page) { + return false; + } + + @Override + public boolean skipSymbolicLinks() { + return true; + } +} diff --git a/src/fitnesse/wiki/SymbolicPage.java b/src/fitnesse/wiki/SymbolicPage.java index b56e2805c..0493fb04d 100644 --- a/src/fitnesse/wiki/SymbolicPage.java +++ b/src/fitnesse/wiki/SymbolicPage.java @@ -68,6 +68,15 @@ public List getChildren() { return symChildren; } + @Override + public List getChildrenWithoutSymbolicLinks() { + List symChildren = new LinkedList<>(); + for (WikiPage child : realPage.getChildrenWithoutSymbolicLinks()) { + symChildren.add(createChildPage(child)); + } + return symChildren; + } + private WikiPage createChildPage(WikiPage child) { WikiPage cyclicReference = findCyclicReference(child); if (cyclicReference != null) { @@ -146,6 +155,16 @@ public int hashCode() { return realPage.hashCode(); } + /** + * @param wikiPage page to check + * @return true if the page is a symbolic link, false for real pages and for pages below a symbolic link + */ + public static boolean isSymbolicLink(WikiPage wikiPage) { + // pages below a link wrap their real page, links below a link wrap that link + return wikiPage.isSymbolicPage() + && (!wikiPage.getParent().isSymbolicPage() || isSymbolicLink(wikiPage.getRealPage())); + } + public static boolean containsWikitext(WikiPage wikiPage) { if (wikiPage instanceof SymbolicPage) { return containsWikitext(((SymbolicPage) wikiPage).realPage); diff --git a/src/fitnesse/wiki/WikiPage.java b/src/fitnesse/wiki/WikiPage.java index 77aa3b7d0..d0acd8115 100644 --- a/src/fitnesse/wiki/WikiPage.java +++ b/src/fitnesse/wiki/WikiPage.java @@ -3,6 +3,7 @@ package fitnesse.wiki; +import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -54,6 +55,22 @@ public interface WikiPage extends Comparable { */ List getChildren(); + /** + * Get child pages for this wiki page, leaving out symbolic links. Implementations that can list their children + * without resolving symbolic links override this, to avoid loading page data. + * + * @return children that are no symbolic links, an empty list if there are none. + */ + default List getChildrenWithoutSymbolicLinks() { + List children = new ArrayList<>(); + for (WikiPage child : getChildren()) { + if (!SymbolicPage.isSymbolicLink(child)) { + children.add(child); + } + } + return children; + } + String getName(); PageData getData(); diff --git a/src/fitnesse/wiki/fs/FileSystemPage.java b/src/fitnesse/wiki/fs/FileSystemPage.java index 7ad7ec3f8..cb9bd0930 100644 --- a/src/fitnesse/wiki/fs/FileSystemPage.java +++ b/src/fitnesse/wiki/fs/FileSystemPage.java @@ -102,6 +102,11 @@ public List getChildren() { return subWikiPageFactory.getChildren(this); } + @Override + public List getChildrenWithoutSymbolicLinks() { + return subWikiPageFactory.getChildrenWithoutSymbolicLinks(this); + } + @Override public WikiPage getChildPage(String childName) { return subWikiPageFactory.getChildPage(this, childName); diff --git a/src/fitnesse/wiki/fs/FileSystemSubWikiPageFactory.java b/src/fitnesse/wiki/fs/FileSystemSubWikiPageFactory.java index 9b1ca9115..d70aa538a 100644 --- a/src/fitnesse/wiki/fs/FileSystemSubWikiPageFactory.java +++ b/src/fitnesse/wiki/fs/FileSystemSubWikiPageFactory.java @@ -29,13 +29,18 @@ public List getChildren(FileBasedWikiPage page) { return children; } + @Override + public List getChildrenWithoutSymbolicLinks(FileBasedWikiPage page) { + return new ArrayList<>(getNormalChildren(page)); + } + private Set getNormalChildren(FileBasedWikiPage page) { final File thisDir = page.getFileSystemPath(); final Set children = new TreeSet<>(); if (fileSystem.exists(thisDir)) { final String[] subFiles = fileSystem.list(thisDir); for (String subFile : subFiles) { - WikiPage maybeChildPage = getChildPage(page, subFile); + WikiPage maybeChildPage = makeChildPage(new File(thisDir, subFile), subFile, page); if (maybeChildPage != null) { children.add(maybeChildPage); } diff --git a/src/fitnesse/wiki/fs/SubWikiPageFactory.java b/src/fitnesse/wiki/fs/SubWikiPageFactory.java index 99a4f2378..96faf3f1b 100644 --- a/src/fitnesse/wiki/fs/SubWikiPageFactory.java +++ b/src/fitnesse/wiki/fs/SubWikiPageFactory.java @@ -8,5 +8,7 @@ public interface SubWikiPageFactory { List getChildren(FileBasedWikiPage wikiPage); + List getChildrenWithoutSymbolicLinks(FileBasedWikiPage wikiPage); + WikiPage getChildPage(FileBasedWikiPage wikiPage, String childName); } diff --git a/src/fitnesse/wiki/fs/WikiFilePage.java b/src/fitnesse/wiki/fs/WikiFilePage.java index c2e692961..80ab20792 100644 --- a/src/fitnesse/wiki/fs/WikiFilePage.java +++ b/src/fitnesse/wiki/fs/WikiFilePage.java @@ -84,6 +84,11 @@ public List getChildren() { return subWikiPageFactory.getChildren(this); } + @Override + public List getChildrenWithoutSymbolicLinks() { + return subWikiPageFactory.getChildrenWithoutSymbolicLinks(this); + } + @Override public PageData getData() { if (pageData == null) { diff --git a/src/fitnesse/wiki/search/ParallelTraversal.java b/src/fitnesse/wiki/search/ParallelTraversal.java new file mode 100644 index 000000000..b13c8700b --- /dev/null +++ b/src/fitnesse/wiki/search/ParallelTraversal.java @@ -0,0 +1,97 @@ +// Copyright (C) 2026 by netcare GmbH. All rights reserved. +// Released under the terms of the CPL Common Public License version 1.0. +package fitnesse.wiki.search; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import fitnesse.components.TraversalListener; +import fitnesse.wiki.PagePruningStrategy; +import fitnesse.wiki.WikiPage; + +/** + * Traverses a page tree like {@link fitnesse.wiki.PageCrawler#traverse}, but reads pages ahead on worker threads: + * the child pages (and optionally the page data) of all children of a page are loaded in parallel. + * Pages are still checked against the pruning strategy and passed to the listener one at a time, + * on the calling thread and in the same order, so neither has to be thread safe. + */ +class ParallelTraversal { + private final TraversalListener listener; + private final PagePruningStrategy pruningStrategy; + private final boolean loadPageData; + private final boolean skipSymbolicLinks; + private final int threads; + private ExecutorService executor; + + ParallelTraversal(TraversalListener listener, PagePruningStrategy pruningStrategy, boolean loadPageData, + int threads) { + this.listener = listener; + this.pruningStrategy = pruningStrategy; + this.loadPageData = loadPageData; + this.skipSymbolicLinks = pruningStrategy.skipSymbolicLinks(); + this.threads = threads; + } + + void traverse(WikiPage startPage) { + executor = Executors.newFixedThreadPool(threads, ParallelTraversal::newThread); + try { + traverse(startPage, load(startPage)); + } finally { + executor.shutdownNow(); + } + } + + private void traverse(WikiPage page, Future> loadedChildren) { + // wait for the page to be loaded before the pruning strategy or the listener may look at it + List children = await(loadedChildren); + if (pruningStrategy.skipPageAndChildren(page)) { + return; + } + listener.process(page); + + List>> loadedGrandChildren = new ArrayList<>(children.size()); + for (WikiPage child : children) { + loadedGrandChildren.add(load(child)); + } + for (int i = 0; i < children.size(); i++) { + traverse(children.get(i), loadedGrandChildren.get(i)); + } + } + + private Future> load(WikiPage page) { + return executor.submit(() -> { + if (loadPageData) { + page.getData(); + } + return skipSymbolicLinks ? page.getChildrenWithoutSymbolicLinks() : page.getChildren(); + }); + } + + private static T await(Future future) { + try { + return future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while loading pages", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new IllegalStateException(cause); + } + } + + private static Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "page loader"); + thread.setDaemon(true); + return thread; + } +} diff --git a/src/fitnesse/wiki/search/TitleWikiPageFinder.java b/src/fitnesse/wiki/search/TitleWikiPageFinder.java index a9dc037cd..d9a09d31e 100644 --- a/src/fitnesse/wiki/search/TitleWikiPageFinder.java +++ b/src/fitnesse/wiki/search/TitleWikiPageFinder.java @@ -1,21 +1,31 @@ package fitnesse.wiki.search; +import java.util.regex.Pattern; + import fitnesse.components.TraversalListener; import fitnesse.wiki.WikiPage; public class TitleWikiPageFinder extends WikiPageFinder { - private String searchString; + private final Pattern pattern; public TitleWikiPageFinder(String searchString, TraversalListener observer) { + this(Pattern.compile(searchString, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE | Pattern.LITERAL), observer); + } + + public TitleWikiPageFinder(Pattern pattern, TraversalListener observer) { super(observer); - this.searchString = searchString.toLowerCase(); + this.pattern = pattern; + } + + @Override + protected boolean readsPageData() { + return false; } @Override protected boolean pageMatches(WikiPage page) { - String content = page.getName().toLowerCase(); - return content.contains(searchString); + return pattern.matcher(page.getName()).find(); } } diff --git a/src/fitnesse/wiki/search/WikiPageFinder.java b/src/fitnesse/wiki/search/WikiPageFinder.java index bd489f8f2..9c0914e50 100644 --- a/src/fitnesse/wiki/search/WikiPageFinder.java +++ b/src/fitnesse/wiki/search/WikiPageFinder.java @@ -6,6 +6,7 @@ import fitnesse.wiki.WikiPage; public abstract class WikiPageFinder implements PageFinder { + public static final int MAX_THREADS = 4; private WikiPageTraverser traverser; @@ -15,13 +16,34 @@ protected WikiPageFinder(TraversalListener observer) { protected abstract boolean pageMatches(WikiPage page); + /** + * @return false if {@link #pageMatches(WikiPage)} does not look at the page data, so it need not be loaded ahead. + */ + protected boolean readsPageData() { + return true; + } + @Override public void search(WikiPage page) { - page.getPageCrawler().traverse(traverser, new NoPruningStrategy()); + search(page, new NoPruningStrategy()); } public void search(WikiPage page, PagePruningStrategy strategy) { - page.getPageCrawler().traverse(traverser, strategy); + search(page, strategy, 1); + } + + /** + * @param page page to start searching at + * @param strategy pages to leave out + * @param threads number of threads to load pages with: 1 searches on the calling thread only, + * more load pages ahead on that many worker threads (at most {@link #MAX_THREADS}) + */ + public void search(WikiPage page, PagePruningStrategy strategy, int threads) { + if (threads <= 1) { + page.getPageCrawler().traverse(traverser, strategy); + } else { + new ParallelTraversal(traverser, strategy, readsPageData(), Math.min(threads, MAX_THREADS)).traverse(page); + } } } diff --git a/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java b/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java index 83b0a46d3..cd4b4f20d 100644 --- a/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java +++ b/test/fitnesse/responders/refactoring/SearchReplaceResponderTest.java @@ -77,6 +77,27 @@ public void onlySelectedPageAndChildrenAreSearched() throws Exception { assertThat(content, containsString("SomePage.ChildPage")); } + @Test + public void replacesInPagesBehindSymbolicLinks() throws Exception { + WikiPageUtil.addPage(root, PathParser.parse("LinkedPage"), "linked page has something too"); + PageData data = somePage.getData(); + data.getProperties().set(SymbolicPage.PROPERTY_NAME).set("SymLink", ".LinkedPage"); + somePage.commit(data); + + String content = getResponseContentUsingSearchReplaceString("something", "replacedthing"); + + assertThat(content, containsString("SomePage.SymLink")); + assertEquals("linked page has replacedthing too", root.getChildPage("LinkedPage").getData().getContent()); + } + + @Test + public void replacesWithOneThreadOnly() { + request.addInput("searchThreads", "4"); + SearchReplaceResponder searchReplaceResponder = (SearchReplaceResponder) responder; + searchReplaceResponder.setRequest(request); + assertEquals(1, searchReplaceResponder.getSearchThreads()); + } + private String getResponseContentUsingSearchReplaceString(String searchString, String replacementString) throws Exception { request.addInput("searchString", searchString); request.addInput("replacementString", replacementString); diff --git a/test/fitnesse/responders/search/SearchFormResponderTest.java b/test/fitnesse/responders/search/SearchFormResponderTest.java index ce516d25a..382292307 100644 --- a/test/fitnesse/responders/search/SearchFormResponderTest.java +++ b/test/fitnesse/responders/search/SearchFormResponderTest.java @@ -2,6 +2,8 @@ // Released under the terms of the CPL Common Public License version 1.0. package fitnesse.responders.search; +import static org.junit.Assert.assertEquals; +import static util.RegexTestCase.assertDoesntHaveRegexp; import static util.RegexTestCase.assertHasRegexp; import static util.RegexTestCase.assertSubString; import static fitnesse.responders.search.SearchResponder.*; @@ -53,6 +55,18 @@ public void testForTwoSearchTypes() throws Exception { assertSubString("value=\"Search Content\"", content); } + @Test + public void textSearchOffersWildcards() throws Exception { + assertSubString("name=\"useWildcards\"", content); + assertEquals(content.indexOf("name=\"useWildcards\""), content.lastIndexOf("name=\"useWildcards\"")); + } + + @Test + public void mostSearchThreadsArePreselected() throws Exception { + assertSubString("", content); + assertDoesntHaveRegexp("