Bug 441016 - Speed up text search by parallelizing

Execute text search processing in parallel, yielding a 3x-4x speedup.

Previously all files in a text search were processed serially.
This change moves the work into Jobs and uses a JobGroup to
parallelize the processing. The following additional changes
were needed:
* Some “global” state (e.g., FileCharSequenceProvider and
  ReusableMatchAccess) that were previously accessed as class member
  variables were changed to per-file or per-job instances.
* Access to FileSearchQuery.TextSearchResultCollector needed to be
  synchronized. Its intermediate cache, which was previously an
  ArrayList and assumed the last entry held the previous match from the
  current file, was changed to a Map of IFile->ArrayList so that each
  file’s results are segregated.

Converted spaces to tabs in the search() method to improve the Gerrit
code review experience.

Added minimal performance logging.

Change-Id: I3e36da89cd891acabb902e28415d8ddecea4df60
Signed-off-by: Terry Parker <tparker@google.com>
diff --git a/org.eclipse.search/.options b/org.eclipse.search/.options
new file mode 100644
index 0000000..f80dc7b
--- /dev/null
+++ b/org.eclipse.search/.options
@@ -0,0 +1,4 @@
+# Debugging options for the org.eclipse.search plug-in
+
+# Reports performance of text searches
+org.eclipse.search/perf=false
diff --git a/org.eclipse.search/build.properties b/org.eclipse.search/build.properties
index 139bc09..6b1490b 100644
--- a/org.eclipse.search/build.properties
+++ b/org.eclipse.search/build.properties
@@ -9,6 +9,7 @@
 #     IBM Corporation - initial API and implementation
 ###############################################################################
 bin.includes = plugin.xml,\
+               .options,\
                about.html,\
                icons/,\
                plugin.properties,\
diff --git a/org.eclipse.search/search/org/eclipse/search/internal/core/text/TextSearchVisitor.java b/org.eclipse.search/search/org/eclipse/search/internal/core/text/TextSearchVisitor.java
index b0bb14c..4464e3c 100644
--- a/org.eclipse.search/search/org/eclipse/search/internal/core/text/TextSearchVisitor.java
+++ b/org.eclipse.search/search/org/eclipse/search/internal/core/text/TextSearchVisitor.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (c) 2000, 2008 IBM Corporation and others.
+ * Copyright (c) 2000, 2015 IBM Corporation and others.
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License v1.0
  * which accompanies this distribution, and is available at
@@ -7,6 +7,7 @@
  *
  * Contributors:
  *     IBM Corporation - initial API and implementation
+ *     Terry Parker <tparker@google.com> (Google Inc.) - Bug 441016 - Speed up text search by parallelizing it using JobGroups
  *******************************************************************************/
 package org.eclipse.search.internal.core.text;
 
@@ -32,6 +33,7 @@
 import org.eclipse.core.runtime.content.IContentType;
 import org.eclipse.core.runtime.content.IContentTypeManager;
 import org.eclipse.core.runtime.jobs.Job;
+import org.eclipse.core.runtime.jobs.JobGroup;
 
 import org.eclipse.core.resources.IFile;
 
@@ -67,6 +69,10 @@
  */
 public class TextSearchVisitor {
 
+	public static final boolean TRACING= "true".equalsIgnoreCase(Platform.getDebugOption("org.eclipse.search/perf")); //$NON-NLS-1$ //$NON-NLS-2$
+	private static final int NUMBER_OF_LOGICAL_THREADS= Runtime.getRuntime().availableProcessors();
+	private static final int FILES_PER_JOB= 50;
+
 	public static class ReusableMatchAccess extends TextSearchMatchAccess {
 
 		private int fOffset;
@@ -106,49 +112,134 @@
 		}
 	}
 
