@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  ## Package Overview
4
4
 
5
5
  - **Name**: `@marianmeres/condition-parser`
6
- - **Version**: 1.7.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 (705 lines)
16
+ └── parser.ts # Main parser implementation
17
17
 
18
18
  tests/
19
- └── all.test.ts # Test suite (682 lines, 30 tests)
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: any[];
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**: Backslash escapes for `'`, `"`, `:`, `)` within strings
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
- 30 tests covering:
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 conditions
168
- - Free text handling
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
- - Error handling
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
- - Error positions tracked internally for debugging
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 falsy to skip |
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; // skip in parsed
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 trailing text that couldn't be parsed (useful for free-text search) |
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: any[];
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` | `any[]` | Array of unique values found in parsed expressions |
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
- Escape special characters with backslash:
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
- key\:colon:value -> key: "key:colon"
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
- Any trailing unparsable content is preserved:
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 append arbitrary unparsable content which will be preserved:
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) this is free text",
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 - filter/route expressions before adding
102
+ // - preAddHook: (ctx) => ctx|null|undefined - return falsy to DROP the expression
99
103
  ```
100
104
 
101
- See [API.md](./API.md) for complete API documentation.
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: any[];
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
- * If it returns a falsy value, the expression will be skipped.
80
- * Useful for filtering or routing expressions to different destinations.
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 = 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.#consume();
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
- const closing = ")";
187
+ let depth = 1;
165
188
  while (this.#pos < this.#length) {
166
189
  const char = this.#consume();
167
- if (char === closing && this.#peek(-2) !== "\\") {
168
- this.#debug("parseParenthesizedValue:result", result, this.#peek());
169
- return result;
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 === "\\" && this.#peek() === closing) {
172
- result += closing;
173
- this.#consume(); // Skip the escaped char
201
+ if (char === "(") {
202
+ depth++;
203
+ result += char;
204
+ continue;
174
205
  }
175
- else {
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
- /** Will parse the currently ahead quoted block with escape support.
182
- * Supports both single ' and double " quotes. */
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 === quote && this.#peek(-2) !== "\\") {
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
- if (char === "\\" && this.#peek() === quote) {
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
- /** Will parse the currently ahead unquoted block until delimiter ":", "(", ")", or \s) */
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 ((char === ":" && this.#peek(-1) !== "\\") ||
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
- if (char === "\\" && this.#peek(1) === ":") {
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
- /** Will parse the "and" or "or" logical operator */
234
- #parseConditionOperator(openingParenthesesLevel) {
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
- const _isNotAhead = (s) => /\s*not\s+/i.exec(s);
240
- if (/^and /i.test(remaining)) {
241
- this.#pos += 4;
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
- // maybe followed by "not"?
244
- const notIsAheadMatch = _isNotAhead(remaining);
245
- if (notIsAheadMatch) {
246
- // minus 1, because the initial test includes single trailing whitespace
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 /i.test(remaining)) {
252
- this.#pos += 3;
329
+ else if (/^or(\s|$)/i.test(remaining)) {
330
+ this.#pos += 2;
253
331
  result = "or";
254
- // maybe followed by "not"?
255
- const notIsAheadMatch = _isNotAhead(remaining);
256
- if (notIsAheadMatch) {
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
- else if (openingParenthesesLevel !== undefined) {
263
- const preLevel = openingParenthesesLevel;
264
- const postLevel = this.#countSameCharsAhead(")");
265
- if (preLevel !== postLevel) {
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
- if (this.#isQuoteAhead()) {
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
- // Try to parse as if we have an operator
296
- if (this.#isOpeningParenthesisAhead()) {
297
- wasParenthesized = true;
298
- value = this.#parseParenthesizedValue();
299
- }
300
- else if (this.#isQuoteAhead()) {
301
- value = this.#parseQuotedString();
302
- }
303
- else {
304
- value = this.#parseUnquotedString();
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
- // Parse the actual value
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
- // this.#pos = _startPos;
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
- let expression = this.#transform?.({
330
- key,
331
- operator,
332
- value,
333
- }) ?? {
334
- key,
335
- operator,
336
- value,
337
- };
338
- if (typeof this.#preAddHook === "function") {
339
- expression = this.#preAddHook(expression);
340
- // return early if hook returned falsey
341
- if (!expression) {
342
- this.#debug("parseBasicExpression:preAddHook truthy skip...");
343
- expression = { key: "1", operator: this.#defaultOperator, value: "1" };
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(out.at(-1).condition, currentOperator);
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
- /** will count how many same exact consequent `char`s are ahead (excluding whitespace) */
397
- #countSameCharsAhead(char) {
398
- const posBkp = this.#pos;
399
- let count = 0;
400
- let next = this.#consume();
401
- while (next === char) {
402
- count++;
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, openingParenthesesLevel) {
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(openingParenthesesLevel);
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
- // point here is that we must expect #parseTerm below to fail (trailing
444
- // unparsable content is legit), so we need to save current operator to
445
- // be able to restore it
446
- const _previousBkp = out.at(-1).operator;
447
- // "previous" operator edit to match condition-builder convention
448
- out.at(-1).operator = conditionOperator;
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
- out.at(-1).operator = _previousBkp;
456
- // and catch unparsed below
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
- let parsed = [];
666
+ const internal = [];
501
667
  let unparsed = "";
502
- const openingLevel = parser.#countSameCharsAhead("(");
503
- try {
504
- // Start with the highest-level logical expression
505
- parsed = parser.#parseCondition(parsed, "and", openingLevel);
506
- }
507
- catch (_e) {
508
- if (options.debug)
509
- parser.#debug(`${_e}`);
510
- // collect trailing unparsed input
511
- unparsed = parser.#input.slice(parser.#pos);
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.7.4",
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.3"
24
+ "@marianmeres/condition-builder": "^1.9.4"
17
25
  },
18
26
  "repository": {
19
27
  "type": "git",