diff --git a/packages/lib/helpers.js b/packages/lib/helpers.js index c3fe23fb..d2157d37 100644 --- a/packages/lib/helpers.js +++ b/packages/lib/helpers.js @@ -12,6 +12,7 @@ * * @param {*} value - value to return the type of. * @returns {string} basic type of the value passed. + * @static */ function basicType(value) { if (value === null) { return "null"; } @@ -24,6 +25,7 @@ function basicType(value) { * Asynchronously wait for the given duration * * @param {number} duration - Time to wait for in milliseconds. + * @static */ async function wait(duration) { await new Promise(resolve => { setTimeout(resolve, duration); }); @@ -36,6 +38,7 @@ async function wait(duration) { * @param {Promise} promise - Promise to wait for. * @param {number} time - Maximum time im milliseconds to wait for. * @param {*=} timeoutResult - Value to return if the operation timed out. + * @static */ async function timeout(promise, time, timeoutResult) { let timer; @@ -61,6 +64,7 @@ async function timeout(promise, time, timeoutResult) { * * @param {Readable} stream - byte stream to read to the end. * @returns {Buffer} content of the stream. + * @static */ async function readStream(stream) { let chunks = []; @@ -85,6 +89,7 @@ async function readStream(stream) { * * @param {string} text - Text to escape RegExp meta chars in. * @returns {string} escaped text. + * @static */ function escapeRegExp(text) { return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); @@ -96,6 +101,7 @@ function escapeRegExp(text) { * Shortens a large number of bytes using the kB/MB/GB/TB prefixes. * @param {number} bytes - Count of bytes to format. * @returns {string} formatted text. + * @static */ function formatBytes(bytes) { if (bytes === 0) { @@ -108,6 +114,169 @@ function formatBytes(bytes) { return (power > 0 ? (bytes / factor ** power).toFixed(2) : bytes) + units[power]; } +function parseSearchIdentifier(pos, input) { + // character = ? all characters excluding space : " ? + // identifier = 1*character + let startPos = pos; + while (pos < input.length && ![" ", '"', ":"].includes(input.charAt(pos))) { + pos += 1; + } + return [pos, input.slice(startPos, pos)]; +} + +function parseSearchWord(pos, input) { + // non quote character = ? all characters excluding " ? + // word op = "-" + // word = [ word op ], ( identifier | '"', *non quote character, '"' ) + let word = { + type: "word", + }; + if (input.charAt(pos) === "-") { + word.exclude = true; + pos += 1; + } + if (input.charAt(pos) === '"') { + pos += 1; + let startPos = pos; + let endPos = pos; + while (pos < input.length && input.charAt(pos) !== '"') { + pos += 1; + endPos += 1; + } + if (input.charAt(pos) === '"') { + pos += 1; + } + word.value = input.slice(startPos, endPos); + } else { + ([pos, word.value] = parseSearchIdentifier(pos, input)); + } + return [pos, word]; +} + +function parseSearchTerm(pos, input, attributes, issues) { + // attribute = identifier, ':', word + // term = word | attribute + let term; + if (["-", '"'].includes(input.charAt(pos))) { + ([pos, term] = parseSearchWord(pos, input)); + } else { + let identifier; + ([pos, identifier] = parseSearchIdentifier(pos, input)); + if (input.charAt(pos) === ":") { + pos += 1; + identifier = identifier.toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(attributes, identifier)) { + issues.push(`Unregonized attribute "${identifier}", use quotes to escape colons`); + } else if (attributes[identifier] === "word") { + let value; + ([pos, value] = parseSearchWord(pos, input)); + term = { type: "attribute", name: identifier, value }; + } else { + throw new Error(`Bad attribute format ${attributes[identifier]} for ${identifier}`); + } + } else { + term = { type: "word", value: identifier }; + } + } + return [pos, term]; +} + +/** + * Match a parsed word term with the given texts. + * + * Returns true if the passed word term in the search matches at least one + * of the passed text snippets. If the word mode is exclude then returns + * true if none of the passed text snippets match. + * + * @param {lib/helpers.ParsedTerm} word - Word to match. + * @param {...string} texts - Text to match word in. + * @returns {boolean} true if the word matches. + */ +function wordMatches(word, ...texts) { + if (word.type !== "word") { + throw Error("wordMatches: parameter is not a word"); + } + let matches = false; + for (let text of texts) { + if (text.includes(word.value)) { + matches = true; + break; + } + } + if (word.exclude) { + matches = !matches; + } + return matches; +} + +/** + * @typedef {object} module:lib/helpers~ParsedTerm + * @property {string} type - Type of term, either attribute or word. + * @property {string=} name - attribute only: Name of attribute. + * @property {boolean=} exclude - word only: exclude results with this word. + * @property {string | module:lib/helpers.ParsedTerm} value - + * Parsed value of this term. Is a string if type is word, and a + * ParsedTerm if type is attribute. + */ + +/** + * @typedef {object} module:lib/helpers~ParsedSearch + * @property {Array} terms - + * Parsed result of search terms. + * @property {Array} issues - + * Issues detected while parsing the seach string. + */ + +/** + * Parse a search string with optional attributes + * + * Parses the given input as a search expression consisting of space + * delimited words and attributes:word pairs. For aattributes to be + * recognized they need to be passed as name: "word" in the attributes + * parameter. + * + * Words in the search expression can optionally be prefixed by a - to + * search for results that does not contain that word. E.g. -author contains + * all results not matching author. + * + * If parsing fails an string describing the problem will be added to the + * issues array returned and parsing will resume at some arbitrary point. + * The issues should be shown to the end user so that they can correct their + * search. + * + * @param {string} input - Search expression to parse. + * @param {Object} attributes - + * Recognized attributes and their format. Currently only word is + * supported. + * @returns {module:lib/helpers~ParsedSearch} parsed terms of the search. + * @static + */ +function parseSearchString(input, attributes = {}) { + // whitespace = 1*" " + // search = [ term, *( [ whitespace ], term ) ] + input = input.trim(); + let parsed = { + terms: [], + issues: [], + }; + let pos = 0; + while (pos < input.length) { + let term; + ([pos, term] = parseSearchTerm(pos, input, attributes, parsed.issues)); + if (term) { + parsed.terms.push(term); + } + while (pos < input.length && input.charAt(pos) === " ") { + pos += 1; + } + } + // istanbul ignore if (should not be possible) + if (pos !== input.length) { + throw new Error(`parse search ended at ${pos} which is not the end of the input (${input.length})`); + } + return parsed; +} + module.exports = { basicType, @@ -116,4 +285,6 @@ module.exports = { readStream, escapeRegExp, formatBytes, + wordMatches, + parseSearchString, }; diff --git a/test/lib/helpers.js b/test/lib/helpers.js index 5b5159f7..3d4b89e3 100644 --- a/test/lib/helpers.js +++ b/test/lib/helpers.js @@ -43,4 +43,113 @@ describe("lib/helpers", function() { ), "timeout"); }); }); + + function parse(input, attributes) { + const result = libHelpers.parseSearchString(input, attributes); + if (!result.issues.length) { + return result.terms; + } + return result; + } + + describe("parseSearchString()", function() { + function word(value, opts = {}) { + return { type: "word", value, ...opts }; + } + function attr(name, value) { + return { type: "attribute", name, value: typeof value === "string" ? word(value) : value }; + } + it("should split words", function() { + assert.deepEqual(parse(""), [].map(word)); + assert.deepEqual(parse("word"), ["word"].map(word)); + assert.deepEqual(parse("some words here"), ["some", "words", "here"].map(word)); + }); + it("should trim whitespace", function() { + assert.deepEqual(parse(" "), [].map(word)); + assert.deepEqual(parse("word "), ["word"].map(word)); + assert.deepEqual(parse(" some words here"), ["some", "words", "here"].map(word)); + }); + it("should parse negated words", function() { + assert.deepEqual(parse("this -that"), [word("this"), word("that", { exclude: true })]); + assert.deepEqual(parse("not-negated"), [word("not-negated")]); + }); + it("should not split quoted words", function() { + assert.deepEqual(parse('"some quoted words" here'), ["some quoted words", "here"].map(word)); + assert.deepEqual(parse('"a quoted string"'), ["a quoted string"].map(word)); + assert.deepEqual(parse('words and "a quoted string"'), ["words", "and", "a quoted string"].map(word)); + }); + it("should split on quotation mark", function() { + assert.deepEqual(parse('"quoted"unquoted'), ["quoted", "unquoted"].map(word)); + assert.deepEqual(parse('"quoted""double"'), ["quoted", "double"].map(word)); + assert.deepEqual(parse('word"with"quote'), ["word", "with", "quote"].map(word)); + assert.deepEqual( + parse('word-"with"-negation'), + [word("word-"), word("with"), word("negation", { exclude: true })] + ); + }); + it("should allow colons in quotes", function() { + assert.deepEqual(parse('"word:value"'), ["word:value"].map(word)); + }); + it("should parse attribute:value", function() { + assert.deepEqual(parse("author:Me", { author: "word" }), [attr("author", "Me")]); + assert.deepEqual(parse("word author:Me", { author: "word" }), [word("word"), attr("author", "Me")]); + assert.deepEqual( + parse("author:Me and author:You", { author: "word" }), + [attr("author", "Me"), word("and"), attr("author", "You")] + ); + assert.deepEqual( + parse("author:Me"), + { + terms: [word("Me")], + issues: ['Unregonized attribute "author", use quotes to escape colons'], + } + ); + assert.deepEqual( + parse("constructor:value"), + { + terms: [word("value")], + issues: ['Unregonized attribute "constructor", use quotes to escape colons'], + } + ); + }); + it("should throw on bad attribute format", function() { + assert.throws( + () => parse("author:Me", { author: "bad" }), + new Error("Bad attribute format bad for author") + ); + }); + it("should ignore attribute case", function() { + assert.deepEqual(parse("Author:Me", { author: "word" }), [attr("author", "Me")]); + assert.deepEqual(parse("AUTHOR:Me", { author: "word" }), [attr("author", "Me")]); + assert.deepEqual(parse("auThOR:Me", { author: "word" }), [attr("author", "Me")]); + }); + it("should parse partial inputs", function() { + assert.deepEqual(parse("-"), [word("", { exclude: true })]); + assert.deepEqual(parse('"'), [""].map(word)); + assert.deepEqual(parse('"word'), ["word"].map(word)); + assert.deepEqual(parse("author:", { author: "word" }), [attr("author", "")]); + assert.deepEqual(parse("author:-", { author: "word" }), [attr("author", word("", { exclude: true }))]); + }); + }); + describe("wordMatches()", function() { + const match = libHelpers.wordMatches; + it("should match any inputs", function() { + assert(match(parse("word")[0], "a word in a string"), "one text"); + assert(!match(parse("word")[0], "a string without"), "one text no match"); + assert(match(parse("word")[0], "no match", "no things", "a word", "bad text", ""), "five text"); + assert(!match(parse("unword")[0], "no match", "no things", "a word", "bad text", ""), "five text no match"); + }); + it("should invert match if using exclude", function() { + assert(!match(parse("-word")[0], "a word in a string"), "one text"); + assert(match(parse("-word")[0], "a string without"), "one text no match"); + assert(!match(parse("-word")[0], "no match", "no things", "a word", "bad text", ""), "five text"); + assert(match(parse("-unword")[0], "no match", "no things", "a word", "bad text", ""), "five text no match"); + }); + it("should throw on bad input", function() { + assert.throws( + () => match(parse("attr:value", { attr: "word" })[0], "a string"), + new Error("wordMatches: parameter is not a word"), + ); + }); + }); });