From 9742eb1645cacd2dc41f4872dec6bb20a38af179 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 9 Jan 2024 19:17:43 +0100 Subject: [PATCH] Add parser for simple range expression Implement parsing of ranges of the form "1, 3-5" into a set of all the numbers it represents. --- packages/lib/src/helpers.ts | 111 ++++++++++++++++++++++++++++++++++-- test/lib/helpers.js | 53 +++++++++++++++++ 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/packages/lib/src/helpers.ts b/packages/lib/src/helpers.ts index b322cf7c..34d5ba28 100644 --- a/packages/lib/src/helpers.ts +++ b/packages/lib/src/helpers.ts @@ -230,6 +230,14 @@ export function formatBytes(bytes: number) { return (power > 0 ? (bytes / factor ** power).toFixed(2) : bytes) + units[power]; } +function skipWhitespace(pos: number, input: string) { + // whitespace = 1*" " + while (pos < input.length && input.charAt(pos) === " ") { + pos += 1; + } + return pos; +} + function parseSearchIdentifier(pos: number, input: string): [number, string] { // character = ? all characters excluding space : " ? // identifier = 1*character @@ -393,7 +401,6 @@ export function parseSearchString( input: string, attributes: Record = {} ): ParsedSearch { - // whitespace = 1*" " // search = [ term, *( [ whitespace ], term ) ] input = input.trim(); let parsed = { @@ -407,9 +414,7 @@ export function parseSearchString( if (term) { parsed.terms.push(term); } - while (pos < input.length && input.charAt(pos) === " ") { - pos += 1; - } + pos = skipWhitespace(pos, input); } // istanbul ignore if (should not be possible) if (pos !== input.length) { @@ -417,3 +422,101 @@ export function parseSearchString( } return parsed; } + +function isDigit(pos: number, input: string) { + const code = input.charCodeAt(pos); + return "0".charCodeAt(0) <= code && code <= "9".charCodeAt(0); +} + +function parseNumber(pos: number, input: string): [number, number] { + // number = 1*('0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9') + let startPos = pos; + let endPos = pos; + while (pos < input.length && isDigit(pos, input)) { + pos += 1; + endPos += 1; + } + return [pos, Number.parseInt(input.slice(startPos, endPos), 10)]; +} + +function parseRange(pos: number, input: string, min: number, max: number): [number, Set] { + // range = number, [ whitespace ], [ '-', [ whitespace ], number ] + const range = new Set(); + if (!isDigit(pos, input)) { + throw new Error(`Expected digit but got '${input[pos]}' at pos ${pos} while parsing "${input}"`); + } + let start: number; + ([pos, start] = parseNumber(pos, input)); + pos = skipWhitespace(pos, input); + if (pos < input.length && input[pos] === "-") { + pos += 1; // Skip dash + pos = skipWhitespace(pos, input); + if (pos === input.length) { + throw new Error(`Expected digit but got end of input while parsing "${input}"`); + } + if (!isDigit(pos, input)) { + throw new Error(`Expected digit but got '${input[pos]}' at pos ${pos} while parsing "${input}"`); + } + let end: number; + ([pos, end] = parseNumber(pos, input)); + + if (start > end) { + ([start, end] = [end, start]); + } + if (start < min) { + throw new Error(`start of range ${start}-${end} is below the minimum value ${min}`); + } + if (end > max) { + throw new Error(`end of range ${start}-${end} is above the maximum value ${max}`); + } + for (let i = start; i <= end; i++) { + range.add(i); + } + } else { // Single value range + if (start < min) { + throw new Error(`value ${start} is below the minimum value ${min}`); + } + if (start > max) { + throw new Error(`value ${start} is above the maximum value ${max}`); + } + range.add(start); + } + return [pos, range]; +} + +/** + * Parse a comma separated range expression + * + * Parses the given input as a series of comma sepparated number ranges + * where each range can consist of either a whole number or a whole number + * of where the range starts, a dash and then a whole number of where the + * range ends inclusively. + * + * @param input - Range expression to parse + * @param min - Minimum accepted input value. + * @param max - Maximum accepted input value. + * @returns Set of all numbers in the parsed range. + */ +export function parseRanges( + input: string, + min: number, + max: number, +) { + // ranges = [ range, *( [ whitespace ], [ ',', [ whitespace ] ], range ) ] + input = input.trim(); + const parsed = new Set(); + let pos = 0; + while (pos < input.length) { + let range: Set; + ([pos, range] = parseRange(pos, input, min, max)); + for (const i of range) { + parsed.add(i); + } + pos = skipWhitespace(pos, input); + if (pos < input.length && input[pos] === ",") { + pos += 1; + pos = skipWhitespace(pos, input); + } + } + return parsed; +} diff --git a/test/lib/helpers.js b/test/lib/helpers.js index 25b0270c..7b898215 100644 --- a/test/lib/helpers.js +++ b/test/lib/helpers.js @@ -300,4 +300,57 @@ describe("lib/helpers", function() { ); }); }); + describe("parseRanges()", function() { + function range(input, min=1, max=10) { + return [...lib.parseRanges(input, min, max)]; + } + function expectedRange(start, end) { + const items = new Set(); + for (let i = start; i <= end; i++) { + items.add(i); + } + return [...items]; + } + it("should parse numbers separated by spaces and or commas", function() { + assert.deepEqual(range("1"), [1]); + assert.deepEqual(range(" 1"), [1]); + assert.deepEqual(range("1 "), [1]); + assert.deepEqual(range(" 1 "), [1]); + assert.deepEqual(range("1 2"), [1, 2]); + assert.deepEqual(range("1, 2"), [1, 2]); + assert.deepEqual(range("1 , 2"), [1, 2]); + assert.deepEqual(range(" 1 2 "), [1, 2]); + }); + it("should parse ranges", function() { + assert.deepEqual(range("1-4"), [1, 2, 3, 4]); + assert.deepEqual(range(" 1-4 "), [1, 2, 3, 4]); + assert.deepEqual(range(" 1 - 4 "), [1, 2, 3, 4]); + assert.deepEqual(range("1- 4"), [1, 2, 3, 4]); + assert.deepEqual(range("1 -4"), [1, 2, 3, 4]); + assert.deepEqual(range(" 1, 2-4"), [1, 2, 3, 4]); + assert.deepEqual(range("4-1"), [1, 2, 3, 4]); + }); + it("should ignore duplicates in ranges", function() { + assert.deepEqual(range("1, 2, 2, 2"), [1, 2]); + assert.deepEqual(range("1-4, 3, 4"), [1, 2, 3, 4]); + assert.deepEqual(range("3, 1-4"), [3, 1, 2, 4]); + }); + it("should throw on bad input", function() { + assert.throws(() => range("1-"), new Error('Expected digit but got end of input while parsing "1-"')); + assert.throws(() => range("a1-4"), new Error('Expected digit but got \'a\' at pos 0 while parsing "a1-4"')); + assert.throws(() => range("1a-4"), new Error('Expected digit but got \'a\' at pos 1 while parsing "1a-4"')); + assert.throws(() => range("1-a4"), new Error('Expected digit but got \'a\' at pos 2 while parsing "1-a4"')); + assert.throws(() => range("1-4a"), new Error('Expected digit but got \'a\' at pos 3 while parsing "1-4a"')); + }); + it("should throw if out of range", function() { + assert.throws(() => range("0"), new Error("value 0 is below the minimum value 1")); + assert.throws(() => range("110"), new Error("value 110 is above the maximum value 10")); + assert.throws(() => range("1-999"), new Error("end of range 1-999 is above the maximum value 10")); + assert.throws(() => range("0-1"), new Error("start of range 0-1 is below the minimum value 1")); + }); + it("should handle 16-bit ranges", function() { + assert.deepEqual(range("1-65535", 1, 2**16 - 1), expectedRange(1, 2**16 - 1)); + assert.deepEqual(range("20000-20002", 1, 2**16 - 1), [20000, 20001, 20002]); + }); + }); });