diff --git a/api/api.go b/api/api.go index 75f0e6f5..c893e73c 100644 --- a/api/api.go +++ b/api/api.go @@ -180,6 +180,7 @@ func Setup(m *http.ServeMux, idx map[string]*searcher.Searcher) { opt.FileRegexp = r.FormValue("files") opt.ExcludeFileRegexp = r.FormValue("excludeFiles") opt.IgnoreCase = parseAsBool(r.FormValue("i")) + opt.LiteralSearch = parseAsBool(r.FormValue("literal")) opt.LinesOfContext = parseAsUintValue( r.FormValue("ctx"), 0, diff --git a/codesearch/regexp/regexp.go b/codesearch/regexp/regexp.go index 591b3c74..11c34897 100644 --- a/codesearch/regexp/regexp.go +++ b/codesearch/regexp/regexp.go @@ -6,7 +6,10 @@ // use in grep-like programs. package regexp -import "regexp/syntax" +import ( + "regexp" + "regexp/syntax" +) func bug() { panic("codesearch/regexp: internal error") @@ -57,3 +60,9 @@ func (r *Regexp) Match(b []byte, beginText, endText bool) (end int) { func (r *Regexp) MatchString(s string, beginText, endText bool) (end int) { return r.m.matchString(s, beginText, endText) } + +// QuoteMeta returns a string that escapes all regular expression +// metacharacters inside the argument text. +func QuoteMeta(s string) string { + return regexp.QuoteMeta(s) +} diff --git a/index/index.go b/index/index.go index d956f3eb..cb442ee8 100644 --- a/index/index.go +++ b/index/index.go @@ -42,6 +42,7 @@ type IndexOptions struct { type SearchOptions struct { IgnoreCase bool + LiteralSearch bool LinesOfContext uint FileRegexp string ExcludeFileRegexp string @@ -146,7 +147,12 @@ func (n *Index) Search(pat string, opt *SearchOptions) (*SearchResponse, error) n.lck.RLock() defer n.lck.RUnlock() - re, err := regexp.Compile(GetRegexpPattern(pat, opt.IgnoreCase)) + patForRe := pat + if opt.LiteralSearch { + patForRe = regexp.QuoteMeta(pat) + } + + re, err := regexp.Compile(GetRegexpPattern(patForRe, opt.IgnoreCase)) if err != nil { return nil, err } diff --git a/jest.config.js b/jest.config.js index f31f81ff..c82d87ac 100644 --- a/jest.config.js +++ b/jest.config.js @@ -82,7 +82,9 @@ module.exports = { // moduleNameMapper: {}, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], + modulePathIgnorePatterns: [ + "db" + ], // Activates notifications for test results // notify: false, diff --git a/ui/assets/js/common.js b/ui/assets/js/common.js index 44e32e6b..ebd2f0f6 100644 --- a/ui/assets/js/common.js +++ b/ui/assets/js/common.js @@ -1,3 +1,7 @@ +export function EscapeRegExp(regexp) { + return regexp.replace(/[-[\]{}()*+!<=:?.\/\\^$|#\s,]/g, '\\$&'); +} + export function ExpandVars(template, values) { for (var name in values) { template = template.replace('{' + name + '}', values[name]); diff --git a/ui/assets/js/common.test.js b/ui/assets/js/common.test.js index f8073028..a8581eb8 100644 --- a/ui/assets/js/common.test.js +++ b/ui/assets/js/common.test.js @@ -1,4 +1,31 @@ -import { ExpandVars, UrlParts, UrlToRepo } from "./common"; +import { EscapeRegExp, ExpandVars, UrlToRepo } from "./common"; + +describe("EscapeRegExp", () => { + const testRegs = [ + ["Some test regexes", ["Some patterns that should not match"]], + ["ab+c", ["abc"]], + ["^\d+$", ["1", "123", "abc"]], + ["./...", ["a/abc"]], + ["\w+", []], + ["\r\n|\r|\n", []], + ["^[a-z]+\[[0-9]+\]$", []], + ["/[-[\]{}()*+!<=:?.\/\\^$|#\s,]", ["/[-[\]{}()*!<=:?.\/\\^$|#\s,]"]], + ["^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$", []], + ["(H..).(o..)", []], + ["^[a-zA-Z0-9 ]*$", []] + ]; + + test.each(testRegs)( + "EscapeRegExp(%s) returns the RegExp matching the input", + (regexp, shouldFail) => { + const re = new RegExp(EscapeRegExp(regexp)) + expect(re.test(regexp)).toBe(true); + shouldFail.forEach((failCase) => { + expect(re.test(failCase)).toBe(false); + }); + }, + ); +}); describe("ExpandVars", () => { test("Replaces template variables with their values", () => { diff --git a/ui/assets/js/hound.js b/ui/assets/js/hound.js index e0e99a07..a34e8317 100644 --- a/ui/assets/js/hound.js +++ b/ui/assets/js/hound.js @@ -1,4 +1,4 @@ -import {UrlParts, UrlToRepo} from './common'; +import {EscapeRegExp, UrlParts, UrlToRepo} from './common'; var Signal = function() { }; @@ -75,6 +75,7 @@ var ParamsFromUrl = function(params) { params = params || { q: '', i: 'nope', + literal: 'nope', files: '', excludeFiles: '', repos: '*' @@ -399,8 +400,12 @@ var SearchBar = React.createClass({ this.props.onSearchRequested(this.getParams()); }, getRegExp : function() { + var regexp = this.refs.q.getDOMNode().value.trim() + if (this.refs.lsearch.getDOMNode().checked) { + regexp = EscapeRegExp(regexp) + } return new RegExp( - this.refs.q.getDOMNode().value.trim(), + regexp, this.refs.icase.getDOMNode().checked ? 'ig' : 'g'); }, getParams: function() { @@ -416,22 +421,29 @@ var SearchBar = React.createClass({ files : this.refs.files.getDOMNode().value.trim(), excludeFiles : this.refs.excludeFiles.getDOMNode().value.trim(), repos : repos.join(','), - i: this.refs.icase.getDOMNode().checked ? 'fosho' : 'nope' + i: this.refs.icase.getDOMNode().checked ? 'fosho' : 'nope', + literal: this.refs.lsearch.getDOMNode().checked ? 'fosho' : 'nope' }; }, setParams: function(params) { var q = this.refs.q.getDOMNode(), i = this.refs.icase.getDOMNode(), + literal = this.refs.lsearch.getDOMNode(), files = this.refs.files.getDOMNode(), excludeFiles = this.refs.excludeFiles.getDOMNode(); q.value = params.q; i.checked = ParamValueToBool(params.i); + literal.checked = ParamValueToBool(params.literal) files.value = params.files; excludeFiles.value = params.excludeFiles; }, hasAdvancedValues: function() { - return this.refs.files.getDOMNode().value.trim() !== '' || this.refs.excludeFiles.getDOMNode().value.trim() !== '' || this.refs.icase.getDOMNode().checked || this.refs.repos.getDOMNode().value !== ''; + return this.refs.files.getDOMNode().value.trim() !== '' + || this.refs.excludeFiles.getDOMNode().value.trim() !== '' + || this.refs.icase.getDOMNode().checked + || this.refs.lsearch.getDOMNode().checked + || this.refs.repos.getDOMNode().value !== ''; }, isAdvancedEmpty: function() { return this.refs.files.getDOMNode().value.trim() === '' && this.refs.excludeFiles.getDOMNode().value.trim() === ''; @@ -542,6 +554,12 @@ var SearchBar = React.createClass({ +
+ +
+ +
+
@@ -801,6 +819,7 @@ var App = React.createClass({ this.setState({ q: params.q, i: params.i, + literal: params.literal, files: params.files, excludeFiles: params.excludeFiles, repos: repos @@ -856,6 +875,7 @@ var App = React.createClass({ var path = location.pathname + '?q=' + encodeURIComponent(params.q) + '&i=' + encodeURIComponent(params.i) + + '&literal=' + encodeURIComponent(params.literal) + '&files=' + encodeURIComponent(params.files) + '&excludeFiles=' + encodeURIComponent(params.excludeFiles) + '&repos=' + params.repos; @@ -867,6 +887,7 @@ var App = React.createClass({