+	/**
+	 * A JobGroup for text searches across multiple files.
+	 */
+	private static class TextSearchJobGroup extends JobGroup {
+		public TextSearchJobGroup(String name, int maxThreads, int initialJobCount) {
+			super(name, maxThreads, initialJobCount);
+		}
+
+		// Always continue processing all other files, even if errors are encountered in individual files.
+		protected boolean shouldCancel(IStatus lastCompletedJobResult, int numberOfFailedJobs, int numberOfCancelledJobs) {
+			return false;
+		}
+	}
+
+	/**
+	 * A job to find matches in a set of files.
+	 */
+	private class TextSearchJob extends Job {
+		private final IFile[] fFiles;
+		private final int fBegin;
+		private final int fEnd;
+		private final Map fDocumentsInEditors;
+		private ReusableMatchAccess fReusableMatchAccess;
+		private IProgressMonitor fMonitor;
+
+		/**
+		 * Searches for matches in a set of files.
+		 *
+		 * @param files an array of IFiles, a portion of which is to be processed
+		 * @param begin the first element in the file array to process
+		 * @param end one past the last element in the array to process
+		 * @param documentsInEditors a map from IFile to IDocument for all open, dirty editors
+		 */
+		public TextSearchJob(IFile[] files, int begin, int end, Map documentsInEditors) {
+			super(files[begin].getName());
+			setSystem(true);
+			fFiles = files;
+			fBegin = begin;
+			fEnd = end;
+			fDocumentsInEditors = documentsInEditors;
+		}
+
+		protected IStatus run(IProgressMonitor inner) {
+			fMonitor= inner;
+			MultiStatus multiStatus=
+					new MultiStatus(NewSearchUI.PLUGIN_ID, IStatus.OK, SearchMessages.TextSearchEngine_statusMessage, null);
+			for (int i = fBegin; i < fEnd; i++) {
+				IStatus status= processFile(fFiles[i], this);
+				// Only accumulate interesting status
+				if (!status.isOK())
+					multiStatus.add(status);
+				// Group cancellation is propagated to this job's monitor.
+				// Stop processing and return the status for the completed jobs.
+				if (inner.isCanceled())
+					break;
+			}
+			return multiStatus;
+		}
+
+		public IProgressMonitor getMonitor() {
+			return fMonitor;
+		}
+
+		public Map getDocumentsInEditors() {
+			return fDocumentsInEditors;
+		}
+
+		public ReusableMatchAccess getReusableMatchAccess() {
+			if (fReusableMatchAccess == null)
+				fReusableMatchAccess = new ReusableMatchAccess();
+			return fReusableMatchAccess;
+		}
+	}
+
 
 	private final TextSearchRequestor fCollector;
-	private final Matcher fMatcher;
+	private final Pattern fSearchPattern;
 
 	private IProgressMonitor fProgressMonitor;
 
 	private int fNumberOfScannedFiles;
 	private int fNumberOfFilesToScan;
 	private IFile fCurrentFile;
+	private Object fLock= new Object();
 
 	private final MultiStatus fStatus;
 
