@marianmeres/condition-parser 1.7.4 → 1.8.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/AGENTS.md +53 -16
- package/API.md +96 -9
- package/CLAUDE.md +51 -0
- package/README.md +15 -5
- package/dist/parser.d.ts +50 -4
- package/dist/parser.js +368 -165
- package/package.json +10 -2
package/AGENTS.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## Package Overview
|
|
4
4
|
|
|
5
5
|
- **Name**: `@marianmeres/condition-parser`
|
|
6
|
-
- **Version**: 1.
|
|
6
|
+
- **Version**: 1.8.0 (pending release; see deno.json for current)
|
|
7
7
|
- **Purpose**: Human-friendly search conditions notation parser (Gmail-style search syntax)
|
|
8
8
|
- **License**: MIT
|
|
9
9
|
- **Runtime**: Deno (primary), Node.js (via NPM distribution)
|
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
```
|
|
14
14
|
src/
|
|
15
15
|
├── mod.ts # Public entry point (re-exports parser.ts)
|
|
16
|
-
└── parser.ts # Main parser implementation
|
|
16
|
+
└── parser.ts # Main parser implementation
|
|
17
17
|
|
|
18
18
|
tests/
|
|
19
|
-
└── all.test.ts # Test suite (
|
|
19
|
+
└── all.test.ts # Test suite (51 tests)
|
|
20
20
|
|
|
21
21
|
scripts/
|
|
22
22
|
└── build-npm.ts # NPM distribution builder
|
|
23
|
+
|
|
24
|
+
mcp.ts # MCP tool definitions (parse-condition, validate-condition-syntax)
|
|
23
25
|
```
|
|
24
26
|
|
|
25
27
|
## Public API
|
|
@@ -65,12 +67,13 @@ interface ConditionParserResult {
|
|
|
65
67
|
parsed: ConditionDump; // from @marianmeres/condition-builder
|
|
66
68
|
unparsed: string;
|
|
67
69
|
meta: Meta;
|
|
70
|
+
errors: ParseError[]; // empty when input parsed cleanly
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
interface Meta {
|
|
71
74
|
keys: string[];
|
|
72
75
|
operators: string[];
|
|
73
|
-
values:
|
|
76
|
+
values: string[];
|
|
74
77
|
expressions: ExpressionData[];
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -79,6 +82,12 @@ interface ExpressionData {
|
|
|
79
82
|
operator: string;
|
|
80
83
|
value: string;
|
|
81
84
|
}
|
|
85
|
+
|
|
86
|
+
interface ParseError {
|
|
87
|
+
message: string;
|
|
88
|
+
position: number;
|
|
89
|
+
snippet: string;
|
|
90
|
+
}
|
|
82
91
|
```
|
|
83
92
|
|
|
84
93
|
## Parser Grammar
|
|
@@ -139,12 +148,22 @@ deno task release minor # Minor version release
|
|
|
139
148
|
## Key Implementation Details
|
|
140
149
|
|
|
141
150
|
1. **Recursive Descent Parser**: Layered parsing methods handle grammar levels
|
|
142
|
-
2. **Fault Tolerance**: Parse errors don't throw; unparsable content preserved in `unparsed`
|
|
143
|
-
3. **Escape Support**:
|
|
151
|
+
2. **Fault Tolerance**: Parse errors don't throw; unparsable content preserved in `unparsed` (both leading and trailing free text around a contiguous parseable middle, single-space joined); diagnostics in `errors[]`
|
|
152
|
+
3. **Escape Support**: Context-dependent backslash escapes (see table below)
|
|
144
153
|
4. **Case Insensitive**: Operators `and`, `or`, `not` are case-insensitive
|
|
145
154
|
5. **Metadata Collection**: Unique keys, operators, values tracked in `meta`
|
|
146
155
|
6. **Transform Pipeline**: Optional expression transformation before output
|
|
147
|
-
7. **Pre-add Hook**: Optional filtering/routing of expressions
|
|
156
|
+
7. **Pre-add Hook**: Optional filtering/routing of expressions; falsy return **drops** the expression
|
|
157
|
+
|
|
158
|
+
### Escape Table
|
|
159
|
+
|
|
160
|
+
| Context | Escapable characters |
|
|
161
|
+
|---------|----------------------|
|
|
162
|
+
| Quoted string (`"..."` / `'...'`) | `\\`, matching quote (`\"` or `\'`) |
|
|
163
|
+
| Parenthesized value (`(...)`) | `\\`, `\(`, `\)` (also supports balanced nested parens literally) |
|
|
164
|
+
| Unquoted token | `\\`, `\:`, `\(`, `\)`, `\ ` (space), `\⇥` (tab) |
|
|
165
|
+
|
|
166
|
+
Stray backslashes (not followed by an escapable char) are preserved literally.
|
|
148
167
|
|
|
149
168
|
## Integration Pattern
|
|
150
169
|
|
|
@@ -158,17 +177,18 @@ const condition = Condition.restore(parsed);
|
|
|
158
177
|
|
|
159
178
|
## Test Coverage
|
|
160
179
|
|
|
161
|
-
|
|
180
|
+
51 tests covering:
|
|
162
181
|
- Basic expression parsing
|
|
163
182
|
- Quoted identifiers (single/double quotes)
|
|
164
|
-
- Escaped characters
|
|
183
|
+
- Escaped characters (including `\\` self-escape)
|
|
165
184
|
- Logical operators (and, or, and not, or not)
|
|
166
|
-
- Parenthesized grouping
|
|
167
|
-
- Nested
|
|
168
|
-
-
|
|
185
|
+
- Parenthesized grouping (including double-wrapped `((...))`)
|
|
186
|
+
- Nested parens inside values
|
|
187
|
+
- `not` disambiguation (only a suffix right after and/or)
|
|
188
|
+
- Free text handling (leading, trailing, and wrapped around parseable middle)
|
|
169
189
|
- Transform function
|
|
170
|
-
- Pre-add hook
|
|
171
|
-
-
|
|
190
|
+
- Pre-add hook (drop semantics + operator transfer + empty-group collapse)
|
|
191
|
+
- `errors[]` diagnostic channel
|
|
172
192
|
- Metadata collection
|
|
173
193
|
|
|
174
194
|
## Error Handling
|
|
@@ -177,7 +197,8 @@ Parser is fault-tolerant:
|
|
|
177
197
|
- Malformed input doesn't throw exceptions
|
|
178
198
|
- Partially parsed content preserved in `parsed`
|
|
179
199
|
- Unparsable remainder preserved in `unparsed`
|
|
180
|
-
-
|
|
200
|
+
- Diagnostic records available in `errors: ParseError[]`
|
|
201
|
+
- For **strict validation** check `errors.length === 0 && !unparsed.trim()`
|
|
181
202
|
|
|
182
203
|
## Common Tasks
|
|
183
204
|
|
|
@@ -193,9 +214,25 @@ ConditionParser.parse(input, {
|
|
|
193
214
|
```
|
|
194
215
|
|
|
195
216
|
### Routing expressions
|
|
196
|
-
Use `preAddHook` to filter or route expressions
|
|
217
|
+
Use `preAddHook` to filter or route expressions. A falsy return **drops** the expression from `parsed` — its "join to next" operator is transferred to the predecessor and empty groups collapse.
|
|
197
218
|
```typescript
|
|
198
219
|
ConditionParser.parse(input, {
|
|
199
220
|
preAddHook: (ctx) => ctx.key === "special" ? null : ctx
|
|
200
221
|
});
|
|
201
222
|
```
|
|
223
|
+
|
|
224
|
+
## Breaking Changes (1.8.0)
|
|
225
|
+
|
|
226
|
+
When working on consumers of this package, be aware:
|
|
227
|
+
|
|
228
|
+
1. `preAddHook` falsy return now **drops** the expression (was: silently substituted `1=1` placeholder).
|
|
229
|
+
2. `ConditionParserResult` has a new required field `errors: ParseError[]`.
|
|
230
|
+
3. `Meta.values` typed `string[]` (was `any[]`); runtime unchanged.
|
|
231
|
+
4. Balanced double-wrapped `((foo:bar))` no longer produces a phantom swallowed error.
|
|
232
|
+
5. Stray `not` inside later terms no longer captures the cursor (preserves preceding expressions).
|
|
233
|
+
6. Quoted and parenthesized values support `\\` (literal backslash) escape.
|
|
234
|
+
7. Parenthesized values preserve balanced nested parens literally: `key:(a(b)c)` → `a(b)c`.
|
|
235
|
+
8. Unquoted tokens accept more escape sequences (`\\`, `\:`, `\(`, `\)`, `\ `, `\⇥`).
|
|
236
|
+
9. Empty `defaultOperator` falls back to `ConditionParser.DEFAULT_OPERATOR`.
|
|
237
|
+
|
|
238
|
+
See [API.md#breaking-changes](./API.md#breaking-changes) for migration notes.
|
package/API.md
CHANGED
|
@@ -12,6 +12,8 @@ Complete API documentation for `@marianmeres/condition-parser`.
|
|
|
12
12
|
- [ConditionParserResult](#conditionparserresult)
|
|
13
13
|
- [Meta](#meta)
|
|
14
14
|
- [ExpressionData](#expressiondata)
|
|
15
|
+
- [ParseError](#parseerror)
|
|
16
|
+
- [Breaking Changes](#breaking-changes)
|
|
15
17
|
|
|
16
18
|
---
|
|
17
19
|
|
|
@@ -33,6 +35,8 @@ static DEFAULT_OPERATOR: string = "eq"
|
|
|
33
35
|
|
|
34
36
|
Default operator used when none is specified in the expression (e.g., `key:value` uses `"eq"`).
|
|
35
37
|
|
|
38
|
+
> **Note**: this static is writable for convenience but affects every subsequent `parse()` call in the process. Prefer the per-call `defaultOperator` option in long-lived processes (servers, CLIs).
|
|
39
|
+
|
|
36
40
|
#### `DEBUG`
|
|
37
41
|
|
|
38
42
|
```ts
|
|
@@ -41,6 +45,8 @@ static DEBUG: boolean = false
|
|
|
41
45
|
|
|
42
46
|
Global debug flag. When `true`, all parser instances log debug information to the console.
|
|
43
47
|
|
|
48
|
+
> **Note**: same caveat as `DEFAULT_OPERATOR` — prefer the per-call `debug` option.
|
|
49
|
+
|
|
44
50
|
---
|
|
45
51
|
|
|
46
52
|
### Static Methods
|
|
@@ -147,10 +153,10 @@ interface ConditionParserOptions {
|
|
|
147
153
|
|
|
148
154
|
| Property | Type | Default | Description |
|
|
149
155
|
|----------|------|---------|-------------|
|
|
150
|
-
| `defaultOperator` | `string` | `"eq"` | Default operator when not explicitly specified |
|
|
156
|
+
| `defaultOperator` | `string` | `"eq"` | Default operator when not explicitly specified. Empty strings are ignored (fall back to the static default). |
|
|
151
157
|
| `debug` | `boolean` | `false` | Enable debug logging to console |
|
|
152
158
|
| `transform` | `function` | identity | Transform function applied to each parsed expression |
|
|
153
|
-
| `preAddHook` | `function` | - | Hook called before adding each expression; return
|
|
159
|
+
| `preAddHook` | `function` | - | Hook called before adding each expression; return `null` / `undefined` to **drop** the expression (see notes below) |
|
|
154
160
|
|
|
155
161
|
**Transform Example:**
|
|
156
162
|
|
|
@@ -174,11 +180,19 @@ const result = ConditionParser.parse("foo:bar baz:bat", {
|
|
|
174
180
|
preAddHook: (ctx) => {
|
|
175
181
|
if (ctx.key === "foo") return ctx; // include in parsed
|
|
176
182
|
otherConditions.push(ctx); // route elsewhere
|
|
177
|
-
return null; //
|
|
183
|
+
return null; // drop from parsed
|
|
178
184
|
}
|
|
179
185
|
});
|
|
180
186
|
```
|
|
181
187
|
|
|
188
|
+
**Drop semantics**:
|
|
189
|
+
|
|
190
|
+
- The expression is omitted from `parsed` and from `meta`.
|
|
191
|
+
- The "join to next sibling" operator attached to the dropped expression is **transferred to its predecessor** so the remaining chain reflects the original logical intent. For `a:1 and b:2 or c:3`, dropping `b:2` yields `a:1 or c:3` (the `or` that would have connected `b → c` is promoted).
|
|
192
|
+
- If a parenthesized group ends up empty (every inner expression dropped), the group wrapper is removed from its parent automatically.
|
|
193
|
+
|
|
194
|
+
> **Breaking change (1.8.0)**: previously, returning falsy from `preAddHook` silently substituted a `{key: "1", operator: <defaultOperator>, value: "1"}` placeholder (`1=1`) rather than dropping. If you relied on the placeholder, return it yourself from the hook.
|
|
195
|
+
|
|
182
196
|
---
|
|
183
197
|
|
|
184
198
|
### ConditionParserResult
|
|
@@ -190,14 +204,18 @@ interface ConditionParserResult {
|
|
|
190
204
|
parsed: ConditionDump;
|
|
191
205
|
unparsed: string;
|
|
192
206
|
meta: Meta;
|
|
207
|
+
errors: ParseError[];
|
|
193
208
|
}
|
|
194
209
|
```
|
|
195
210
|
|
|
196
211
|
| Property | Type | Description |
|
|
197
212
|
|----------|------|-------------|
|
|
198
213
|
| `parsed` | `ConditionDump` | Array of parsed condition expressions (compatible with `@marianmeres/condition-builder`) |
|
|
199
|
-
| `unparsed` | `string` | Any
|
|
214
|
+
| `unparsed` | `string` | Any free-text fragments that couldn't be parsed — both leading (before the first expression) and trailing (after the last parseable token), combined with a single space (useful for free-text search) |
|
|
200
215
|
| `meta` | [`Meta`](#meta) | Metadata about the parsed expressions |
|
|
216
|
+
| `errors` | [`ParseError[]`](#parseerror) | Diagnostic records for any syntactic issues encountered; empty on clean parse |
|
|
217
|
+
|
|
218
|
+
> For **strict validation** (distinguish "syntax error" from "trailing free text"), check both `errors.length === 0` **and** `unparsed === ""`.
|
|
201
219
|
|
|
202
220
|
---
|
|
203
221
|
|
|
@@ -209,7 +227,7 @@ Metadata about the parsed expressions.
|
|
|
209
227
|
interface Meta {
|
|
210
228
|
keys: string[];
|
|
211
229
|
operators: string[];
|
|
212
|
-
values:
|
|
230
|
+
values: string[];
|
|
213
231
|
expressions: ExpressionData[];
|
|
214
232
|
}
|
|
215
233
|
```
|
|
@@ -218,9 +236,11 @@ interface Meta {
|
|
|
218
236
|
|----------|------|-------------|
|
|
219
237
|
| `keys` | `string[]` | Array of unique keys found in parsed expressions |
|
|
220
238
|
| `operators` | `string[]` | Array of unique operators found in parsed expressions |
|
|
221
|
-
| `values` | `
|
|
239
|
+
| `values` | `string[]` | Array of unique values found in parsed expressions (string-equality deduplicated) |
|
|
222
240
|
| `expressions` | [`ExpressionData[]`](#expressiondata) | Array of unique expressions as objects |
|
|
223
241
|
|
|
242
|
+
> **Type narrowing (1.8.0)**: `values` is now typed `string[]` (was `any[]`). Runtime behavior is unchanged — parser output has always been strings — but strict `any[]` consumers may need to update their type annotations.
|
|
243
|
+
|
|
224
244
|
**Example:**
|
|
225
245
|
|
|
226
246
|
```ts
|
|
@@ -258,6 +278,28 @@ interface ExpressionData {
|
|
|
258
278
|
|
|
259
279
|
---
|
|
260
280
|
|
|
281
|
+
### ParseError
|
|
282
|
+
|
|
283
|
+
Diagnostic record describing where/why parsing stopped short.
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
interface ParseError {
|
|
287
|
+
message: string;
|
|
288
|
+
position: number;
|
|
289
|
+
snippet: string;
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
| Property | Type | Description |
|
|
294
|
+
|----------|------|-------------|
|
|
295
|
+
| `message` | `string` | Human-readable error message (e.g. `"Unterminated quoted string"`) |
|
|
296
|
+
| `position` | `number` | Zero-based index in the original input where the error was detected |
|
|
297
|
+
| `snippet` | `string` | Short slice of the input surrounding `position` (context window) |
|
|
298
|
+
|
|
299
|
+
A non-empty `errors` array **does not** imply `parsed` is empty — any successfully parsed prefix is preserved and the failing token(s) are rolled into `unparsed`.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
261
303
|
## Expression Syntax
|
|
262
304
|
|
|
263
305
|
### Basic Expressions
|
|
@@ -279,12 +321,22 @@ Use single or double quotes for identifiers containing spaces or special charact
|
|
|
279
321
|
|
|
280
322
|
### Escaped Characters
|
|
281
323
|
|
|
282
|
-
|
|
324
|
+
Backslash-escape special characters. Stray backslashes (not followed by an escapable char) are kept literally.
|
|
325
|
+
|
|
326
|
+
| Context | Escapable chars |
|
|
327
|
+
|---------|-----------------|
|
|
328
|
+
| Quoted strings (`"..."` / `'...'`) | `\\`, matching quote (`\"` or `\'`) |
|
|
329
|
+
| Parenthesized values (`(...)`) | `\\`, `\(`, `\)` |
|
|
330
|
+
| Unquoted tokens | `\\`, `\:`, `\(`, `\)`, `\ ` (space), `\⇥` (tab) |
|
|
331
|
+
|
|
332
|
+
Examples:
|
|
283
333
|
|
|
284
334
|
```
|
|
285
335
|
"value with \" quote" -> value with " quote
|
|
286
336
|
'value with \' quote' -> value with ' quote
|
|
287
|
-
|
|
337
|
+
"path\\to\\file" -> path\to\file
|
|
338
|
+
key\:colon:value -> { key: "key:colon", value: "value" }
|
|
339
|
+
key:(value with \) paren) -> value: "value with ) paren"
|
|
288
340
|
```
|
|
289
341
|
|
|
290
342
|
### Parenthesized Values
|
|
@@ -294,9 +346,12 @@ Wrap values in parentheses (useful for complex values):
|
|
|
294
346
|
```
|
|
295
347
|
key:(value with spaces)
|
|
296
348
|
key:eq:(complex value)
|
|
349
|
+
key:eq:(a(b)c) -> value: "a(b)c" (balanced nested parens)
|
|
297
350
|
key:eq:(value with \) paren)
|
|
298
351
|
```
|
|
299
352
|
|
|
353
|
+
> **Nested parens (1.8.0)**: parenthesized values now preserve balanced nested `()` as literal content. Previously the first unescaped `)` terminated the value.
|
|
354
|
+
|
|
300
355
|
### Logical Operators
|
|
301
356
|
|
|
302
357
|
```
|
|
@@ -318,14 +373,30 @@ a:b and (c:d or (e:f and g:h))
|
|
|
318
373
|
|
|
319
374
|
### Free Text
|
|
320
375
|
|
|
321
|
-
|
|
376
|
+
Unparsable content surrounding a contiguous parseable middle is preserved.
|
|
377
|
+
Both **leading** (before the first expression) and **trailing** (after the
|
|
378
|
+
last parseable token) fragments are collected into `unparsed`, single-space
|
|
379
|
+
joined:
|
|
322
380
|
|
|
323
381
|
```
|
|
324
382
|
category:books the search query
|
|
325
383
|
// parsed: category:books
|
|
326
384
|
// unparsed: "the search query"
|
|
385
|
+
|
|
386
|
+
the search query category:books
|
|
387
|
+
// parsed: category:books
|
|
388
|
+
// unparsed: "the search query"
|
|
389
|
+
|
|
390
|
+
the search category:books query
|
|
391
|
+
// parsed: category:books
|
|
392
|
+
// unparsed: "the search query"
|
|
327
393
|
```
|
|
328
394
|
|
|
395
|
+
> **Limitation**: only free text that wraps a *contiguous* parseable section
|
|
396
|
+
> is reassembled. Free text appearing **between** two expressions (e.g.,
|
|
397
|
+
> `a:b free c:d`) breaks the parse at the interstitial token, so `c:d`
|
|
398
|
+
> ends up inside `unparsed` along with `"free"`.
|
|
399
|
+
|
|
329
400
|
---
|
|
330
401
|
|
|
331
402
|
## Integration with condition-builder
|
|
@@ -353,3 +424,19 @@ c.and("user_id", "eq", 123).and(
|
|
|
353
424
|
console.log(c.toString());
|
|
354
425
|
// "user_id"='123' and (("folder"='my projects' or "folder"='inbox') and "text"~*'foo bar')
|
|
355
426
|
```
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## Breaking Changes
|
|
431
|
+
|
|
432
|
+
### 1.8.0
|
|
433
|
+
|
|
434
|
+
1. **`preAddHook` falsy return now drops the expression** (previously replaced with a `{key: "1", operator: defaultOperator, value: "1"}` placeholder). The dropped expression's "join to next" operator is transferred to its predecessor, and empty parenthesized groups collapse. See [PreAddHook Example](#preaddhook-example). *Migration*: if you relied on the `1=1` placeholder, return it explicitly from your hook.
|
|
435
|
+
2. **`ConditionParserResult.errors: ParseError[]`** is a new required field on the result. *Migration*: consumers using the destructured shape `{ parsed, unparsed, meta }` are unaffected; code constructing `ConditionParserResult` literals needs to add `errors: []`.
|
|
436
|
+
3. **`Meta.values` is now typed `string[]`** (was `any[]`). Runtime behavior unchanged. *Migration*: update strict type annotations if needed.
|
|
437
|
+
4. **Parser no longer throws a bogus "Parentheses level mismatch"** for balanced inputs starting with one or more `(`. Previously the throw was silently swallowed — the visible effect is that `errors` no longer holds a phantom entry for inputs like `((foo:bar))`.
|
|
438
|
+
5. **Stray `not` in trailing terms is no longer consumed.** `a:b and c:d not e:f` now preserves `c:d` (previously dropped) and routes `not e:f` to `unparsed`. The `not` keyword is still recognized when it sits immediately after an `and` / `or` join.
|
|
439
|
+
6. **Backslash escapes apply to backslash itself.** `"foo\\bar"` now yields `foo\bar`; previously the input was unterminated. Also applies to parenthesized values.
|
|
440
|
+
7. **Parenthesized values support balanced nested parens.** `key:(a(b)c)` now yields `a(b)c`; previously the first `)` terminated the value.
|
|
441
|
+
8. **Unquoted tokens can escape more characters**: `\\`, `\:`, `\(`, `\)`, `\ ` (space), `\⇥` (tab). Previously only `\:` was recognized.
|
|
442
|
+
9. **Empty `defaultOperator` option silently falls back** to `ConditionParser.DEFAULT_OPERATOR` (`"eq"` by default). Previously could produce malformed expressions.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @marianmeres/condition-parser
|
|
2
|
+
|
|
3
|
+
Human-friendly search conditions parser (Gmail-style syntax) for Deno/Node.js.
|
|
4
|
+
|
|
5
|
+
## Quick Facts
|
|
6
|
+
|
|
7
|
+
- **Entry**: `src/mod.ts` -> `src/parser.ts`
|
|
8
|
+
- **Main API**: `ConditionParser.parse(input, options?)` returns `{ parsed, unparsed, meta }`
|
|
9
|
+
- **Tests**: `deno task test` (30 tests)
|
|
10
|
+
- **Integrates with**: `@marianmeres/condition-builder`
|
|
11
|
+
|
|
12
|
+
## Syntax
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
key:value # implicit "eq" operator
|
|
16
|
+
key:operator:value # explicit operator
|
|
17
|
+
"key":"op":"value" # quoted (spaces allowed)
|
|
18
|
+
a:b and c:d # AND join
|
|
19
|
+
a:b or c:d # OR join
|
|
20
|
+
a:b and not c:d # AND NOT
|
|
21
|
+
(a:b or c:d) and e:f # grouping
|
|
22
|
+
category:books query # trailing text -> unparsed
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Options
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
{
|
|
29
|
+
defaultOperator: "eq", // operator when not specified
|
|
30
|
+
debug: false, // console logging
|
|
31
|
+
transform: (ctx) => ctx, // modify expressions
|
|
32
|
+
preAddHook: (ctx) => ctx // filter expressions (return null to skip)
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Result
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
{
|
|
40
|
+
parsed: ConditionDump, // structured conditions
|
|
41
|
+
unparsed: string, // trailing free text
|
|
42
|
+
meta: { keys, operators, values, expressions }
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Files
|
|
47
|
+
|
|
48
|
+
- `src/parser.ts` - Parser implementation (recursive descent)
|
|
49
|
+
- `tests/all.test.ts` - Test suite
|
|
50
|
+
- `API.md` - Full API documentation
|
|
51
|
+
- `AGENTS.md` - Machine-friendly docs
|
package/README.md
CHANGED
|
@@ -65,11 +65,13 @@ You can use escaped quotes (or colons) inside the identifiers:
|
|
|
65
65
|
`"my key":'my \: operator':"my \" value with quotes" and (foo:<:bar or baz:>:bat)`
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Also, you can
|
|
68
|
+
Also, you can mix in arbitrary unparsable content — around a contiguous
|
|
69
|
+
parseable middle — which will be preserved. Both leading and trailing
|
|
70
|
+
free-text fragments are collected into `unparsed` (single-space joined):
|
|
69
71
|
|
|
70
72
|
```ts
|
|
71
73
|
const result = ConditionParser.parse(
|
|
72
|
-
"a:b and (c:d or e:f)
|
|
74
|
+
"this is a:b and (c:d or e:f) free text",
|
|
73
75
|
options: Partial<ConditionParserOptions> // read below
|
|
74
76
|
);
|
|
75
77
|
|
|
@@ -88,17 +90,25 @@ const result = ConditionParser.parse(
|
|
|
88
90
|
operator: "and"
|
|
89
91
|
}
|
|
90
92
|
],
|
|
91
|
-
unparsed: "this is free text"
|
|
93
|
+
unparsed: "this is free text",
|
|
94
|
+
meta: { /* unique keys/operators/values/expressions */ },
|
|
95
|
+
errors: [] // non-empty on syntactic problems; see API.md
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
// ConditionParser.parse options (all optional):
|
|
95
99
|
// - defaultOperator: string (default "eq") - operator when not specified
|
|
96
100
|
// - debug: boolean (default false) - enable debug logging
|
|
97
101
|
// - transform: (ctx) => ctx - transform each parsed expression
|
|
98
|
-
// - preAddHook: (ctx) => ctx|null -
|
|
102
|
+
// - preAddHook: (ctx) => ctx|null|undefined - return falsy to DROP the expression
|
|
99
103
|
```
|
|
100
104
|
|
|
101
|
-
|
|
105
|
+
For strict validation (e.g. form input), check both:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const ok = errors.length === 0 && unparsed.trim() === "";
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
See [API.md](./API.md) for complete API documentation, including the [Breaking Changes](./API.md#breaking-changes) section for 1.8.0.
|
|
102
112
|
|
|
103
113
|
## In friends harmony with condition-builder
|
|
104
114
|
|
package/dist/parser.d.ts
CHANGED
|
@@ -21,10 +21,25 @@ export interface Meta {
|
|
|
21
21
|
/** Array of unique operators found in the parsed expressions */
|
|
22
22
|
operators: string[];
|
|
23
23
|
/** Array of unique values found in the parsed expressions */
|
|
24
|
-
values:
|
|
24
|
+
values: string[];
|
|
25
25
|
/** Array of unique expressions as {key, operator, value} objects */
|
|
26
26
|
expressions: ExpressionData[];
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* A diagnostic record produced when the parser cannot fully consume the input.
|
|
30
|
+
*
|
|
31
|
+
* The parser remains permissive (errors are not thrown to the caller) but they
|
|
32
|
+
* are surfaced here so consumers like validators can distinguish between
|
|
33
|
+
* "trailing free text" and "syntax error mid-expression".
|
|
34
|
+
*/
|
|
35
|
+
export interface ParseError {
|
|
36
|
+
/** Human-readable error message. */
|
|
37
|
+
message: string;
|
|
38
|
+
/** Zero-based character position in the original input where the error was detected. */
|
|
39
|
+
position: number;
|
|
40
|
+
/** A short slice of the input around `position` (with surrounding context). */
|
|
41
|
+
snippet: string;
|
|
42
|
+
}
|
|
28
43
|
/**
|
|
29
44
|
* Result returned by {@link ConditionParser.parse}.
|
|
30
45
|
*/
|
|
@@ -43,6 +58,14 @@ export interface ConditionParserResult {
|
|
|
43
58
|
* Metadata about the parsed expressions (unique keys, operators, values).
|
|
44
59
|
*/
|
|
45
60
|
meta: Meta;
|
|
61
|
+
/**
|
|
62
|
+
* Diagnostic errors collected while parsing. Empty when the input parsed cleanly.
|
|
63
|
+
*
|
|
64
|
+
* Note: a non-empty `errors` does not necessarily mean `parsed` is empty —
|
|
65
|
+
* any successfully parsed prefix is preserved. Consumers wanting strict
|
|
66
|
+
* validation should check both `errors.length === 0` and `unparsed === ""`.
|
|
67
|
+
*/
|
|
68
|
+
errors: ParseError[];
|
|
46
69
|
}
|
|
47
70
|
/**
|
|
48
71
|
* Configuration options for the ConditionParser.
|
|
@@ -76,10 +99,22 @@ export interface ConditionParserOptions {
|
|
|
76
99
|
transform: (context: ExpressionContext) => ExpressionContext;
|
|
77
100
|
/**
|
|
78
101
|
* Hook function called before adding each expression to the output.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
102
|
+
*
|
|
103
|
+
* Return the (possibly modified) `ExpressionContext` to keep the expression,
|
|
104
|
+
* or return `null` / `undefined` to **drop** it from the output.
|
|
105
|
+
*
|
|
106
|
+
* When an expression is dropped:
|
|
107
|
+
* - The expression is omitted from `parsed` and from `meta`.
|
|
108
|
+
* - The "join to next sibling" operator that was attached to the dropped
|
|
109
|
+
* expression is transferred to its predecessor (if any), so the remaining
|
|
110
|
+
* chain reflects the user's original logical intent as closely as possible.
|
|
111
|
+
* - If a parenthesized group ends up with no expressions, the group itself
|
|
112
|
+
* is removed from its parent.
|
|
113
|
+
*
|
|
114
|
+
* Useful for filtering or routing expressions to a different sink.
|
|
115
|
+
*
|
|
81
116
|
* @param context - The parsed expression context
|
|
82
|
-
* @returns The expression context to add, or null/undefined to skip
|
|
117
|
+
* @returns The expression context to add, or `null` / `undefined` to skip
|
|
83
118
|
* @example
|
|
84
119
|
* ```ts
|
|
85
120
|
* preAddHook: (ctx) => {
|
|
@@ -129,11 +164,21 @@ export declare class ConditionParser {
|
|
|
129
164
|
#private;
|
|
130
165
|
/**
|
|
131
166
|
* Default operator used when none is specified in the expression.
|
|
167
|
+
*
|
|
168
|
+
* Note: this is a writable static for convenience, but mutating it affects
|
|
169
|
+
* every subsequent `ConditionParser.parse` call in the process. Prefer the
|
|
170
|
+
* per-call `defaultOperator` option in long-lived processes.
|
|
171
|
+
*
|
|
132
172
|
* @default "eq"
|
|
133
173
|
*/
|
|
134
174
|
static DEFAULT_OPERATOR: string;
|
|
135
175
|
/**
|
|
136
176
|
* Global debug flag. When true, all parser instances will log debug information.
|
|
177
|
+
*
|
|
178
|
+
* Note: this is a writable static for convenience, but mutating it affects
|
|
179
|
+
* every subsequent `ConditionParser.parse` call in the process. Prefer the
|
|
180
|
+
* per-call `debug` option in long-lived processes.
|
|
181
|
+
*
|
|
137
182
|
* @default false
|
|
138
183
|
*/
|
|
139
184
|
static DEBUG: boolean;
|
|
@@ -172,6 +217,7 @@ export declare class ConditionParser {
|
|
|
172
217
|
* - `parsed`: Array of parsed condition expressions in ConditionDump format
|
|
173
218
|
* - `unparsed`: Any trailing text that couldn't be parsed (useful for free-text search)
|
|
174
219
|
* - `meta`: Metadata about the parsed expressions (unique keys, operators, values)
|
|
220
|
+
* - `errors`: Diagnostic records collected during parsing (empty when successful)
|
|
175
221
|
*
|
|
176
222
|
* @example
|
|
177
223
|
* Basic parsing:
|
package/dist/parser.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel pushed in place of an item that was skipped by `preAddHook`.
|
|
3
|
+
* It is removed from `out` after `parseTerm` returns; consumers never see it.
|
|
4
|
+
*/
|
|
5
|
+
const SKIP_MARKER = Symbol("ConditionParser.skip");
|
|
1
6
|
/**
|
|
2
7
|
* Human-friendly conditions notation parser for search expressions.
|
|
3
8
|
*
|
|
@@ -36,11 +41,21 @@
|
|
|
36
41
|
export class ConditionParser {
|
|
37
42
|
/**
|
|
38
43
|
* Default operator used when none is specified in the expression.
|
|
44
|
+
*
|
|
45
|
+
* Note: this is a writable static for convenience, but mutating it affects
|
|
46
|
+
* every subsequent `ConditionParser.parse` call in the process. Prefer the
|
|
47
|
+
* per-call `defaultOperator` option in long-lived processes.
|
|
48
|
+
*
|
|
39
49
|
* @default "eq"
|
|
40
50
|
*/
|
|
41
51
|
static DEFAULT_OPERATOR = "eq";
|
|
42
52
|
/**
|
|
43
53
|
* Global debug flag. When true, all parser instances will log debug information.
|
|
54
|
+
*
|
|
55
|
+
* Note: this is a writable static for convenience, but mutating it affects
|
|
56
|
+
* every subsequent `ConditionParser.parse` call in the process. Prefer the
|
|
57
|
+
* per-call `debug` option in long-lived processes.
|
|
58
|
+
*
|
|
44
59
|
* @default false
|
|
45
60
|
*/
|
|
46
61
|
static DEBUG = false;
|
|
@@ -51,21 +66,22 @@ export class ConditionParser {
|
|
|
51
66
|
#debugEnabled = false;
|
|
52
67
|
#depth = -1;
|
|
53
68
|
#meta = {
|
|
54
|
-
keys: new Set(
|
|
55
|
-
operators: new Set(
|
|
56
|
-
values: new Set(
|
|
57
|
-
expressions: new Set(
|
|
69
|
+
keys: new Set(),
|
|
70
|
+
operators: new Set(),
|
|
71
|
+
values: new Set(),
|
|
72
|
+
expressions: new Set(),
|
|
58
73
|
};
|
|
59
74
|
#transform;
|
|
60
75
|
#preAddHook;
|
|
61
76
|
constructor(input, options = {}) {
|
|
62
77
|
input = `${input}`.trim();
|
|
63
|
-
// removing this... makes no sense
|
|
64
|
-
// if (!input) throw new TypeError(`Expecting non empty input`);
|
|
65
78
|
const { defaultOperator = ConditionParser.DEFAULT_OPERATOR, debug = false, transform = (c) => c, preAddHook, } = options ?? {};
|
|
66
79
|
this.#input = input;
|
|
67
80
|
this.#length = input.length;
|
|
68
|
-
this.#defaultOperator =
|
|
81
|
+
this.#defaultOperator =
|
|
82
|
+
typeof defaultOperator === "string" && defaultOperator
|
|
83
|
+
? defaultOperator
|
|
84
|
+
: ConditionParser.DEFAULT_OPERATOR;
|
|
69
85
|
this.#debugEnabled = !!debug;
|
|
70
86
|
this.#debug(`[ ${this.#input} ]`, this.#defaultOperator);
|
|
71
87
|
this.#transform = transform;
|
|
@@ -129,7 +145,7 @@ export class ConditionParser {
|
|
|
129
145
|
/** Will look ahead (if positive) or behind (if negative) based on `offset` */
|
|
130
146
|
#peek(offset = 0) {
|
|
131
147
|
const at = this.#pos + offset;
|
|
132
|
-
return at < this.#length ? this.#input[at] : "";
|
|
148
|
+
return at >= 0 && at < this.#length ? this.#input[at] : "";
|
|
133
149
|
}
|
|
134
150
|
/** Will move the internal cursor one character ahead */
|
|
135
151
|
#consume() {
|
|
@@ -138,7 +154,7 @@ export class ConditionParser {
|
|
|
138
154
|
/** Will move the internal cursor at the end of the currently ahead whitespace block. */
|
|
139
155
|
#consumeWhitespace() {
|
|
140
156
|
while (this.#pos < this.#length && /\s/.test(this.#peek())) {
|
|
141
|
-
this.#
|
|
157
|
+
this.#pos++;
|
|
142
158
|
}
|
|
143
159
|
}
|
|
144
160
|
/** Will look ahead to see if there is a single or double quote */
|
|
@@ -152,6 +168,13 @@ export class ConditionParser {
|
|
|
152
168
|
#isEOF() {
|
|
153
169
|
return this.#pos >= this.#length;
|
|
154
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Reads a parenthesized value: `(...)`. Supports balanced nested parens
|
|
173
|
+
* and backslash escapes for `\(`, `\)`, and `\\`.
|
|
174
|
+
*
|
|
175
|
+
* The opening `(` is consumed; the matching closing `)` is consumed too.
|
|
176
|
+
* The returned string contains everything in between (with escapes resolved).
|
|
177
|
+
*/
|
|
155
178
|
#parseParenthesizedValue() {
|
|
156
179
|
this.#debug("parseParenthesizedValue:start");
|
|
157
180
|
// sanity
|
|
@@ -161,109 +184,161 @@ export class ConditionParser {
|
|
|
161
184
|
// Consume opening (
|
|
162
185
|
this.#consume();
|
|
163
186
|
let result = "";
|
|
164
|
-
|
|
187
|
+
let depth = 1;
|
|
165
188
|
while (this.#pos < this.#length) {
|
|
166
189
|
const char = this.#consume();
|
|
167
|
-
if (char ===
|
|
168
|
-
|
|
169
|
-
|
|
190
|
+
if (char === "\\") {
|
|
191
|
+
const next = this.#peek();
|
|
192
|
+
if (next === "(" || next === ")" || next === "\\") {
|
|
193
|
+
result += next;
|
|
194
|
+
this.#consume();
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// Stray backslash — keep literal.
|
|
198
|
+
result += char;
|
|
199
|
+
continue;
|
|
170
200
|
}
|
|
171
|
-
if (char === "
|
|
172
|
-
|
|
173
|
-
|
|
201
|
+
if (char === "(") {
|
|
202
|
+
depth++;
|
|
203
|
+
result += char;
|
|
204
|
+
continue;
|
|
174
205
|
}
|
|
175
|
-
|
|
206
|
+
if (char === ")") {
|
|
207
|
+
depth--;
|
|
208
|
+
if (depth === 0) {
|
|
209
|
+
this.#debug("parseParenthesizedValue:result", result, this.#peek());
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
176
212
|
result += char;
|
|
213
|
+
continue;
|
|
177
214
|
}
|
|
215
|
+
result += char;
|
|
178
216
|
}
|
|
179
217
|
throw this.#createError("Unterminated parenthesized string");
|
|
180
218
|
}
|
|
181
|
-
/**
|
|
182
|
-
*
|
|
219
|
+
/**
|
|
220
|
+
* Reads a single- or double-quoted string with backslash escapes.
|
|
221
|
+
* Supports `\\` (literal backslash), `\<quote>` (literal quote of the
|
|
222
|
+
* matching kind), plus stray backslashes are kept literal.
|
|
223
|
+
*/
|
|
183
224
|
#parseQuotedString() {
|
|
184
225
|
this.#debug("parseQuotedString:start");
|
|
185
226
|
// sanity
|
|
186
227
|
if (!this.#isQuoteAhead()) {
|
|
187
228
|
throw this.#createError("Not quoted string");
|
|
188
229
|
}
|
|
189
|
-
let result = "";
|
|
190
230
|
// Consume opening quote
|
|
191
231
|
const quote = this.#consume();
|
|
232
|
+
let result = "";
|
|
192
233
|
while (this.#pos < this.#length) {
|
|
193
234
|
const char = this.#consume();
|
|
194
|
-
if (char ===
|
|
235
|
+
if (char === "\\") {
|
|
236
|
+
const next = this.#peek();
|
|
237
|
+
if (next === quote || next === "\\") {
|
|
238
|
+
result += next;
|
|
239
|
+
this.#consume();
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
// Stray backslash — keep literal.
|
|
243
|
+
result += char;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (char === quote) {
|
|
195
247
|
this.#debug("parseQuotedString:result", result);
|
|
196
248
|
return result;
|
|
197
249
|
}
|
|
198
|
-
|
|
199
|
-
result += quote;
|
|
200
|
-
this.#consume(); // Skip the escaped quote
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
result += char;
|
|
204
|
-
}
|
|
250
|
+
result += char;
|
|
205
251
|
}
|
|
206
252
|
throw this.#createError("Unterminated quoted string");
|
|
207
253
|
}
|
|
208
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Reads an unquoted token, terminated by `:`, `(`, `)`, or whitespace.
|
|
256
|
+
* Backslash escapes for `\:`, `\\`, `\ ` (space), `\(`, `\)` are supported;
|
|
257
|
+
* stray backslashes are kept literal.
|
|
258
|
+
*/
|
|
209
259
|
#parseUnquotedString() {
|
|
210
260
|
this.#debug("parseUnquotedString:start");
|
|
211
261
|
let result = "";
|
|
212
262
|
while (this.#pos < this.#length) {
|
|
213
263
|
const char = this.#peek();
|
|
214
|
-
if (
|
|
264
|
+
if (char === "\\") {
|
|
265
|
+
const next = this.#peek(1);
|
|
266
|
+
if (next === ":" ||
|
|
267
|
+
next === "\\" ||
|
|
268
|
+
next === "(" ||
|
|
269
|
+
next === ")" ||
|
|
270
|
+
next === " " ||
|
|
271
|
+
next === "\t") {
|
|
272
|
+
result += next;
|
|
273
|
+
this.#consume(); // backslash
|
|
274
|
+
this.#consume(); // escaped char
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Stray backslash — keep literal and continue.
|
|
278
|
+
result += this.#consume();
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (char === ":" ||
|
|
215
282
|
char === "(" ||
|
|
216
283
|
char === ")" ||
|
|
217
284
|
/\s/.test(char)) {
|
|
218
285
|
break;
|
|
219
286
|
}
|
|
220
|
-
|
|
221
|
-
result += ":";
|
|
222
|
-
this.#consume(); // Skip the backslash
|
|
223
|
-
this.#consume(); // Skip the escaped colon
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
result += this.#consume();
|
|
227
|
-
}
|
|
287
|
+
result += this.#consume();
|
|
228
288
|
}
|
|
229
289
|
result = result.trim();
|
|
230
290
|
this.#debug("parseUnquotedString:result", result);
|
|
231
291
|
return result;
|
|
232
292
|
}
|
|
233
|
-
/**
|
|
234
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Tries to parse an `and` / `or` / `and not` / `or not` join operator at
|
|
295
|
+
* the current position. Returns the matched operator or `null` if none.
|
|
296
|
+
*
|
|
297
|
+
* The "not" suffix is only matched when it appears **immediately** after
|
|
298
|
+
* the join keyword (separated by whitespace), preventing earlier bugs
|
|
299
|
+
* where `not` anywhere later in the input could capture the cursor.
|
|
300
|
+
*/
|
|
301
|
+
#parseConditionOperator() {
|
|
235
302
|
this.#debug("parseConditionOperator:start", this.#peek());
|
|
236
303
|
this.#consumeWhitespace();
|
|
237
304
|
const remaining = this.#input.slice(this.#pos);
|
|
238
305
|
let result = null;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
306
|
+
// Match a "not " (or "not" at EOF would be bogus — require trailing ws).
|
|
307
|
+
const notAfter = (afterIndex) => {
|
|
308
|
+
const slice = remaining.slice(afterIndex);
|
|
309
|
+
const m = /^\s*not(\s+|$)/i.exec(slice);
|
|
310
|
+
return m ? m[0].length : 0;
|
|
311
|
+
};
|
|
312
|
+
if (/^and(\s|$)/i.test(remaining)) {
|
|
313
|
+
this.#pos += 3;
|
|
242
314
|
result = "and";
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
this.#pos += notIsAheadMatch[0].length - 1;
|
|
315
|
+
const skip = notAfter(3);
|
|
316
|
+
// only treat as "and not" if there is something AFTER the "not " too
|
|
317
|
+
if (skip && remaining.length > 3 + skip) {
|
|
318
|
+
this.#pos += skip;
|
|
248
319
|
result = "andNot";
|
|
249
320
|
}
|
|
321
|
+
else if (!skip) {
|
|
322
|
+
// require at least one whitespace after "and"
|
|
323
|
+
if (!/^and\s/i.test(remaining)) {
|
|
324
|
+
this.#pos -= 3;
|
|
325
|
+
result = null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
250
328
|
}
|
|
251
|
-
else if (/^or
|
|
252
|
-
this.#pos +=
|
|
329
|
+
else if (/^or(\s|$)/i.test(remaining)) {
|
|
330
|
+
this.#pos += 2;
|
|
253
331
|
result = "or";
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// minus 1, because the initial test includes single trailing whitespace
|
|
258
|
-
this.#pos += notIsAheadMatch[0].length - 1;
|
|
332
|
+
const skip = notAfter(2);
|
|
333
|
+
if (skip && remaining.length > 2 + skip) {
|
|
334
|
+
this.#pos += skip;
|
|
259
335
|
result = "orNot";
|
|
260
336
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
throw this.#createError(`Parentheses level mismatch (opening: ${preLevel}, closing: ${postLevel})`);
|
|
337
|
+
else if (!skip) {
|
|
338
|
+
if (!/^or\s/i.test(remaining)) {
|
|
339
|
+
this.#pos -= 2;
|
|
340
|
+
result = null;
|
|
341
|
+
}
|
|
267
342
|
}
|
|
268
343
|
}
|
|
269
344
|
this.#debug("parseConditionOperator:result", result);
|
|
@@ -272,51 +347,33 @@ export class ConditionParser {
|
|
|
272
347
|
/** Will parse the key:operator:value segment */
|
|
273
348
|
#parseBasicExpression(out, currentOperator) {
|
|
274
349
|
this.#debug("parseBasicExpression:start", currentOperator);
|
|
275
|
-
// so we can restore "unparsed"
|
|
350
|
+
// so we can restore "unparsed" — any error that escapes this method
|
|
351
|
+
// rewinds the cursor to the start of the token, so the caller surfaces
|
|
352
|
+
// the whole bad expression as `unparsed` (rather than a silently-eaten
|
|
353
|
+
// slice of it).
|
|
276
354
|
const _startPos = this.#pos;
|
|
277
355
|
let key;
|
|
278
|
-
|
|
279
|
-
key = this.#parseQuotedString();
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
key = this.#parseUnquotedString();
|
|
283
|
-
}
|
|
284
|
-
// Consume the first colon
|
|
285
|
-
this.#consumeWhitespace();
|
|
286
|
-
if (this.#consume() !== ":") {
|
|
287
|
-
this.#pos = _startPos;
|
|
288
|
-
throw this.#createError("Expected colon after key");
|
|
289
|
-
}
|
|
290
|
-
this.#consumeWhitespace();
|
|
291
|
-
// Check if we have an operator
|
|
292
|
-
let operator = this.#defaultOperator;
|
|
356
|
+
let operator;
|
|
293
357
|
let value;
|
|
294
358
|
let wasParenthesized = false;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
this.#consumeWhitespace();
|
|
307
|
-
// If we find a colon, what we parsed was actually an operator
|
|
308
|
-
if (this.#peek() === ":") {
|
|
309
|
-
if (wasParenthesized) {
|
|
310
|
-
this.#pos = _startPos;
|
|
311
|
-
throw this.#createError("Operator cannot be a parenthesized expression");
|
|
359
|
+
try {
|
|
360
|
+
if (this.#isQuoteAhead()) {
|
|
361
|
+
key = this.#parseQuotedString();
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
key = this.#parseUnquotedString();
|
|
365
|
+
}
|
|
366
|
+
// Consume the first colon
|
|
367
|
+
this.#consumeWhitespace();
|
|
368
|
+
if (this.#consume() !== ":") {
|
|
369
|
+
throw this.#createError("Expected colon after key");
|
|
312
370
|
}
|
|
313
|
-
operator = value;
|
|
314
|
-
this.#consume(); // consume the second colon
|
|
315
371
|
this.#consumeWhitespace();
|
|
316
|
-
//
|
|
372
|
+
// Check if we have an operator
|
|
373
|
+
operator = this.#defaultOperator;
|
|
374
|
+
// Try to parse as if we have an operator
|
|
317
375
|
if (this.#isOpeningParenthesisAhead()) {
|
|
318
|
-
|
|
319
|
-
// throw new Error("Value cannot be a parenthesized expression");
|
|
376
|
+
wasParenthesized = true;
|
|
320
377
|
value = this.#parseParenthesizedValue();
|
|
321
378
|
}
|
|
322
379
|
else if (this.#isQuoteAhead()) {
|
|
@@ -325,23 +382,53 @@ export class ConditionParser {
|
|
|
325
382
|
else {
|
|
326
383
|
value = this.#parseUnquotedString();
|
|
327
384
|
}
|
|
385
|
+
this.#consumeWhitespace();
|
|
386
|
+
// If we find a colon, what we parsed was actually an operator
|
|
387
|
+
if (this.#peek() === ":") {
|
|
388
|
+
if (wasParenthesized) {
|
|
389
|
+
throw this.#createError("Operator cannot be a parenthesized expression");
|
|
390
|
+
}
|
|
391
|
+
operator = value;
|
|
392
|
+
this.#consume(); // consume the second colon
|
|
393
|
+
this.#consumeWhitespace();
|
|
394
|
+
// Parse the actual value
|
|
395
|
+
if (this.#isOpeningParenthesisAhead()) {
|
|
396
|
+
value = this.#parseParenthesizedValue();
|
|
397
|
+
}
|
|
398
|
+
else if (this.#isQuoteAhead()) {
|
|
399
|
+
value = this.#parseQuotedString();
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
value = this.#parseUnquotedString();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
328
405
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
406
|
+
catch (e) {
|
|
407
|
+
this.#pos = _startPos;
|
|
408
|
+
throw e;
|
|
409
|
+
}
|
|
410
|
+
// Apply transform; we trust the user's transform to return a context.
|
|
411
|
+
const transformed = this.#transform({ key, operator, value });
|
|
412
|
+
const expression = transformed ?? { key, operator, value };
|
|
413
|
+
// preAddHook may drop the expression entirely.
|
|
414
|
+
if (this.#preAddHook) {
|
|
415
|
+
const kept = this.#preAddHook(expression);
|
|
416
|
+
if (!kept) {
|
|
417
|
+
this.#debug("parseBasicExpression:preAddHook drop");
|
|
418
|
+
// Push a skip marker; #parseCondition will remove it AFTER it has
|
|
419
|
+
// had a chance to set its operator from the next iteration's
|
|
420
|
+
// conditionOperator. This is essential to correctly transfer the
|
|
421
|
+
// "join to next" operator from the dropped item to its predecessor.
|
|
422
|
+
out.push({
|
|
423
|
+
__skip: SKIP_MARKER,
|
|
424
|
+
operator: currentOperator,
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
344
427
|
}
|
|
428
|
+
// preAddHook may have returned a modified context.
|
|
429
|
+
expression.key = kept.key;
|
|
430
|
+
expression.operator = kept.operator;
|
|
431
|
+
expression.value = kept.value;
|
|
345
432
|
}
|
|
346
433
|
const result = {
|
|
347
434
|
expression,
|
|
@@ -349,9 +436,11 @@ export class ConditionParser {
|
|
|
349
436
|
condition: undefined,
|
|
350
437
|
};
|
|
351
438
|
this.#debug("parseBasicExpression:result", result);
|
|
352
|
-
this.#meta.keys.add(expression.key);
|
|
353
|
-
this.#meta.operators.add(expression.operator);
|
|
354
|
-
this.#meta.values.add(expression.value
|
|
439
|
+
this.#meta.keys.add(String(expression.key));
|
|
440
|
+
this.#meta.operators.add(String(expression.operator));
|
|
441
|
+
this.#meta.values.add(typeof expression.value === "string"
|
|
442
|
+
? expression.value
|
|
443
|
+
: String(expression.value));
|
|
355
444
|
// need to make it unique... so just quick-n-dirty
|
|
356
445
|
this.#meta.expressions.add(JSON.stringify([expression.key, expression.operator, expression.value]));
|
|
357
446
|
out.push(result);
|
|
@@ -365,12 +454,13 @@ export class ConditionParser {
|
|
|
365
454
|
this.#consume();
|
|
366
455
|
this.#consumeWhitespace();
|
|
367
456
|
// IMPORTANT: we're going deeper, so need to create the nested level
|
|
457
|
+
const nested = [];
|
|
368
458
|
out.push({
|
|
369
|
-
condition:
|
|
459
|
+
condition: nested,
|
|
370
460
|
operator: currentOperator,
|
|
371
461
|
expression: undefined,
|
|
372
462
|
});
|
|
373
|
-
this.#parseCondition(
|
|
463
|
+
this.#parseCondition(nested, currentOperator);
|
|
374
464
|
this.#consumeWhitespace();
|
|
375
465
|
if (this.#peek() !== ")") {
|
|
376
466
|
this.#pos = _startPos;
|
|
@@ -378,6 +468,11 @@ export class ConditionParser {
|
|
|
378
468
|
}
|
|
379
469
|
// consume closing parenthesis
|
|
380
470
|
this.#consume();
|
|
471
|
+
// If the inner condition ended up empty (e.g. all members were dropped
|
|
472
|
+
// by preAddHook), drop the wrapper too.
|
|
473
|
+
if (nested.length === 0) {
|
|
474
|
+
out.pop();
|
|
475
|
+
}
|
|
381
476
|
this.#debug("parseParenthesizedExpression:result");
|
|
382
477
|
}
|
|
383
478
|
/** Will parse either basic or parenthesized term based on look ahead */
|
|
@@ -393,42 +488,29 @@ export class ConditionParser {
|
|
|
393
488
|
}
|
|
394
489
|
this.#debug("parseTerm:end", this.#peek());
|
|
395
490
|
}
|
|
396
|
-
/**
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
this.#consumeWhitespace();
|
|
404
|
-
next = this.#consume();
|
|
405
|
-
}
|
|
406
|
-
this.#pos = posBkp;
|
|
407
|
-
return count;
|
|
408
|
-
}
|
|
409
|
-
#moveToFirstMatch(regex) {
|
|
410
|
-
let bkp = this.#pos;
|
|
411
|
-
let next = this.#consume();
|
|
412
|
-
let match = next && regex.test(next);
|
|
413
|
-
while (match) {
|
|
414
|
-
this.#consumeWhitespace();
|
|
415
|
-
bkp = this.#pos;
|
|
416
|
-
next = this.#consume();
|
|
417
|
-
match = next && regex.test(next);
|
|
418
|
-
}
|
|
419
|
-
this.#pos = bkp;
|
|
491
|
+
/**
|
|
492
|
+
* Test whether the last entry in `out` is a skip marker (placeholder that
|
|
493
|
+
* will not survive into the public output).
|
|
494
|
+
*/
|
|
495
|
+
#lastIsSkip(out) {
|
|
496
|
+
const last = out.at(-1);
|
|
497
|
+
return !!last && last.__skip === SKIP_MARKER;
|
|
420
498
|
}
|
|
421
499
|
/** Parses sequences of terms connected by logical operators (and/or) */
|
|
422
|
-
#parseCondition(out, conditionOperator
|
|
500
|
+
#parseCondition(out, conditionOperator) {
|
|
423
501
|
this.#depth++;
|
|
424
502
|
this.#consumeWhitespace();
|
|
425
503
|
this.#debug("parseCondition:start", conditionOperator, this.#peek());
|
|
426
504
|
// Parse first term
|
|
427
505
|
this.#parseTerm(out, conditionOperator);
|
|
506
|
+
// If the very first term was a skip placeholder, remove it before the
|
|
507
|
+
// loop runs — it has no predecessor to inherit its operator.
|
|
508
|
+
if (this.#lastIsSkip(out))
|
|
509
|
+
out.pop();
|
|
428
510
|
// Parse subsequent terms
|
|
429
511
|
while (true) {
|
|
430
512
|
this.#consumeWhitespace();
|
|
431
|
-
conditionOperator = this.#parseConditionOperator(
|
|
513
|
+
conditionOperator = this.#parseConditionOperator();
|
|
432
514
|
// no recognized condition
|
|
433
515
|
if (!conditionOperator) {
|
|
434
516
|
this.#consumeWhitespace();
|
|
@@ -440,26 +522,109 @@ export class ConditionParser {
|
|
|
440
522
|
break;
|
|
441
523
|
}
|
|
442
524
|
}
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
525
|
+
// "previous" operator edit to match condition-builder convention.
|
|
526
|
+
// If the previous slot is empty (very first term was skipped) skip
|
|
527
|
+
// the assignment — otherwise we set/transfer the operator.
|
|
528
|
+
const prev = out.at(-1);
|
|
529
|
+
const _previousBkp = prev?.operator;
|
|
530
|
+
if (prev)
|
|
531
|
+
prev.operator = conditionOperator;
|
|
449
532
|
try {
|
|
450
533
|
this.#parseTerm(out, conditionOperator);
|
|
451
534
|
}
|
|
452
535
|
catch (e) {
|
|
453
536
|
this.#debug(`${e}`);
|
|
454
|
-
// restore
|
|
455
|
-
|
|
456
|
-
|
|
537
|
+
// restore on error so the previous chain isn't corrupted
|
|
538
|
+
if (prev)
|
|
539
|
+
prev.operator = _previousBkp;
|
|
457
540
|
throw e;
|
|
458
541
|
}
|
|
542
|
+
// If parseTerm pushed a skip marker, the "join to next" operator
|
|
543
|
+
// it would have carried is preserved on the predecessor (whose
|
|
544
|
+
// .operator we just set above). Remove the marker now so the chain
|
|
545
|
+
// stays clean for the next iteration.
|
|
546
|
+
if (this.#lastIsSkip(out))
|
|
547
|
+
out.pop();
|
|
459
548
|
}
|
|
460
549
|
this.#depth--;
|
|
461
550
|
return out;
|
|
462
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Scans the input for the first position that could begin a valid condition
|
|
554
|
+
* expression. Returns that position, or -1 if nothing expression-like exists.
|
|
555
|
+
*
|
|
556
|
+
* A candidate start is either:
|
|
557
|
+
* - an opening parenthesis `(` (start of a group), or
|
|
558
|
+
* - a word / quoted string followed by optional whitespace and `:`.
|
|
559
|
+
*
|
|
560
|
+
* Everything before the returned position is surfaced as leading free text
|
|
561
|
+
* in `unparsed` (combined with any trailing free text).
|
|
562
|
+
*/
|
|
563
|
+
#findFirstExpressionStart() {
|
|
564
|
+
const input = this.#input;
|
|
565
|
+
const len = this.#length;
|
|
566
|
+
let i = 0;
|
|
567
|
+
let wordStart = -1;
|
|
568
|
+
while (i < len) {
|
|
569
|
+
const ch = input[i];
|
|
570
|
+
// Paren group is a valid top-level expression start.
|
|
571
|
+
if (ch === "(")
|
|
572
|
+
return i;
|
|
573
|
+
// Whitespace breaks word tracking.
|
|
574
|
+
if (/\s/.test(ch)) {
|
|
575
|
+
wordStart = -1;
|
|
576
|
+
i++;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
// Quoted string: treat the whole run as one "word". If the next
|
|
580
|
+
// non-whitespace after the closing quote is `:`, it's a key.
|
|
581
|
+
if (ch === '"' || ch === "'") {
|
|
582
|
+
const ws = wordStart < 0 ? i : wordStart;
|
|
583
|
+
const quote = ch;
|
|
584
|
+
i++;
|
|
585
|
+
while (i < len && input[i] !== quote) {
|
|
586
|
+
if (input[i] === "\\" && i + 1 < len)
|
|
587
|
+
i += 2;
|
|
588
|
+
else
|
|
589
|
+
i++;
|
|
590
|
+
}
|
|
591
|
+
if (i < len)
|
|
592
|
+
i++; // closing quote
|
|
593
|
+
let j = i;
|
|
594
|
+
while (j < len && /\s/.test(input[j]))
|
|
595
|
+
j++;
|
|
596
|
+
if (input[j] === ":")
|
|
597
|
+
return ws;
|
|
598
|
+
wordStart = -1;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
// Backslash escape: both chars belong to the current word.
|
|
602
|
+
if (ch === "\\" && i + 1 < len) {
|
|
603
|
+
if (wordStart < 0)
|
|
604
|
+
wordStart = i;
|
|
605
|
+
i += 2;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
// Stray `)` can't start an expression.
|
|
609
|
+
if (ch === ")") {
|
|
610
|
+
wordStart = -1;
|
|
611
|
+
i++;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// A `:` following a word marks a key boundary.
|
|
615
|
+
if (ch === ":") {
|
|
616
|
+
if (wordStart >= 0)
|
|
617
|
+
return wordStart;
|
|
618
|
+
i++;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
// Regular word character.
|
|
622
|
+
if (wordStart < 0)
|
|
623
|
+
wordStart = i;
|
|
624
|
+
i++;
|
|
625
|
+
}
|
|
626
|
+
return -1;
|
|
627
|
+
}
|
|
463
628
|
/**
|
|
464
629
|
* Parses a human-friendly search condition string into a structured format.
|
|
465
630
|
*
|
|
@@ -469,6 +634,7 @@ export class ConditionParser {
|
|
|
469
634
|
* - `parsed`: Array of parsed condition expressions in ConditionDump format
|
|
470
635
|
* - `unparsed`: Any trailing text that couldn't be parsed (useful for free-text search)
|
|
471
636
|
* - `meta`: Metadata about the parsed expressions (unique keys, operators, values)
|
|
637
|
+
* - `errors`: Diagnostic records collected during parsing (empty when successful)
|
|
472
638
|
*
|
|
473
639
|
* @example
|
|
474
640
|
* Basic parsing:
|
|
@@ -497,19 +663,55 @@ export class ConditionParser {
|
|
|
497
663
|
*/
|
|
498
664
|
static parse(input, options = {}) {
|
|
499
665
|
const parser = new ConditionParser(input, options);
|
|
500
|
-
|
|
666
|
+
const internal = [];
|
|
501
667
|
let unparsed = "";
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
668
|
+
const errors = [];
|
|
669
|
+
// Empty input is a no-op (avoid throwing "Expected colon after key" at pos 0).
|
|
670
|
+
if (parser.#length > 0) {
|
|
671
|
+
// Locate the first position that could start an expression; anything
|
|
672
|
+
// before it is "leading free text" which we surface as `unparsed`
|
|
673
|
+
// combined (space-joined) with any trailing free text.
|
|
674
|
+
const startPos = parser.#findFirstExpressionStart();
|
|
675
|
+
if (startPos < 0) {
|
|
676
|
+
// No expression-like start anywhere — whole input is free text.
|
|
677
|
+
unparsed = parser.#input;
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
const leading = parser.#input.slice(0, startPos).trim();
|
|
681
|
+
parser.#pos = startPos;
|
|
682
|
+
try {
|
|
683
|
+
parser.#parseCondition(internal, "and");
|
|
684
|
+
}
|
|
685
|
+
catch (e) {
|
|
686
|
+
parser.#debug(`${e}`);
|
|
687
|
+
// collect trailing unparsed input
|
|
688
|
+
unparsed = parser.#input.slice(parser.#pos);
|
|
689
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
690
|
+
// First line of `__createError`-formatted messages is the bare cause.
|
|
691
|
+
const firstLine = message.split("\n", 1)[0];
|
|
692
|
+
const start = Math.max(0, parser.#pos - 20);
|
|
693
|
+
const end = Math.min(parser.#input.length, parser.#pos + 20);
|
|
694
|
+
errors.push({
|
|
695
|
+
message: firstLine,
|
|
696
|
+
position: parser.#pos,
|
|
697
|
+
snippet: parser.#input.slice(start, end),
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
// Trailing content that wasn't grabbed by the throw path (e.g. an
|
|
701
|
+
// unmatched closing parenthesis at the top level breaks the parse
|
|
702
|
+
// loop without throwing). Surface it as `unparsed` to match the
|
|
703
|
+
// long-standing convention.
|
|
704
|
+
if (!unparsed && parser.#pos < parser.#length) {
|
|
705
|
+
unparsed = parser.#input.slice(parser.#pos);
|
|
706
|
+
}
|
|
707
|
+
// Prepend any leading free text, single-space joined.
|
|
708
|
+
if (leading) {
|
|
709
|
+
unparsed = unparsed ? `${leading} ${unparsed}` : leading;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
512
712
|
}
|
|
713
|
+
// Strip any lingering skip markers (defensive — should never happen).
|
|
714
|
+
const parsed = internal.filter((item) => !item || item.__skip !== SKIP_MARKER);
|
|
513
715
|
return {
|
|
514
716
|
parsed,
|
|
515
717
|
unparsed,
|
|
@@ -522,6 +724,7 @@ export class ConditionParser {
|
|
|
522
724
|
return { key, operator, value };
|
|
523
725
|
}),
|
|
524
726
|
},
|
|
727
|
+
errors,
|
|
525
728
|
};
|
|
526
729
|
}
|
|
527
730
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/condition-parser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/mod.js",
|
|
6
6
|
"types": "dist/mod.d.ts",
|
|
@@ -10,10 +10,18 @@
|
|
|
10
10
|
"import": "./dist/mod.js"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md",
|
|
17
|
+
"API.md",
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"CLAUDE.md"
|
|
20
|
+
],
|
|
13
21
|
"author": "Marian Meres",
|
|
14
22
|
"license": "MIT",
|
|
15
23
|
"dependencies": {
|
|
16
|
-
"@marianmeres/condition-builder": "^1.9.
|
|
24
|
+
"@marianmeres/condition-builder": "^1.9.4"
|
|
17
25
|
},
|
|
18
26
|
"repository": {
|
|
19
27
|
"type": "git",
|