Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions FitNesseRoot/FitNesse/ReleaseNotes/content.txt
Original file line number Diff line number Diff line change
@@ -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]]).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.) |
Expand All @@ -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. |
Expand Down
6 changes: 6 additions & 0 deletions src/fitnesse/resources/css/fitnesse_pages.css
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ span.error {
font-style: italic;
}

/** Search results **/
.symlink {
font-style: italic;
font-weight: bold;
}

.CodeMirror {
border: 1px solid #d7d7d7;
}
Expand Down
28 changes: 28 additions & 0 deletions src/fitnesse/resources/templates/searchForm.vm
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@
#end
<input type="radio" id="leaf" name="searchScope" value="" #if( !$request.hasInput("searchScope") || $request.getInput("searchScope").equals("") ) checked="checked"#end#/>$page.getName()
</fieldset>
<fieldset>
<label for="includeSymLinks">Options:</label>
<input type="checkbox" id="includeSymLinks" name="includeSymLinks"#if( $request.hasInput("includeSymLinks")) checked="checked"#end/>Include symbolic links
<input type="checkbox" id="useWildcards" name="useWildcards"#if( $request.hasInput("useWildcards")) checked="checked"#end/>Use wildcards (*)
<select id="searchThreads" name="searchThreads">
#foreach( $threads in $resultResponder.getSearchThreadChoices() )
<option value="$threads"#if( $threads == $resultResponder.getSelectedSearchThreads() ) selected="selected"#end>$threads</option>
#end
</select>Threads
</fieldset>
<fieldset class="buttons">
<input type="submit" name="searchType" value="Search Titles" data-toggle="tooltip" data-placement="bottom" title="Searches in page titles only. Will run fairly quickly."/>
<input type="submit" name="searchType" value="Search Content" data-toggle="tooltip" data-placement="bottom" title="Searches in the content of every page. Don't hold your breath."/>
Expand Down Expand Up @@ -54,6 +64,15 @@
#end
<input type="radio" id="leaf" name="searchScope" value="" #if( !$request.hasInput("searchScope") || $request.getInput("searchScope").equals("") ) checked="checked"#end#/>$page.getName()
</fieldset>
<fieldset>
<label for="includeSymLinks">Options:</label>
<input type="checkbox" id="includeSymLinks" name="includeSymLinks"#if( $request.hasInput("includeSymLinks")) checked="checked"#end/>Include symbolic links
<select id="searchThreads" name="searchThreads">
#foreach( $threads in $resultResponder.getSearchThreadChoices() )
<option value="$threads"#if( $threads == $resultResponder.getSelectedSearchThreads() ) selected="selected"#end>$threads</option>
#end
</select>Threads
</fieldset>
<fieldset class="buttons">
<input type="submit" name="searchType" value="Search Content" data-toggle="tooltip" data-placement="bottom" title="Searches in the content of every page. Don't hold your breath."/>
<a class="button" href="$viewLocation">Back</a>
Expand All @@ -75,6 +94,15 @@
#end
<input type="radio" id="leaf" name="searchScope" value="" #if( !$request.hasInput("searchScope") || $request.getInput("searchScope").equals("") ) checked="checked"#end#/>$page.getName()
</fieldset>
<fieldset>
<label for="includeSymLinks">Options:</label>
<input type="checkbox" id="includeSymLinks" name="includeSymLinks"#if( $request.hasInput("includeSymLinks")) checked="checked"#end/>Include symbolic links
<select id="searchThreads" name="searchThreads">
#foreach( $threads in $resultResponder.getSearchThreadChoices() )
<option value="$threads"#if( $threads == $resultResponder.getSelectedSearchThreads() ) selected="selected"#end>$threads</option>
#end
</select>Threads
</fieldset>