-	private final FileCharSequenceProvider fFileCharSequenceProvider;
-
-	private final ReusableMatchAccess fMatchAccess;
-
 	public TextSearchVisitor(TextSearchRequestor collector, Pattern searchPattern) {
 		fCollector= collector;
 		fStatus= new MultiStatus(NewSearchUI.PLUGIN_ID, IStatus.OK, SearchMessages.TextSearchEngine_statusMessage, null);
 
-		fMatcher= searchPattern.pattern().length() == 0 ? null : searchPattern.matcher(new String());
-
-		fFileCharSequenceProvider= new FileCharSequenceProvider();
-		fMatchAccess= new ReusableMatchAccess();
+		fSearchPattern= searchPattern;
 	}
 
 	public IStatus search(IFile[] files, IProgressMonitor monitor) {
+		if (files.length == 0) {
+			return fStatus;
+		}
 		fProgressMonitor= monitor == null ? new NullProgressMonitor() : monitor;
-        fNumberOfScannedFiles= 0;
-        fNumberOfFilesToScan= files.length;
-        fCurrentFile= null;
+		fNumberOfScannedFiles= 0;
+		fNumberOfFilesToScan= files.length;
+		fCurrentFile= null;
+		int jobCount = Math.round((files.length + FILES_PER_JOB - 1) / FILES_PER_JOB);
+		final JobGroup jobGroup= new TextSearchJobGroup("Text Search", NUMBER_OF_LOGICAL_THREADS, jobCount); //$NON-NLS-1$
+		long startTime= TRACING ? System.currentTimeMillis() : 0;
 
-        Job monitorUpdateJob= new Job(SearchMessages.TextSearchVisitor_progress_updating_job) {
-        	private int fLastNumberOfScannedFiles= 0;
+		Job monitorUpdateJob= new Job(SearchMessages.TextSearchVisitor_progress_updating_job) {
+			private int fLastNumberOfScannedFiles= 0;
 
-        	public IStatus run(IProgressMonitor inner) {
-        		while (!inner.isCanceled()) {
-					IFile file= fCurrentFile;
+			public IStatus run(IProgressMonitor inner) {
+				while (!inner.isCanceled()) {
+					// Propagate user cancellation to the JobGroup.
+					if (fProgressMonitor.isCanceled()) {
+						jobGroup.cancel();
+						break;
+					}
+
+					IFile file;
+					int numberOfScannedFiles;
+					synchronized (fLock) {
+						file= fCurrentFile;
+						numberOfScannedFiles= fNumberOfScannedFiles;
+					}
 					if (file != null) {
 						String fileName= file.getName();
-						Object[] args= { fileName, new Integer(fNumberOfScannedFiles), new Integer(fNumberOfFilesToScan)};
+						Object[] args= { fileName, new Integer(numberOfScannedFiles), new Integer(fNumberOfFilesToScan)};
 						fProgressMonitor.subTask(Messages.format(SearchMessages.TextSearchVisitor_scanning, args));
-						int steps= fNumberOfScannedFiles - fLastNumberOfScannedFiles;
+						int steps= numberOfScannedFiles - fLastNumberOfScannedFiles;
 						fProgressMonitor.worked(steps);
 						fLastNumberOfScannedFiles += steps;
 					}
@@ -158,49 +249,61 @@
 						return Status.OK_STATUS;
 					}
 				}
-    			return Status.OK_STATUS;
-        	}
-        };
+				return Status.OK_STATUS;
+			}
+		};
 
-        try {
-        	String taskName= fMatcher == null ? SearchMessages.TextSearchVisitor_filesearch_task_label :  Messages.format(SearchMessages.TextSearchVisitor_textsearch_task_label, fMatcher.pattern().pattern());
-            fProgressMonitor.beginTask(taskName, fNumberOfFilesToScan);
-            monitorUpdateJob.setSystem(true);
-            monitorUpdateJob.schedule();
-            try {
-	            fCollector.beginReporting();
-	            processFiles(files);
-	            return fStatus;
-            } finally {
-                monitorUpdateJob.cancel();
-            }
-        } finally {
-            fProgressMonitor.done();
-            fCollector.endReporting();
-        }
+		try {
+			String taskName= fSearchPattern.pattern().length() == 0
+					? SearchMessages.TextSearchVisitor_filesearch_task_label
+					: Messages.format(SearchMessages.TextSearchVisitor_textsearch_task_label, fSearchPattern.pattern());
+			fProgressMonitor.beginTask(taskName, fNumberOfFilesToScan);
+			monitorUpdateJob.setSystem(true);
+			monitorUpdateJob.schedule();
+			try {
+				fCollector.beginReporting();
+				Map documentsInEditors= PlatformUI.isWorkbenchRunning() ? evalNonFileBufferDocuments() : Collections.EMPTY_MAP;
+				int filesPerJob = (files.length + jobCount - 1) / jobCount;
+				for (int first= 0; first < files.length; first += filesPerJob) {
+					int end= Math.min(files.length, first + filesPerJob);
+					Job job= new TextSearchJob(files, first, end, documentsInEditors);
+					job.setJobGroup(jobGroup);
+					job.schedule();
+				}
+
+				// The monitorUpdateJob is managing progress and cancellation,
+				// so it is ok to pass a null monitor into the job group.
+				jobGroup.join(0, null);
+				if (fProgressMonitor.isCanceled())
+					throw new OperationCanceledException(SearchMessages.TextSearchVisitor_canceled);
+
+				fStatus.addAll(jobGroup.getResult());
+				return fStatus;
+			} catch (InterruptedException e) {
+				throw new OperationCanceledException(SearchMessages.TextSearchVisitor_canceled);
+			} finally {
+				monitorUpdateJob.cancel();
+			}
+		} finally {
+			fProgressMonitor.done();
+			fCollector.endReporting();
+			if (TRACING) {
+				Object[] args= { new Integer(fNumberOfScannedFiles), new Integer(jobCount), new Integer(NUMBER_OF_LOGICAL_THREADS), new Long(System.currentTimeMillis() - startTime) };
+				System.out.println(Messages.format(
+						"[TextSearch] Search duration for {0} files in {1} jobs using {2} threads: {3}ms", args)); //$NON-NLS-1$
+			}
+	   }
 	}
 
 	public IStatus search(TextSearchScope scope, IProgressMonitor monitor) {
 		return search(scope.evaluateFilesInScope(fStatus), monitor);
-    }
-
-	private void processFiles(IFile[] files) {
-		final Map documentsInEditors;
-		if (PlatformUI.isWorkbenchRunning())
-			documentsInEditors= evalNonFileBufferDocuments();
-		else
-			documentsInEditors= Collections.EMPTY_MAP;
-
-        for (int i= 0; i < files.length; i++) {
-        	fCurrentFile= files[i];
-            boolean res= processFile(fCurrentFile, documentsInEditors);
-            if (!res)
-            	break;
-		}
 	}
 
 	/**
-	 * @return returns a map from IFile to IDocument for all open, dirty editors
+	 * Returns a map from IFile to IDocument for all open, dirty editors. After creation this map
+	 * is not modified, so returning a non-synchronized map is ok.
+	 *
+	 * @return a map from IFile to IDocument for all open, dirty editors
 	 */
 	private Map evalNonFileBufferDocuments() {
 		Map result= new HashMap();
@@ -242,32 +345,39 @@
 		}
 	}
 
