Add parseSearchString to helpers

Implement parsing of a search string into terms which can be used to
match results with them.
This commit is contained in:
Hornwitser committed 2022-09-16 02:44:06 +02:00
1 parent 3b57c1e0ae
commit dfcbc559b8
2 files changed
+280

No files matched your search

+171
View File
@@ -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<module:lib/helpers~ParsedTerm>} terms -
* Parsed result of search terms.
* @property {Array<string>} 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<string, string>} 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,
};
+109
View File
@@ -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"),
);
});
});
});