xref: /OpenGrok/opengrok-indexer/src/main/java/org/opengrok/indexer/web/SearchHelper.java (revision d6df19e1b22784c78f567cf74c42f18e3901b900)
1 /*
2  * CDDL HEADER START
3  *
4  * The contents of this file are subject to the terms of the
5  * Common Development and Distribution License (the "License").
6  * You may not use this file except in compliance with the License.
7  *
8  * See LICENSE.txt included in this distribution for the specific
9  * language governing permissions and limitations under the License.
10  *
11  * When distributing Covered Code, include this CDDL HEADER in each
12  * file and include the License file at LICENSE.txt.
13  * If applicable, add the following below this CDDL HEADER, with the
14  * fields enclosed by brackets "[]" replaced with your own identifying
15  * information: Portions Copyright [yyyy] [name of copyright owner]
16  *
17  * CDDL HEADER END
18  */
19 
20 /*
21  * Copyright (c) 2011, 2021, Oracle and/or its affiliates. All rights reserved.
22  * Portions Copyright (c) 2011, Jens Elkner.
23  * Portions Copyright (c) 2017, 2020, Chris Fraire <cfraire@me.com>.
24  */
25 package org.opengrok.indexer.web;
26 
27 import java.io.File;
28 import java.io.FileNotFoundException;
29 import java.io.IOException;
30 import java.nio.file.Path;
31 import java.nio.file.Paths;
32 import java.util.ArrayList;
33 import java.util.List;
34 import java.util.Map;
35 import java.util.Set;
36 import java.util.SortedSet;
37 import java.util.TreeSet;
38 import java.util.logging.Level;
39 import java.util.logging.Logger;
40 import java.util.regex.Pattern;
41 import java.util.stream.Collectors;
42 import org.apache.lucene.document.Document;
43 import org.apache.lucene.index.DirectoryReader;
44 import org.apache.lucene.index.IndexReader;
45 import org.apache.lucene.index.IndexableField;
46 import org.apache.lucene.index.LeafReaderContext;
47 import org.apache.lucene.index.ReaderUtil;
48 import org.apache.lucene.index.Term;
49 import org.apache.lucene.queryparser.classic.ParseException;
50 import org.apache.lucene.search.IndexSearcher;
51 import org.apache.lucene.search.Matches;
52 import org.apache.lucene.search.MatchesIterator;
53 import org.apache.lucene.search.MatchesUtils;
54 import org.apache.lucene.search.Query;
55 import org.apache.lucene.search.ScoreDoc;
56 import org.apache.lucene.search.ScoreMode;
57 import org.apache.lucene.search.Sort;
58 import org.apache.lucene.search.SortField;
59 import org.apache.lucene.search.TermQuery;
60 import org.apache.lucene.search.TopDocs;
61 import org.apache.lucene.search.TopFieldDocs;
62 import org.apache.lucene.search.Weight;
63 import org.apache.lucene.search.spell.DirectSpellChecker;
64 import org.apache.lucene.search.spell.SuggestMode;
65 import org.apache.lucene.search.spell.SuggestWord;
66 import org.apache.lucene.store.FSDirectory;
67 import org.opengrok.indexer.analysis.AbstractAnalyzer;
68 import org.opengrok.indexer.analysis.AnalyzerGuru;
69 import org.opengrok.indexer.analysis.CompatibleAnalyser;
70 import org.opengrok.indexer.analysis.Definitions;
71 import org.opengrok.indexer.configuration.Project;
72 import org.opengrok.indexer.configuration.RuntimeEnvironment;
73 import org.opengrok.indexer.configuration.SuperIndexSearcher;
74 import org.opengrok.indexer.index.IndexDatabase;
75 import org.opengrok.indexer.index.IndexedSymlink;
76 import org.opengrok.indexer.logger.LoggerFactory;
77 import org.opengrok.indexer.search.QueryBuilder;
78 import org.opengrok.indexer.search.SettingsHelper;
79 import org.opengrok.indexer.search.Summarizer;
80 import org.opengrok.indexer.search.context.Context;
81 import org.opengrok.indexer.search.context.HistoryContext;
82 import org.opengrok.indexer.util.ForbiddenSymlinkException;
83 import org.opengrok.indexer.util.IOUtils;
84 
85 /**
86  * Working set for a search basically to factor out/separate search related
87  * complexity from UI design.
88  *
89  * @author Jens Elkner
90  */
91 public class SearchHelper {
92 
93     private static final Logger LOGGER = LoggerFactory.getLogger(SearchHelper.class);
94 
95     private static final Pattern TAB_SPACE = Pattern.compile("[\t ]+");
96 
97     public static final String REQUEST_ATTR = "SearchHelper";
98 
99     /**
100      * Default query parse error message prefix.
101      */
102     public static final String PARSE_ERROR_MSG = "Unable to parse your query: ";
103 
104     /**
105      * Max number of words to suggest for spellcheck.
106      */
107     public static final int SPELLCHECK_SUGGEST_WORD_COUNT = 5;
108 
109     /**
110      * data root: used to find the search index file.
111      */
112     private final File dataRoot;
113     /**
114      * context path, i.e. the applications' context path (usually /source) to use
115      * when generating a redirect URL
116      */
117     private final String contextPath;
118 
119     /**
120      * piggyback: the source root directory.
121      */
122     private final File sourceRoot;
123 
124     /**
125      * piggyback: the <i>Eftar</i> file-reader to use.
126      */
127     private final EftarFileReader desc;
128     /**
129      * the result cursor start index, i.e. where to start displaying results
130      */
131     private final int start;
132     /**
133      * max. number of result items to show
134      */
135     private final int maxItems;
136     /**
137      * The QueryBuilder used to create the query.
138      */
139     private final QueryBuilder builder;
140     /**
141      * The order used for ordering query results.
142      */
143     private final SortOrder order;
144     /**
145      * Indicate whether this is search from a cross-reference. If {@code true}
146      * {@link #executeQuery()} sets {@link #redirect} if certain conditions are
147      * met.
148      */
149     private final boolean crossRefSearch;
150     /**
151      * As with {@link #crossRefSearch}, but here indicating either a
152      * cross-reference search or a "full blown search".
153      */
154     private final boolean guiSearch;
155     /**
156      * if not {@code null}, the consumer should redirect the client to a
157      * separate result page denoted by the value of this field. Automatically
158      * set via {@link #prepareExec(SortedSet)} and {@link #executeQuery()}.
159      */
160     private String redirect;
161     /**
162      * A value indicating if redirection should be short-circuited when state or
163      * query result would have indicated otherwise.
164      */
165     private final boolean noRedirect;
166     /**
167      * if not {@code null}, the UI should show this error message and stop
168      * processing the search. Automatically set via
169      * {@link #prepareExec(SortedSet)} and {@link #executeQuery()}.
170      */
171     private String errorMsg;
172     /**
173      * the reader used to open the index. Automatically set via
174      * {@link #prepareExec(SortedSet)}.
175      */
176     private IndexReader reader;
177     /**
178      * the searcher used to open/search the index. Automatically set via
179      * {@link #prepareExec(SortedSet)}.
180      */
181     private IndexSearcher searcher;
182     /**
183      * If performing multi-project search, the indexSearcher objects will be
184      * tracked by the indexSearcherMap so that they can be properly released
185      * once the results are read.
186      */
187     private final ArrayList<SuperIndexSearcher> searcherList = new ArrayList<>();
188     /**
189      * Close IndexReader associated with searches on destroy().
190      */
191     private Boolean closeOnDestroy;
192     /**
193      * List of docs which result from the executing the query.
194      */
195     private ScoreDoc[] hits;
196     /**
197      * Total number of hits.
198      */
199     private long totalHits;
200     /**
201      * the query created by {@link #builder} via
202      * {@link #prepareExec(SortedSet)}.
203      */
204     private Query query;
205     /**
206      * the Lucene sort instruction based on {@link #order} created via
207      * {@link #prepareExec(SortedSet)}.
208      */
209     protected Sort sort;
210     /**
211      * The spellchecker object.
212      */
213     private DirectSpellChecker checker;
214     /**
215      * projects to use to setup indexer searchers. Usually setup via
216      * {@link #prepareExec(SortedSet)}.
217      */
218     private SortedSet<String> projects;
219     /**
220      * opengrok summary context. Usually created via {@link #prepareSummary()}.
221      */
222     private Context sourceContext = null;
223     /**
224      * result summarizer usually created via {@link #prepareSummary()}.
225      */
226     private Summarizer summarizer = null;
227     /**
228      * history context usually created via {@link #prepareSummary()}.
229      */
230     private HistoryContext historyContext;
231 
232     private File indexDir;
233 
234     private SettingsHelper settingsHelper;
235 
SearchHelper(int start, SortOrder sortOrder, File dataRoot, File sourceRoot, int maxItems, EftarFileReader eftarFileReader, QueryBuilder queryBuilder, boolean crossRefSearch, String contextPath, boolean guiSearch, boolean noRedirect)236     public SearchHelper(int start, SortOrder sortOrder, File dataRoot, File sourceRoot, int maxItems,
237                         EftarFileReader eftarFileReader, QueryBuilder queryBuilder, boolean crossRefSearch,
238                         String contextPath, boolean guiSearch, boolean noRedirect) {
239         this.start = start;
240         this.order = sortOrder;
241         this.dataRoot = dataRoot;
242         this.sourceRoot = sourceRoot;
243         this.maxItems = maxItems;
244         this.desc = eftarFileReader;
245         this.builder = queryBuilder;
246         this.crossRefSearch = crossRefSearch;
247         this.contextPath = contextPath;
248         this.guiSearch = guiSearch;
249         this.noRedirect = noRedirect;
250     }
251 
getDataRoot()252     public File getDataRoot() {
253         return dataRoot;
254     }
255 
getSourceRoot()256     public File getSourceRoot() {
257         return sourceRoot;
258     }
259 
getDesc()260     public EftarFileReader getDesc() {
261         return desc;
262     }
263 
getBuilder()264     public QueryBuilder getBuilder() {
265         return builder;
266     }
267 
getContextPath()268     public String getContextPath() {
269         return contextPath;
270     }
271 
setRedirect(String redirect)272     public void setRedirect(String redirect) {
273         this.redirect = redirect;
274     }
275 
getRedirect()276     public String getRedirect() {
277         return redirect;
278     }
279 
getErrorMsg()280     public String getErrorMsg() {
281         return errorMsg;
282     }
283 
setErrorMsg(String errorMsg)284     public void setErrorMsg(String errorMsg) {
285         this.errorMsg = errorMsg;
286     }
287 
getSearcher()288     public IndexSearcher getSearcher() {
289         return searcher;
290     }
291 
getHits()292     public ScoreDoc[] getHits() {
293         return hits;
294     }
295 
getQuery()296     public Query getQuery() {
297         return query;
298     }
299 
getTotalHits()300     public long getTotalHits() {
301         return totalHits;
302     }
303 
getProjects()304     public SortedSet<String> getProjects() {
305         return projects;
306     }
307 
getSourceContext()308     public Context getSourceContext() {
309         return sourceContext;
310     }
311 
getMaxItems()312     public int getMaxItems() {
313         return maxItems;
314     }
315 
getOrder()316     public SortOrder getOrder() {
317         return order;
318     }
319 
getStart()320     public int getStart() {
321         return start;
322     }
323 
getSummarizer()324     public Summarizer getSummarizer() {
325         return summarizer;
326     }
327 
getHistoryContext()328     public HistoryContext getHistoryContext() {
329         return historyContext;
330     }
331 
332     /**
333      * User readable description for file types. Only those listed in
334      * fileTypeDescription will be shown to the user.
335      *
336      * Returns a set of file type descriptions to be used for a search form.
337      *
338      * @return Set of tuples with file type and description.
339      */
getFileTypeDescriptions()340     public static Set<Map.Entry<String, String>> getFileTypeDescriptions() {
341         return AnalyzerGuru.getfileTypeDescriptions().entrySet();
342     }
343 
344     /**
345      * Create the searcher to use w.r.t. currently set parameters and the given
346      * projects. Does not produce any {@link #redirect} link. It also does
347      * nothing if {@link #redirect} or {@link #errorMsg} have a
348      * none-{@code null} value.
349      * <p>
350      * Parameters which should be populated/set at this time:
351      * <ul>
352      * <li>{@link #builder}</li> <li>{@link #dataRoot}</li>
353      * <li>{@link #order} (falls back to relevance if unset)</li>
354      * </ul>
355      * Populates/sets:
356      * <ul>
357      * <li>{@link #query}</li> <li>{@link #searcher}</li> <li>{@link #sort}</li>
358      * <li>{@link #projects}</li> <li>{@link #errorMsg} if an error occurs</li>
359      * </ul>
360      *
361      * @param projects project names. If empty, a no-project setup
362      * is assumed (i.e. DATA_ROOT/index will be used instead of possible
363      * multiple DATA_ROOT/$project/index). If the set contains projects
364      * not known in the configuration or projects not yet indexed,
365      * an error will be returned in {@link #errorMsg}.
366      * @return this instance
367      */
prepareExec(SortedSet<String> projects)368     public SearchHelper prepareExec(SortedSet<String> projects) {
369         if (redirect != null || errorMsg != null) {
370             return this;
371         }
372 
373         settingsHelper = null;
374         // the Query created by the QueryBuilder
375         try {
376             indexDir = new File(dataRoot, IndexDatabase.INDEX_DIR);
377             query = builder.build();
378             if (projects == null) {
379                 errorMsg = "No project selected!";
380                 return this;
381             }
382             this.projects = projects;
383             if (projects.isEmpty()) {
384                 // no project setup
385                 FSDirectory dir = FSDirectory.open(indexDir.toPath());
386                 reader = DirectoryReader.open(dir);
387                 searcher = new IndexSearcher(reader);
388                 closeOnDestroy = true;
389             } else {
390                 // Check list of project names first to make sure all of them
391                 // are valid and indexed.
392                 closeOnDestroy = false;
393                 Set<String> invalidProjects = projects.stream().
394                     filter(proj -> (Project.getByName(proj) == null)).
395                     collect(Collectors.toSet());
396                 if (!invalidProjects.isEmpty()) {
397                     errorMsg = "Project list contains invalid projects: " +
398                         String.join(", ", invalidProjects);
399                     return this;
400                 }
401                 Set<Project> notIndexedProjects =
402                     projects.stream().
403                     map(Project::getByName).
404                     filter(proj -> !proj.isIndexed()).
405                     collect(Collectors.toSet());
406                 if (!notIndexedProjects.isEmpty()) {
407                     errorMsg = "Some of the projects to be searched are not indexed yet: " +
408                         String.join(", ", notIndexedProjects.stream().
409                         map(Project::getName).
410                         collect(Collectors.toSet()));
411                     return this;
412                 }
413 
414                 // We use MultiReader even for single project. This should
415                 // not matter given that MultiReader is just a cheap wrapper
416                 // around set of IndexReader objects.
417                 reader = RuntimeEnvironment.getInstance().getMultiReader(projects, searcherList);
418                 if (reader != null) {
419                     searcher = new IndexSearcher(reader);
420                 } else {
421                     errorMsg = "Failed to initialize search. Check the index";
422                     if (!projects.isEmpty()) {
423                         errorMsg += " for projects: " + String.join(", ", projects);
424                     }
425                     return this;
426                 }
427             }
428 
429             // TODO check if below is somehow reusing sessions so we don't
430             // requery again and again, I guess 2min timeout sessions could be
431             // useful, since you click on the next page within 2mins, if not,
432             // then wait ;)
433             // Most probably they are not reused. SearcherLifetimeManager might help here.
434             switch (order) {
435                 case LASTMODIFIED:
436                     sort = new Sort(new SortField(QueryBuilder.DATE, SortField.Type.STRING, true));
437                     break;
438                 case BY_PATH:
439                     sort = new Sort(new SortField(QueryBuilder.FULLPATH, SortField.Type.STRING));
440                     break;
441                 default:
442                     sort = Sort.RELEVANCE;
443                     break;
444             }
445             checker = new DirectSpellChecker();
446         } catch (ParseException e) {
447             errorMsg = PARSE_ERROR_MSG + e.getMessage();
448         } catch (FileNotFoundException e) {
449             errorMsg = "Index database not found. Check the index";
450             if (!projects.isEmpty()) {
451                 errorMsg += " for projects: " + String.join(", ", projects);
452             }
453             errorMsg += "; " + e.getMessage();
454         } catch (IOException e) {
455             errorMsg = e.getMessage();
456         }
457         return this;
458     }
459 
460     /**
461      * Calls {@link #prepareExec(java.util.SortedSet)} with a single-element
462      * set for {@code project}.
463      * @param project a defined instance
464      * @return this instance
465      */
prepareExec(Project project)466     public SearchHelper prepareExec(Project project) {
467         SortedSet<String> oneProject = new TreeSet<>();
468         oneProject.add(project.getName());
469         return prepareExec(oneProject);
470     }
471 
472     /**
473      * Start the search prepared by {@link #prepareExec(SortedSet)}. It does
474      * nothing if {@link #redirect} or {@link #errorMsg} have a
475      * none-{@code null} value.
476      * <p>
477      * Parameters which should be populated/set at this time: <ul> <li>all
478      * fields required for and populated by
479      * {@link #prepareExec(SortedSet)})</li> <li>{@link #start} (default:
480      * 0)</li> <li>{@link #maxItems} (default: 0)</li>
481      * <li>{@link #crossRefSearch} (default: false)</li> </ul> Populates/sets:
482      * <ul> <li>{@link #hits} (see {@link TopFieldDocs#scoreDocs})</li>
483      * <li>{@link #totalHits} (see {@link TopFieldDocs#totalHits})</li>
484      * <li>{@link #contextPath}</li> <li>{@link #errorMsg} if an error
485      * occurs</li> <li>{@link #redirect} if certain conditions are met</li>
486      * </ul>
487      *
488      * @return this instance
489      */
executeQuery()490     public SearchHelper executeQuery() {
491         if (redirect != null || errorMsg != null) {
492             return this;
493         }
494         try {
495             TopFieldDocs fdocs = searcher.search(query, start + maxItems, sort);
496             totalHits = fdocs.totalHits.value;
497             hits = fdocs.scoreDocs;
498 
499             /*
500              * Determine if possibly a single-result redirect to xref is
501              * eligible and applicable. If history query is active, then nope.
502              */
503             if (!noRedirect && hits != null && hits.length == 1 && builder.getHist() == null) {
504                 int docID = hits[0].doc;
505                 if (crossRefSearch && query instanceof TermQuery && builder.getDefs() != null) {
506                     maybeRedirectToDefinition(docID, (TermQuery) query);
507                 } else if (guiSearch) {
508                     if (builder.isPathSearch()) {
509                         redirectToFile(docID);
510                     } else {
511                         maybeRedirectToMatchOffset(docID, builder.getContextFields());
512                     }
513                 }
514             }
515         } catch (IOException | ClassNotFoundException e) {
516             errorMsg = e.getMessage();
517         }
518         return this;
519     }
520 
maybeRedirectToDefinition(int docID, TermQuery termQuery)521     private void maybeRedirectToDefinition(int docID, TermQuery termQuery)
522             throws IOException, ClassNotFoundException {
523         // Bug #3900: Check if this is a search for a single term, and that
524         // term is a definition. If that's the case, and we only have one match,
525         // we'll generate a direct link instead of a listing.
526         //
527         // Attempt to create a direct link to the definition if we search for
528         // one single definition term AND we have exactly one match AND there
529         // is only one definition of that symbol in the document that matches.
530         Document doc = searcher.doc(docID);
531         IndexableField tagsField = doc.getField(QueryBuilder.TAGS);
532         if (tagsField != null) {
533             byte[] rawTags = tagsField.binaryValue().bytes;
534             Definitions tags = Definitions.deserialize(rawTags);
535             String symbol = termQuery.getTerm().text();
536             if (tags.occurrences(symbol) == 1) {
537                 String anchor = Util.uriEncode(symbol);
538                 redirect = contextPath + Prefix.XREF_P
539                         + Util.uriEncodePath(doc.get(QueryBuilder.PATH))
540                         + '?' + QueryParameters.FRAGMENT_IDENTIFIER_PARAM_EQ + anchor
541                         + '#' + anchor;
542             }
543         }
544     }
545 
maybeRedirectToMatchOffset(int docID, List<String> contextFields)546     private void maybeRedirectToMatchOffset(int docID, List<String> contextFields)
547             throws IOException {
548         /*
549          * Only PLAIN files might redirect to a file offset, since an offset
550          * must be subsequently converted to a line number and that is tractable
551          * only from plain text.
552          */
553         Document doc = searcher.doc(docID);
554         String genre = doc.get(QueryBuilder.T);
555         if (!AbstractAnalyzer.Genre.PLAIN.typeName().equals(genre)) {
556             return;
557         }
558 
559         List<LeafReaderContext> leaves = reader.leaves();
560         int subIndex = ReaderUtil.subIndex(docID, leaves);
561         LeafReaderContext leaf = leaves.get(subIndex);
562 
563         Query rewritten = query.rewrite(reader);
564         Weight weight = rewritten.createWeight(searcher, ScoreMode.COMPLETE_NO_SCORES, 1);
565         Matches matches = weight.matches(leaf, docID - leaf.docBase); // Adjust docID
566         if (matches != null && matches != MatchesUtils.MATCH_WITH_NO_TERMS) {
567             int matchCount = 0;
568             int offset = -1;
569             for (String field : contextFields) {
570                 MatchesIterator matchesIterator = matches.getMatches(field);
571                 while (matchesIterator.next()) {
572                     if (matchesIterator.startOffset() >= 0) {
573                         // Abort if there is more than a single match offset.
574                         if (++matchCount > 1) {
575                             return;
576                         }
577                         offset = matchesIterator.startOffset();
578                     }
579                 }
580             }
581             if (offset >= 0) {
582                 redirect = contextPath + Prefix.XREF_P
583                         + Util.uriEncodePath(doc.get(QueryBuilder.PATH))
584                         + '?' + QueryParameters.MATCH_OFFSET_PARAM_EQ + offset;
585             }
586         }
587     }
588 
redirectToFile(int docID)589     private void redirectToFile(int docID) throws IOException {
590         Document doc = searcher.doc(docID);
591         redirect = contextPath + Prefix.XREF_P + Util.uriEncodePath(doc.get(QueryBuilder.PATH));
592     }
593 
getSuggestion(Term term, IndexReader ir, List<String> result)594     private void getSuggestion(Term term, IndexReader ir,
595             List<String> result) throws IOException {
596         if (term == null) {
597             return;
598         }
599         String[] toks = TAB_SPACE.split(term.text(), 0);
600         for (String tok : toks) {
601             //TODO below seems to be case insensitive ... for refs/defs this is bad
602             SuggestWord[] words = checker.suggestSimilar(new Term(term.field(), tok),
603                 SPELLCHECK_SUGGEST_WORD_COUNT, ir, SuggestMode.SUGGEST_ALWAYS);
604             for (SuggestWord w : words) {
605                 result.add(w.string);
606             }
607         }
608     }
609 
610     /**
611      * If a search did not return a hit, one may use this method to obtain
612      * suggestions for a new search.
613      *
614      * <p>
615      * Parameters which should be populated/set at this time: <ul>
616      * <li>{@link #projects}</li> <li>{@link #dataRoot}</li>
617      * <li>{@link #builder}</li> </ul>
618      *
619      * @return a possible empty list of suggestions.
620      */
getSuggestions()621     public List<Suggestion> getSuggestions() {
622         if (projects == null) {
623             return new ArrayList<>(0);
624         }
625         String[] name;
626         if (projects.isEmpty()) {
627             name = new String[]{"/"};
628         } else if (projects.size() == 1) {
629             name = new String[]{projects.first()};
630         } else {
631             name = new String[projects.size()];
632             int ii = 0;
633             for (String proj : projects) {
634                 name[ii++] = proj;
635             }
636         }
637         List<Suggestion> res = new ArrayList<>();
638         List<String> dummy = new ArrayList<>();
639         FSDirectory dir;
640         IndexReader ir = null;
641         Term t;
642         for (String proj : name) {
643             Suggestion suggestion = new Suggestion(proj);
644             try {
645                 if (!closeOnDestroy) {
646                     SuperIndexSearcher searcher = RuntimeEnvironment.getInstance().getIndexSearcher(proj);
647                     searcherList.add(searcher);
648                     ir = searcher.getIndexReader();
649                 } else {
650                     dir = FSDirectory.open(new File(indexDir, proj).toPath());
651                     ir = DirectoryReader.open(dir);
652                 }
653                 if (builder.getFreetext() != null
654                         && !builder.getFreetext().isEmpty()) {
655                     t = new Term(QueryBuilder.FULL, builder.getFreetext());
656                     getSuggestion(t, ir, dummy);
657                     suggestion.setFreetext(dummy.toArray(new String[0]));
658                     dummy.clear();
659                 }
660                 if (builder.getRefs() != null && !builder.getRefs().isEmpty()) {
661                     t = new Term(QueryBuilder.REFS, builder.getRefs());
662                     getSuggestion(t, ir, dummy);
663                     suggestion.setRefs(dummy.toArray(new String[0]));
664                     dummy.clear();
665                 }
666                 if (builder.getDefs() != null && !builder.getDefs().isEmpty()) {
667                     t = new Term(QueryBuilder.DEFS, builder.getDefs());
668                     getSuggestion(t, ir, dummy);
669                     suggestion.setDefs(dummy.toArray(new String[0]));
670                     dummy.clear();
671                 }
672                 //TODO suggest also for path and history?
673                 if (suggestion.isUsable()) {
674                     res.add(suggestion);
675                 }
676             } catch (IOException e) {
677                 LOGGER.log(Level.WARNING,
678                         String.format("Got exception while getting spelling suggestions for project %s:", proj), e);
679             } finally {
680                 if (ir != null && closeOnDestroy) {
681                     try {
682                         ir.close();
683                     } catch (IOException ex) {
684                         LOGGER.log(Level.WARNING, "Got exception while "
685                                 + "getting spelling suggestions: ", ex);
686                     }
687                 }
688             }
689         }
690         return res;
691     }
692 
693     /**
694      * Prepare the fields to support printing a full blown summary. Does nothing
695      * if {@link #redirect} or {@link #errorMsg} have a none-{@code null} value.
696      *
697      * <p>
698      * Parameters which should be populated/set at this time: <ul>
699      * <li>{@link #query}</li> <li>{@link #builder}</li> </ul> Populates/sets:
700      * Otherwise the following fields are set (includes {@code null}): <ul>
701      * <li>{@link #sourceContext}</li> <li>{@link #summarizer}</li>
702      * <li>{@link #historyContext}</li> </ul>
703      *
704      * @return this instance.
705      */
prepareSummary()706     public SearchHelper prepareSummary() {
707         if (redirect != null || errorMsg != null) {
708             return this;
709         }
710         try {
711             sourceContext = new Context(query, builder);
712             summarizer = new Summarizer(query, new CompatibleAnalyser());
713         } catch (Exception e) {
714             LOGGER.log(Level.WARNING, "Summarizer: {0}", e.getMessage());
715         }
716         try {
717             historyContext = new HistoryContext(query);
718         } catch (Exception e) {
719             LOGGER.log(Level.WARNING, "HistoryContext: {0}", e.getMessage());
720         }
721         return this;
722     }
723 
724     /**
725      * Free any resources associated with this helper (that includes closing the
726      * used {@link #searcher} in case of no-project setup).
727      */
destroy()728     public void destroy() {
729         if (searcher != null && closeOnDestroy) {
730             IOUtils.close(searcher.getIndexReader());
731         }
732 
733         for (SuperIndexSearcher is : searcherList) {
734             try {
735                 is.getSearcherManager().release(is);
736             } catch (IOException ex) {
737                 LOGGER.log(Level.WARNING, "cannot release indexSearcher", ex);
738             }
739         }
740     }
741 
742     /**
743      * Searches for a document for a single file from the index.
744      * @param file the file whose definitions to find
745      * @return {@link ScoreDoc#doc} or -1 if it could not be found
746      * @throws IOException if an error happens when accessing the index
747      * @throws ParseException if an error happens when building the Lucene query
748      */
searchSingle(File file)749     public int searchSingle(File file) throws IOException,
750             ParseException {
751 
752         RuntimeEnvironment env = RuntimeEnvironment.getInstance();
753         String path;
754         try {
755             path = env.getPathRelativeToSourceRoot(file);
756         } catch (ForbiddenSymlinkException e) {
757             LOGGER.log(Level.FINER, e.getMessage());
758             return -1;
759         }
760         //sanitize windows path delimiters
761         //in order not to conflict with Lucene escape character
762         path = path.replace("\\", "/");
763 
764         QueryBuilder singleBuilder = new QueryBuilder();
765         if (builder != null) {
766             singleBuilder.reset(builder);
767         }
768         query = singleBuilder.setPath(path).build();
769 
770         TopDocs top = searcher.search(query, 1);
771         if (top.totalHits.value == 0) {
772             return -1;
773         }
774 
775         int docID = top.scoreDocs[0].doc;
776         Document doc = searcher.doc(docID);
777 
778         String foundPath = doc.get(QueryBuilder.PATH);
779         // Only use the result if PATH matches exactly.
780         if (!path.equals(foundPath)) {
781             return -1;
782         }
783 
784         return docID;
785     }
786 
787     /**
788      * Gets the persisted tabSize via {@link SettingsHelper} for the active
789      * reader.
790      * @param proj a defined instance or {@code null} if no project is active
791      * @return tabSize
792      * @throws IOException if an I/O error occurs querying the active reader
793      */
getTabSize(Project proj)794     public int getTabSize(Project proj) throws IOException {
795         ensureSettingsHelper();
796         return settingsHelper.getTabSize(proj);
797     }
798 
799     /**
800      * Determines if there is a prime equivalent to {@code relativePath}
801      * according to indexed symlinks and translate (or not) accordingly.
802      * @param project the project name or empty string if projects are not used
803      * @param relativePath an OpenGrok-style (i.e. starting with a file
804      *                     separator) relative path
805      * @return a prime relative path or just {@code relativePath} if no prime
806      * is matched
807      */
getPrimeRelativePath(String project, String relativePath)808     public String getPrimeRelativePath(String project, String relativePath)
809             throws IOException, ForbiddenSymlinkException {
810 
811         RuntimeEnvironment env = RuntimeEnvironment.getInstance();
812         String sourceRoot = env.getSourceRootPath();
813         if (sourceRoot == null) {
814             throw new IllegalStateException("sourceRoot is not defined");
815         }
816         File absolute = new File(sourceRoot + relativePath);
817 
818         ensureSettingsHelper();
819         settingsHelper.getSettings(project);
820         Map<String, IndexedSymlink> indexedSymlinks = settingsHelper.getSymlinks(project);
821         if (indexedSymlinks != null) {
822             String canonical = absolute.getCanonicalFile().getPath();
823             for (IndexedSymlink entry : indexedSymlinks.values()) {
824                 if (canonical.equals(entry.getCanonical())) {
825                     if (absolute.getPath().equals(entry.getAbsolute())) {
826                         return relativePath;
827                     }
828                     Path newAbsolute = Paths.get(entry.getAbsolute());
829                     return env.getPathRelativeToSourceRoot(newAbsolute.toFile());
830                 } else if (canonical.startsWith(entry.getCanonicalSeparated())) {
831                     Path newAbsolute = Paths.get(entry.getAbsolute(),
832                             canonical.substring(entry.getCanonicalSeparated().length()));
833                     return env.getPathRelativeToSourceRoot(newAbsolute.toFile());
834                 }
835             }
836         }
837 
838         return relativePath;
839     }
840 
ensureSettingsHelper()841     private void ensureSettingsHelper() {
842         if (settingsHelper == null) {
843             settingsHelper = new SettingsHelper(reader);
844         }
845     }
846 }
847