-	public boolean processFile(IFile file, Map documentsInEditors) {
-		try {
-		    if (!fCollector.acceptFile(file) || fMatcher == null) {
-		       return true;
-		    }
+	public IStatus processFile(IFile file, TextSearchJob job) {
+		// A natural cleanup after the change to use JobGroups is accepted would be to move these
+		// methods to the TextSearchJob class.
+		IProgressMonitor monitor= job.getMonitor();
+		ReusableMatchAccess matchAccess= job.getReusableMatchAccess();
+		Matcher matcher= fSearchPattern.pattern().length() == 0 ? null : fSearchPattern.matcher(new String());
+		FileCharSequenceProvider fileCharSequenceProvider= new FileCharSequenceProvider();
 
-			IDocument document= getOpenDocument(file, documentsInEditors);
+		try {
+			if (!fCollector.acceptFile(file) || matcher == null) {
+				return Status.OK_STATUS;
+			}
+
+			IDocument document= getOpenDocument(file, job.getDocumentsInEditors());
 
 			if (document != null) {
 				DocumentCharSequence documentCharSequence= new DocumentCharSequence(document);
 				// assume all documents are non-binary
-				locateMatches(file, documentCharSequence);
+				locateMatches(file, documentCharSequence, matcher, matchAccess, monitor);
 			} else {
 				CharSequence seq= null;
 				try {
-					seq= fFileCharSequenceProvider.newCharSequence(file);
+					seq= fileCharSequenceProvider.newCharSequence(file);
 					if (hasBinaryContent(seq, file) && !fCollector.reportBinaryFile(file)) {
-						return true;
+						return Status.OK_STATUS;
 					}
-					locateMatches(file, seq);
+					locateMatches(file, seq, matcher, matchAccess, monitor);
 				} catch (FileCharSequenceProvider.FileCharSequenceException e) {
 					e.throwWrappedException();
 				} finally {
 					if (seq != null) {
 						try {
-							fFileCharSequenceProvider.releaseCharSequence(seq);
+							fileCharSequenceProvider.releaseCharSequence(seq);
 						} catch (IOException e) {
 							SearchPlugin.log(e);
 						}
@@ -277,30 +387,32 @@
 		} catch (UnsupportedCharsetException e) {
 			String[] args= { getCharSetName(file), file.getFullPath().makeRelative().toString()};
 			String message= Messages.format(SearchMessages.TextSearchVisitor_unsupportedcharset, args);
-			fStatus.add(new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e));
+			return new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e);
 		} catch (IllegalCharsetNameException e) {
 			String[] args= { getCharSetName(file), file.getFullPath().makeRelative().toString()};
 			String message= Messages.format(SearchMessages.TextSearchVisitor_illegalcharset, args);
-			fStatus.add(new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e));
+			return new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e);
 		} catch (IOException e) {
 			String[] args= { getExceptionMessage(e), file.getFullPath().makeRelative().toString()};
 			String message= Messages.format(SearchMessages.TextSearchVisitor_error, args);
-			fStatus.add(new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e));
+			return new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e);
 		} catch (CoreException e) {
 			String[] args= { getExceptionMessage(e), file.getFullPath().makeRelative().toString()};
 			String message= Messages.format(SearchMessages.TextSearchVisitor_error, args);
-			fStatus.add(new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e));
+			return new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e);
 		} catch (StackOverflowError e) {
+			// Trigger cancellation of remaining jobs in the group.
+			// An alternative is to move this method into TextSearchJob and call getJobGroup().cancel() directly.
+			fProgressMonitor.setCanceled(true);
 			String message= SearchMessages.TextSearchVisitor_patterntoocomplex0;
-			fStatus.add(new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e));
-			return false;
+			return new Status(IStatus.ERROR, NewSearchUI.PLUGIN_ID, IStatus.ERROR, message, e);
 		} finally {
-			fNumberOfScannedFiles++;
+			synchronized (fLock) {
+				fCurrentFile= file;
+				fNumberOfScannedFiles++;
+			}
 		}