<div class="properties">
<fieldset>
Expand Down
17 changes: 13 additions & 4 deletions src/fitnesse/resources/templates/searchResults.vm
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
<a href="javascript:void(tableSorter.sort(2));">Tags</a>
</th>
<th>
<a href="javascript:void(tableSorter.sort(3, 'date'));">LastModified</a>
<a href="javascript:void(tableSorter.sort(3));">Via SymLink</a>
</th>
<th>
<a href="javascript:void(tableSorter.sort(4, 'date'));">LastModified</a>
</th>
</tr>
</thead>
Expand All @@ -31,12 +34,17 @@
#end
</td>
<td>
#if( $result.isSymbolicPage() )
<a href="$pagePath">#foreach( $pathPage in $resultResponder.getPathPages($result) )#if( $resultResponder.isSymbolicLink($pathPage) )<span class="symlink" title="symbolic link">$pathPage.getName()</span>#else$pathPage.getName()#end#if( $foreach.hasNext ).#end#end</a>
#else
<a href="$pagePath">$pagePath</a>
#end
</td>
<td style="text-align: right;">
#set ( $tags = $result.getData().getAttribute("Suites") )
<label>#if ($tags && !$tags.equals("null"))#escape($tags)#end</label>
</td>
<td>#if($result.isSymbolicPage())yes#{else}no#end</td>
<td>$result.getData().getProperties().getLastModificationTime()
</td>
</tr>
Expand All @@ -60,9 +68,10 @@
<pre>[[Search below !-$page.getName()-! for &lt;description&gt;][$page.getPageCrawler().getFullPath()?$queryString]]</pre>

#if ($hits > 1)
<script language="javascript">document.getElementById("feedback").innerHTML = 'Found $hits results for your search.'</script>
<script language="javascript">document.getElementById("feedback").innerHTML = 'Found $hits results for your search in $resultResponder.getSearchTime() seconds.'</script>
#elseif ($hits == 1)
<script language="javascript">document.getElementById("feedback").innerHTML = 'Found $hits result for your search.'</script>
<script language="javascript">document.getElementById("feedback").innerHTML = 'Found $hits result for your search in $resultResponder.getSearchTime() seconds.'</script>
#else
<script language="javascript">document.getElementById("feedback").innerHTML = 'No pages matched your search criteria.'</script>
<script language="javascript">document.getElementById("feedback").innerHTML = 'No pages matched your search criteria. Search took $resultResponder.getSearchTime() seconds.'
document.getElementById("searchResultsTable").style.display = 'none'</script>
#end
12 changes: 12 additions & 0 deletions src/fitnesse/responders/refactoring/SearchReplaceResponder.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ protected PageFinder getPageFinder(TraversalListener<WikiPage> 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");
}
Expand Down
88 changes: 87 additions & 1 deletion src/fitnesse/responders/search/ResultResponder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -90,12 +103,85 @@ protected void doSending() throws IOException {

@Override
public final void traverse(TraversalListener<WikiPage> 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<WikiPage> 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<WikiPage> getPathPages(WikiPage page) {
LinkedList<WikiPage> 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() ;
Expand Down
35 changes: 31 additions & 4 deletions src/fitnesse/responders/search/SearchResponder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -53,13 +54,17 @@ protected PageFinder getPageFinder(TraversalListener<WikiPage> 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);
}
}
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/fitnesse/wiki/PageCrawler.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import fitnesse.components.TraversalListener;

import java.util.List;

public class PageCrawler {

private final WikiPage context;
Expand Down Expand Up @@ -121,7 +123,8 @@ private void traverse(WikiPage page, TraversalListener<? super WikiPage> listene
if(pruningStrategy.skipPageAndChildren(page)){ return; }

listener.process(page);
for (WikiPage wikiPage : page.getChildren()) {
List<WikiPage> children = pruningStrategy.skipSymbolicLinks() ? page.getChildrenWithoutSymbolicLinks() : page.getChildren();
for (WikiPage wikiPage : children) {
traverse(wikiPage, listener, pruningStrategy);
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/fitnesse/wiki/PagePruningStrategy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
21 changes: 21 additions & 0 deletions src/fitnesse/wiki/SymbolicLinkPruningStrategy.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading