@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 +21 -0
- package/README.md +136 -0
- package/dist/mod.d.ts +1 -0
- package/dist/mod.js +1 -0
- package/dist/parser.d.ts +38 -0
- package/dist/parser.js +393 -0
- package/package.json +19 -0
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";
|
package/dist/parser.d.ts
ADDED
|
@@ -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
|
+
}
|