-		if (fProgressMonitor.isCanceled())
-			throw new OperationCanceledException(SearchMessages.TextSearchVisitor_canceled);
-
-		return true;
+		return monitor.isCanceled() ? Status.CANCEL_STATUS : Status.OK_STATUS;
 	}
 
 	private boolean hasBinaryContent(CharSequence seq, IFile file) throws CoreException {
@@ -330,29 +442,27 @@
 		return false;
 	}
 
-	private void locateMatches(IFile file, CharSequence searchInput) throws CoreException {
+	private void locateMatches(IFile file, CharSequence searchInput, Matcher matcher, ReusableMatchAccess matchAccess, IProgressMonitor monitor) throws CoreException {
 		try {
-			fMatcher.reset(searchInput);
+			matcher.reset(searchInput);
 			int k= 0;
-			while (fMatcher.find()) {
-				int start= fMatcher.start();
-				int end= fMatcher.end();
+			while (matcher.find()) {
+				int start= matcher.start();
+				int end= matcher.end();
 				if (end != start) { // don't report 0-length matches
-					fMatchAccess.initialize(file, start, end - start, searchInput);
-					boolean res= fCollector.acceptPatternMatch(fMatchAccess);
+					matchAccess.initialize(file, start, end - start, searchInput);
+					boolean res= fCollector.acceptPatternMatch(matchAccess);
 					if (!res) {
 						return; // no further reporting requested
 					}
 				}
-				if (k++ == 20) {
-					if (fProgressMonitor.isCanceled()) {
-						throw new OperationCanceledException(SearchMessages.TextSearchVisitor_canceled);
-					}
-					k= 0;
+				// Periodically check for cancellation and quit working on the current file if the job has been cancelled.
+				if (++k % 20 == 0 && monitor.isCanceled()) {
+					break;
 				}
 			}
 		} finally {
-			fMatchAccess.initialize(null, 0, 0, new String()); // clear references
+			matchAccess.initialize(null, 0, 0, new String()); // clear references
 		}
 	}
 
diff --git a/org.eclipse.search/search/org/eclipse/search/internal/ui/text/FileSearchQuery.java b/org.eclipse.search/search/org/eclipse/search/internal/ui/text/FileSearchQuery.java
index b3b4250..6204333 100644
--- a/org.eclipse.search/search/org/eclipse/search/internal/ui/text/FileSearchQuery.java
+++ b/org.eclipse.search/search/org/eclipse/search/internal/ui/text/FileSearchQuery.java
@@ -1,5 +1,5 @@
 /*******************************************************************************
- * Copyright (c) 2000, 2014 IBM Corporation and others.
+ * Copyright (c) 2000, 2015 IBM Corporation and others.
  * All rights reserved. This program and the accompanying materials
  * are made available under the terms of the Eclipse Public License v1.0
  * which accompanies this distribution, and is available at
@@ -11,10 +11,14 @@
  *     Ulrich Etter, etteru@ethz.ch - 47136 Search view should show match objects
  *     Roman Fuchs, fuchsro@ethz.ch - 47136 Search view should show match objects
  *     Christian Walther (Indel AG) - Bug 399094: Add whole word option to file search
+ *     Terry Parker <tparker@google.com> (Google Inc.) - Bug 441016 - Speed up text search by parallelizing it using JobGroups
  *******************************************************************************/
 package org.eclipse.search.internal.ui.text;
 
 import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
 import java.util.regex.Pattern;
 
 import org.eclipse.core.runtime.CoreException;
@@ -48,7 +52,8 @@
 		private final boolean fSearchInBinaries;
 
 		private final boolean fIsLightweightAutoRefresh;
-		private ArrayList fCachedMatches;
+		private Map fCachedMatches; // map of IFile -> ArrayList of FileMatches
+		private Object fLock= new Object();
 
 		private TextSearchResultCollector(AbstractTextSearchResult result, boolean isFileSearchOnly, boolean searchInBinaries) {
 			fResult= result;
@@ -63,7 +68,9 @@
 				return false;
 
 			if (fIsFileSearchOnly) {
-				fResult.addMatch(new FileMatch(file));
+				synchronized (fLock) {
+					fResult.addMatch(new FileMatch(file));
+				}
 			}
 			flushMatches();
 			return true;
@@ -77,22 +84,55 @@
 		}
 
 		public boolean acceptPatternMatch(TextSearchMatchAccess matchRequestor) throws CoreException {
+			ArrayList matches;
+			synchronized(fLock) {
+				// fCachedMatches is set to null when the caller invokes endReporting(),
+				// indicating that no further results are desired/expected, so discard
+				// any additional results.
+				if (fCachedMatches == null) {
+					return false;
+				}
+				matches= (ArrayList) fCachedMatches.get(matchRequestor.getFile());
+			}
+
 			int matchOffset= matchRequestor.getMatchOffset();
 
-			LineElement lineElement= getLineElement(matchOffset, matchRequestor);
+			/*
+			 * Another job may call flushCaches() at any time, which will clear the cached matches.
+			 * Any addition of matches to the cache needs to be protected against the flushing of
+			 * the cache by other jobs. It is OK to call getLineElement() with an unprotected local
+			 * reference to the matches for this file, because getLineElement() uses previous matches
+			 * as an optimization when creating new matches but doesn't update the cache directly
+			 * (and because each file is processed by at most one job).
+			 */
+			LineElement lineElement= getLineElement(matchOffset, matchRequestor, matches);
 			if (lineElement != null) {
 				FileMatch fileMatch= new FileMatch(matchRequestor.getFile(), matchOffset, matchRequestor.getMatchLength(), lineElement);
-				fCachedMatches.add(fileMatch);
+				synchronized(fLock) {
+					// fCachedMatches is set to null when the caller invokes endReporting(),
+					// indicating that no further results are desired/expected, so discard
+					// any additional results.
+					if (fCachedMatches == null) {
+						return false;
+					}
+					matches= (ArrayList) fCachedMatches.get(matchRequestor.getFile());
+					if (matches == null) {
+						matches= new ArrayList();
+						fCachedMatches.put(matchRequestor.getFile(), matches);
+					}
+					matches.add(fileMatch);
+				}
 			}
 			return true;
 		}
 
-		private LineElement getLineElement(int offset, TextSearchMatchAccess matchRequestor) {
+		private LineElement getLineElement(int offset, TextSearchMatchAccess matchRequestor, ArrayList matches) {
 			int lineNumber= 1;
 			int lineStart= 0;
-			if (!fCachedMatches.isEmpty()) {
+
+			if (matches != null) {
 				// match on same line as last?
-				FileMatch last= (FileMatch) fCachedMatches.get(fCachedMatches.size() - 1);
+				FileMatch last= (FileMatch) matches.get(matches.size() - 1);
 				LineElement lineElement= last.getLineElement();
 				if (lineElement.contains(offset)) {
 					return lineElement;
@@ -142,18 +182,26 @@
 		}
 
 		public void beginReporting() {
-			fCachedMatches= new ArrayList();
+			fCachedMatches= new HashMap();
 		}
 
 		public void endReporting() {
 			flushMatches();
-			fCachedMatches= null;
+			synchronized (fLock) {
+				fCachedMatches= null;
+			}
 		}
 
 		private void flushMatches() {
-			if (!fCachedMatches.isEmpty()) {
-				fResult.addMatches((Match[]) fCachedMatches.toArray(new Match[fCachedMatches.size()]));
-				fCachedMatches.clear();
+			synchronized (fLock) {
+				if (fCachedMatches != null && !fCachedMatches.isEmpty()) {
+					Iterator it = fCachedMatches.values().iterator();
+					while(it.hasNext()) {
+						ArrayList matches= (ArrayList) it.next();
+						fResult.addMatches((Match[]) matches.toArray(new Match[matches.size()]));
+					}
+					fCachedMatches.clear();
+				}
 			}
 		}
 	}