mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
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:
1 parent
3b57c1e0ae
commit
dfcbc559b8
2 files changed
+280
No files matched your search
@@ -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,
|
||||
};
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user