@marianmeres/condition-parser 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Marian Meres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # @marianmeres/condition-parser
2
+
3
+ Human friendly search conditions notation parser. Somewhat similar to Gmail "Search email" input.
4
+
5
+ The parsed output is designed to match [condition-builder](https://github.com/marianmeres/condition-builder)
6
+ dump format, so the two play nicely together.
7
+
8
+ ## Installation
9
+
10
+ deno
11
+
12
+ ```sh
13
+ deno add jsr:@marianmeres/condition-parser
14
+ ```
15
+
16
+ nodejs
17
+
18
+ ```sh
19
+ npm i @marianmeres/condition-parser
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ import { ConditionParser } from "@marianmeres/condition-parser";
26
+ ```
27
+
28
+ ## Examples
29
+
30
+ The core parsable expression:
31
+
32
+ ```ts
33
+ // for the default "equals" (short "eq") operator
34
+ "key:value"
35
+ // or with custom operator
36
+ "key:operator:value"
37
+ ```
38
+
39
+ is parsed internally as
40
+
41
+ ```ts
42
+ { key: "key", operator: "operator", value: "value" }
43
+ ```
44
+
45
+ You can join multiple ones with `and` or `or`. The default `and` can be omitted, so:
46
+
47
+ ```ts
48
+ "foo:bar baz:bat or hey:ho 'let\'s':go"
49
+ ```
50
+
51
+ is equivalent to
52
+
53
+ ```ts
54
+ "foo:bar and baz:bat or hey:ho and 'let\'s':go"
55
+ ```
56
+
57
+ You can use parentheses to logically group the expressions.
58
+ You can use escaped quotes (or colons) inside the identifiers:
59
+
60
+ ```ts
61
+ `"my key":'my \: operator':"my \" value with quotes" and (foo:<:bar or baz:>:bat)`
62
+ ```
63
+
64
+ Also, you can append arbitrary unparsable content which will be preserved:
65
+
66
+ ```ts
67
+ const result = ConditionParser.parse(
68
+ "a:b and (c:d or e:f) this is free text",
69
+ options: Partial<ConditionParserOptions> // read below
70
+ );
71
+
72
+ // result is now:
73
+ {
74
+ parsed: [
75
+ {
76
+ expression: { key: "a", operator: "eq", value: "b" },
77
+ operator: "and"
78
+ },
79
+ {
80
+ condition: [
81
+ { expression: [{ key: "c", operator: "eq", value: "d" }], operator: "or" },
82
+ { expression: [{ key: "e", operator: "eq", value: "f" }], operator: "or" }
83
+ ],
84
+ operator: "and"
85
+ }
86
+ ],
87
+ unparsed: "this is free text"
88
+ }
89
+
90
+ // supported ConditionParser.parse options:
91
+ export interface ConditionParserOptions {
92
+ /** Operator is optional. If not present will default to this option, which is by default "eq" */
93
+ defaultOperator: string;
94
+ /** Will print debug info to console. Defaults to false */
95
+ debug: boolean;
96
+ /** If provided, will use the output of this fn as a final parsed expression output. */
97
+ transform: (context: Context) => Context;
98
+ /** Applied as the last step before adding the currently parsed expression.
99
+ * If returns falsey, will skip adding the currently parsed expression. */
100
+ preAddHook: (context: Context) => null | undefined | Context;
101
+ }
102
+
103
+ ```
104
+
105
+ ## In friends harmony with condition-builder
106
+
107
+ See [condition-builder](https://github.com/marianmeres/condition-builder) for more.
108
+
109
+ ```ts
110
+ import { ConditionParser } from "@marianmeres/condition-parser";
111
+ import { Condition } from "@marianmeres/condition-builder";
112
+
113
+ const userSearchInput = '(folder:"my projects" or folder:inbox) foo bar';
114
+
115
+ const options = {
116
+ renderKey: (ctx) => `"${ctx.key.replaceAll('"', '""')}"`,
117
+ renderValue: (ctx) => `'${ctx.value.toString().replaceAll("'", "''")}'`,
118
+ };
119
+
120
+ const { parsed, unparsed } = ConditionParser.parse(userSearchInput);
121
+
122
+ const c = new Condition(options);
123
+
124
+ c.and("user_id", "eq", 123).and(
125
+ Condition.restore(parsed, options).and("text", "match", unparsed),
126
+ );
127
+
128
+ assertEquals(
129
+ `"user_id"='123' and (("folder"='my projects' or "folder"='inbox') and "text"~*'foo bar')`,
130
+ c.toString(),
131
+ );
132
+ ```
133
+
134
+ ## Related
135
+
136
+ [@marianmeres/condition-builder](https://github.com/marianmeres/condition-builder)
package/dist/mod.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./parser.js";
package/dist/mod.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./parser.js";
@@ -0,0 +1,38 @@
1
+ import type { ConditionDump, ExpressionContext } from "@marianmeres/condition-builder";
2
+ interface Meta {
3
+ keys: string[];
4
+ operators: string[];
5
+ values: any[];
6
+ }
7
+ /** ConditionParser.parse options */
8
+ export interface ConditionParserOptions {
9
+ defaultOperator: string;
10
+ debug: boolean;
11
+ /** If provided, will use the output of this fn as a final parsed expression. */
12
+ transform: (context: ExpressionContext) => ExpressionContext;
13
+ /** Applied as a last step before adding. If returns falsey, will effectively skip
14
+ * adding. */
15
+ preAddHook: (context: ExpressionContext) => null | undefined | ExpressionContext;
16
+ }
17
+ /**
18
+ * Human friendly conditions notation parser. See README.md for examples.
19
+ *
20
+ * Designed to play nicely with @marianmeres/condition-builder.
21
+ *
22
+ * Internally uses series of layered parsers, each handling a specific part of the grammar,
23
+ * with logical expressions at the top, basic expressions at the bottom, and parenthesized
24
+ * grouping connecting them recursively.
25
+ */
26
+ export declare class ConditionParser {
27
+ #private;
28
+ static DEFAULT_OPERATOR: string;
29
+ static DEBUG: boolean;
30
+ private constructor();
31
+ /** Main api. Will parse the provided input. */
32
+ static parse(input: string, options?: Partial<ConditionParserOptions>): {
33
+ parsed: ConditionDump;
34
+ unparsed: string;
35
+ meta: Meta;
36
+ };
37
+ }
38
+ export {};
package/dist/parser.js ADDED
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Human friendly conditions notation parser. See README.md for examples.
3
+ *
4
+ * Designed to play nicely with @marianmeres/condition-builder.
5
+ *
6
+ * Internally uses series of layered parsers, each handling a specific part of the grammar,
7
+ * with logical expressions at the top, basic expressions at the bottom, and parenthesized
8
+ * grouping connecting them recursively.
9
+ */
10
+ export class ConditionParser {
11
+ static DEFAULT_OPERATOR = "eq";
12
+ static DEBUG = false;
13
+ #input;
14
+ #pos = 0;
15
+ #length;
16
+ #defaultOperator;
17
+ #debugEnabled = false;
18
+ #depth = -1;
19
+ #meta = {
20
+ keys: new Set([]),
21
+ operators: new Set([]),
22
+ values: new Set([]),
23
+ };
24
+ #transform;
25
+ #preAddHook;
26
+ constructor(input, options = {}) {
27
+ input = `${input}`.trim();
28
+ if (!input)
29
+ throw new TypeError(`Expecting non empty input`);
30
+ const { defaultOperator = ConditionParser.DEFAULT_OPERATOR, debug = false, transform = (c) => c, preAddHook, } = options ?? {};
31
+ this.#input = input;
32
+ this.#length = input.length;
33
+ this.#defaultOperator = defaultOperator;
34
+ this.#debugEnabled = !!debug;
35
+ this.#debug(`[ ${this.#input} ]`, this.#defaultOperator);
36
+ this.#transform = transform;
37
+ if (typeof preAddHook === "function") {
38
+ this.#preAddHook = preAddHook;
39
+ }
40
+ }
41
+ /** Will log debug info if `this.#debugEnabled` */
42
+ #debug(...args) {
43
+ if (ConditionParser.DEBUG || this.#debugEnabled) {
44
+ if (this.#depth > 0) {
45
+ args = ["->".repeat(this.#depth), ...args];
46
+ }
47
+ console.debug("[ConditionParser]", ...args);
48
+ }
49
+ }
50
+ /** Will look ahead (if positive) or behind (if negative) based on `offset` */
51
+ #peek(offset = 0) {
52
+ const at = this.#pos + offset;
53
+ return at < this.#length ? this.#input[at] : "";
54
+ }
55
+ /** Will move the internal cursor one character ahead */
56
+ #consume() {
57
+ return this.#pos < this.#length ? this.#input[this.#pos++] : null;
58
+ }
59
+ /** Will move the internal cursor at the end of the currently ahead whitespace block. */
60
+ #consumeWhitespace() {
61
+ while (this.#pos < this.#length && /\s/.test(this.#peek())) {
62
+ this.#consume();
63
+ }
64
+ }
65
+ /** Will look ahead to see if there is a single or double quote */
66
+ #isQuoteAhead() {
67
+ return /['"]/.test(this.#peek());
68
+ }
69
+ #isOpeningParenthesisAhead() {
70
+ return this.#peek() === "(";
71
+ }
72
+ /** Will test if is at the "end of file" (end of string) */
73
+ #isEOF() {
74
+ return this.#pos >= this.#length;
75
+ }
76
+ #parseParenthesizedValue() {
77
+ this.#debug("parseParenthesizedValue:start");
78
+ // sanity
79
+ if (this.#peek() !== "(") {
80
+ throw new Error("Not parenthesized string");
81
+ }
82
+ // Consume opening (
83
+ this.#consume();
84
+ let result = "";
85
+ const closing = ")";
86
+ while (this.#pos < this.#length) {
87
+ const char = this.#consume();
88
+ if (char === closing && this.#peek(-2) !== "\\") {
89
+ this.#debug("parseParenthesizedValue:result", result, this.#peek());
90
+ return result;
91
+ }
92
+ if (char === "\\" && this.#peek() === closing) {
93
+ result += closing;
94
+ this.#consume(); // Skip the escaped char
95
+ }
96
+ else {
97
+ result += char;
98
+ }
99
+ }
100
+ throw new Error("Unterminated parenthesized string");
101
+ }
102
+ /** Will parse the currently ahead quoted block with escape support.
103
+ * Supports both single ' and double " quotes. */
104
+ #parseQuotedString() {
105
+ this.#debug("parseQuotedString:start");
106
+ // sanity
107
+ if (!this.#isQuoteAhead()) {
108
+ throw new Error("Not quoted string");
109
+ }
110
+ let result = "";
111
+ // Consume opening quote
112
+ const quote = this.#consume();
113
+ while (this.#pos < this.#length) {
114
+ const char = this.#consume();
115
+ if (char === quote && this.#peek(-2) !== "\\") {
116
+ this.#debug("parseQuotedString:result", result);
117
+ return result;
118
+ }
119
+ if (char === "\\" && this.#peek() === quote) {
120
+ result += quote;
121
+ this.#consume(); // Skip the escaped quote
122
+ }
123
+ else {
124
+ result += char;
125
+ }
126
+ }
127
+ throw new Error("Unterminated quoted string");
128
+ }
129
+ /** Will parse the currently ahead unquoted block until delimiter ":", "(", ")", or \s) */
130
+ #parseUnquotedString() {
131
+ this.#debug("parseUnquotedString:start");
132
+ let result = "";
133
+ while (this.#pos < this.#length) {
134
+ const char = this.#peek();
135
+ if ((char === ":" && this.#peek(-1) !== "\\") ||
136
+ char === "(" ||
137
+ char === ")" ||
138
+ /\s/.test(char)) {
139
+ break;
140
+ }
141
+ if (char === "\\" && this.#peek(1) === ":") {
142
+ result += ":";
143
+ this.#consume(); // Skip the backslash
144
+ this.#consume(); // Skip the escaped colon
145
+ }
146
+ else {
147
+ result += this.#consume();
148
+ }
149
+ }
150
+ result = result.trim();
151
+ this.#debug("parseUnquotedString:result", result);
152
+ return result;
153
+ }
154
+ /** Will parse the "and" or "or" logical operator */
155
+ #parseConditionOperator(openingParenthesesLevel) {
156
+ this.#debug("parseConditionOperator:start", this.#peek());
157
+ this.#consumeWhitespace();
158
+ const remaining = this.#input.slice(this.#pos);
159
+ let result = null;
160
+ if (/^and /i.test(remaining)) {
161
+ this.#pos += 4;
162
+ result = "and";
163
+ }
164
+ else if (/^or /i.test(remaining)) {
165
+ this.#pos += 3;
166
+ result = "or";
167
+ }
168
+ else if (openingParenthesesLevel !== undefined) {
169
+ const preLevel = openingParenthesesLevel;
170
+ const postLevel = this.#countSameCharsAhead(")");
171
+ if (preLevel !== postLevel) {
172
+ throw new Error(`Parentheses level mismatch (${preLevel}, ${postLevel})`);
173
+ }
174
+ }
175
+ this.#debug("parseConditionOperator:result", result);
176
+ return result;
177
+ }
178
+ /** Will parse the key:operator:value segment */
179
+ #parseBasicExpression(out, currentOperator) {
180
+ this.#debug("parseBasicExpression:start", currentOperator);
181
+ // so we can restore "unparsed"
182
+ const _startPos = this.#pos;
183
+ let key;
184
+ if (this.#isQuoteAhead()) {
185
+ key = this.#parseQuotedString();
186
+ }
187
+ else {
188
+ key = this.#parseUnquotedString();
189
+ }
190
+ // Consume the first colon
191
+ this.#consumeWhitespace();
192
+ if (this.#consume() !== ":") {
193
+ this.#pos = _startPos;
194
+ throw new Error("Expected colon after key");
195
+ }
196
+ this.#consumeWhitespace();
197
+ // Check if we have an operator
198
+ let operator = this.#defaultOperator;
199
+ let value;
200
+ let wasParenthesized = false;
201
+ // Try to parse as if we have an operator
202
+ if (this.#isOpeningParenthesisAhead()) {
203
+ wasParenthesized = true;
204
+ value = this.#parseParenthesizedValue();
205
+ }
206
+ else if (this.#isQuoteAhead()) {
207
+ value = this.#parseQuotedString();
208
+ }
209
+ else {
210
+ value = this.#parseUnquotedString();
211
+ }
212
+ this.#consumeWhitespace();
213
+ // If we find a colon, what we parsed was actually an operator
214
+ if (this.#peek() === ":") {
215
+ if (wasParenthesized) {
216
+ this.#pos = _startPos;
217
+ throw new Error("Operator cannot be a parenthesized expression");
218
+ }
219
+ operator = value;
220
+ this.#consume(); // consume the second colon
221
+ this.#consumeWhitespace();
222
+ // Parse the actual value
223
+ if (this.#isOpeningParenthesisAhead()) {
224
+ // this.#pos = _startPos;
225
+ // throw new Error("Value cannot be a parenthesized expression");
226
+ value = this.#parseParenthesizedValue();
227
+ }
228
+ else if (this.#isQuoteAhead()) {
229
+ value = this.#parseQuotedString();
230
+ }
231
+ else {
232
+ value = this.#parseUnquotedString();
233
+ }
234
+ }
235
+ let expression = this.#transform?.({
236
+ key,
237
+ operator,
238
+ value,
239
+ }) ?? {
240
+ key,
241
+ operator,
242
+ value,
243
+ };
244
+ if (typeof this.#preAddHook === "function") {
245
+ expression = this.#preAddHook(expression);
246
+ // return early if hook returned falsey
247
+ if (!expression) {
248
+ this.#debug("parseBasicExpression:preAddHook truthy skip...");
249
+ expression = { key: "1", operator: this.#defaultOperator, value: "1" };
250
+ }
251
+ }
252
+ const result = {
253
+ expression,
254
+ operator: currentOperator,
255
+ condition: undefined,
256
+ };
257
+ this.#debug("parseBasicExpression:result", result);
258
+ this.#meta.keys.add(expression.key);
259
+ this.#meta.operators.add(expression.operator);
260
+ this.#meta.values.add(expression.value);
261
+ out.push(result);
262
+ }
263
+ /** Will recursively parse (...) */
264
+ #parseParenthesizedExpression(out, currentOperator) {
265
+ this.#debug("parseParenthesizedExpression:start", currentOperator);
266
+ // so we can restore "unparsed"
267
+ const _startPos = this.#pos;
268
+ // Consume opening parenthesis
269
+ this.#consume();
270
+ this.#consumeWhitespace();
271
+ // IMPORTANT: we're going deeper, so need to create the nested level
272
+ out.push({
273
+ condition: [],
274
+ operator: currentOperator,
275
+ expression: undefined,
276
+ });
277
+ this.#parseCondition(out.at(-1).condition, currentOperator);
278
+ this.#consumeWhitespace();
279
+ if (this.#peek() !== ")") {
280
+ this.#pos = _startPos;
281
+ throw new Error("Expected closing parenthesis");
282
+ }
283
+ // consume closing parenthesis
284
+ this.#consume();
285
+ this.#debug("parseParenthesizedExpression:result");
286
+ }
287
+ /** Will parse either basic or parenthesized term based on look ahead */
288
+ #parseTerm(out, currentOperator) {
289
+ this.#debug("parseTerm:start", currentOperator, this.#peek());
290
+ this.#consumeWhitespace();
291
+ // decision point
292
+ if (this.#peek() === "(") {
293
+ this.#parseParenthesizedExpression(out, currentOperator);
294
+ }
295
+ else {
296
+ this.#parseBasicExpression(out, currentOperator);
297
+ }
298
+ this.#debug("parseTerm:end", this.#peek());
299
+ }
300
+ /** will count how many same exact consequent `char`s are ahead (excluding whitespace) */
301
+ #countSameCharsAhead(char) {
302
+ const posBkp = this.#pos;
303
+ let count = 0;
304
+ let next = this.#consume();
305
+ while (next === char) {
306
+ count++;
307
+ this.#consumeWhitespace();
308
+ next = this.#consume();
309
+ }
310
+ this.#pos = posBkp;
311
+ return count;
312
+ }
313
+ #moveToFirstMatch(regex) {
314
+ let bkp = this.#pos;
315
+ let next = this.#consume();
316
+ let match = next && regex.test(next);
317
+ while (match) {
318
+ this.#consumeWhitespace();
319
+ bkp = this.#pos;
320
+ next = this.#consume();
321
+ match = next && regex.test(next);
322
+ }
323
+ this.#pos = bkp;
324
+ }
325
+ /** Parses sequences of terms connected by logical operators (and/or) */
326
+ #parseCondition(out, conditionOperator, openingParenthesesLevel) {
327
+ this.#depth++;
328
+ this.#consumeWhitespace();
329
+ this.#debug("parseCondition:start", conditionOperator, this.#peek());
330
+ // Parse first term
331
+ this.#parseTerm(out, conditionOperator);
332
+ // Parse subsequent terms
333
+ while (true) {
334
+ this.#consumeWhitespace();
335
+ conditionOperator = this.#parseConditionOperator(openingParenthesesLevel);
336
+ // no recognized condition
337
+ if (!conditionOperator) {
338
+ this.#consumeWhitespace();
339
+ // the default "and" is optional...
340
+ if (!this.#isEOF() && this.#peek() !== ")") {
341
+ conditionOperator = "and";
342
+ }
343
+ else {
344
+ break;
345
+ }
346
+ }
347
+ // point here is that we must expect #parseTerm below to fail (trailing
348
+ // unparsable content is legit), so we need to save current operator to
349
+ // be able to restore it
350
+ const _previousBkp = out.at(-1).operator;
351
+ // "previous" operator edit to match condition-builder convention
352
+ out.at(-1).operator = conditionOperator;
353
+ try {
354
+ this.#parseTerm(out, conditionOperator);
355
+ }
356
+ catch (e) {
357
+ this.#debug(`${e}`);
358
+ // restore
359
+ out.at(-1).operator = _previousBkp;
360
+ // and catch unparsed below
361
+ throw e;
362
+ }
363
+ }
364
+ this.#depth--;
365
+ return out;
366
+ }
367
+ /** Main api. Will parse the provided input. */
368
+ static parse(input, options = {}) {
369
+ const parser = new ConditionParser(input, options);
370
+ let parsed = [];
371
+ let unparsed = "";
372
+ const openingLevel = parser.#countSameCharsAhead("(");
373
+ try {
374
+ // Start with the highest-level logical expression
375
+ parsed = parser.#parseCondition(parsed, "and", openingLevel);
376
+ }
377
+ catch (_e) {
378
+ if (options.debug)
379
+ parser.#debug(`${_e}`);
380
+ // collect trailing unparsed input
381
+ unparsed = parser.#input.slice(parser.#pos);
382
+ }
383
+ return {
384
+ parsed,
385
+ unparsed,
386
+ meta: {
387
+ keys: [...parser.#meta.keys],
388
+ operators: [...parser.#meta.operators],
389
+ values: [...parser.#meta.values],
390
+ },
391
+ };
392
+ }
393
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@marianmeres/condition-parser",
3
+ "version": "1.3.0",
4
+ "type": "module",
5
+ "main": "dist/mod.js",
6
+ "types": "dist/mod.d.ts",
7
+ "author": "Marian Meres",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/marianmeres/condition-parser.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/marianmeres/condition-parser/issues"
15
+ },
16
+ "dependencies": {
17
+ "@marianmeres/condition-builder": "^1.8.0"
18
+ }
19
+ }