path-expression-matcher 1.0.0 → 1.1.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/README.md CHANGED
@@ -37,6 +37,13 @@ if (matcher.matches(expr)) {
37
37
  console.log("Match found!");
38
38
  console.log("Current path:", matcher.toString()); // "root.users.user"
39
39
  }
40
+
41
+ // Namespace support
42
+ const nsExpr = new Expression("soap::Envelope.soap::Body..ns::UserId");
43
+ matcher.push("Envelope", null, "soap");
44
+ matcher.push("Body", null, "soap");
45
+ matcher.push("UserId", null, "ns");
46
+ console.log(matcher.toString()); // "soap:Envelope.soap:Body.ns:UserId"
40
47
  ```
41
48
 
42
49
  ## 📖 Pattern Syntax
@@ -78,11 +85,37 @@ if (matcher.matches(expr)) {
78
85
 
79
86
  **Note:** Position selectors use the **counter** (occurrence count of the tag name), not the position (child index). For example, in `<root><a/><b/><a/></root>`, the second `<a/>` has position=2 but counter=1.
80
87
 
88
+ ### Namespaces
89
+
90
+ ```javascript
91
+ "ns::user" // user with namespace "ns"
92
+ "soap::Envelope" // Envelope with namespace "soap"
93
+ "ns::user[id]" // user with namespace "ns" and "id" attribute
94
+ "ns::user:first" // First user with namespace "ns"
95
+ "*::user" // user with any namespace
96
+ "..ns::item" // item with namespace "ns" anywhere in tree
97
+ "soap::Envelope.soap::Body" // Nested namespaced elements
98
+ "ns::first" // Tag named "first" with namespace "ns" (NO ambiguity!)
99
+ ```
100
+
101
+ **Namespace syntax:**
102
+ - Use **double colon (::)** for namespace: `ns::tag`
103
+ - Use **single colon (:)** for position: `tag:first`
104
+ - Combined: `ns::tag:first` (namespace + tag + position)
105
+
106
+ **Namespace matching rules:**
107
+ - Pattern `ns::user` matches only nodes with namespace "ns" and tag "user"
108
+ - Pattern `user` (no namespace) matches nodes with tag "user" regardless of namespace
109
+ - Pattern `*::user` matches tag "user" with any namespace (wildcard namespace)
110
+ - Namespaces are tracked separately for counter/position (e.g., `ns1::item` and `ns2::item` have independent counters)
111
+
81
112
  ### Combined Patterns
82
113
 
83
114
  ```javascript
84
- "..user[id]:first" // First user with id, anywhere
85
- "root..user[type=admin]" // Admin user under root
115
+ "..user[id]:first" // First user with id, anywhere
116
+ "root..user[type=admin]" // Admin user under root
117
+ "ns::user[id]:first" // First namespaced user with id
118
+ "soap::Envelope..ns::UserId" // UserId with namespace ns under SOAP envelope
86
119
  ```
87
120
 
88
121
  ## 🔧 API Reference
@@ -125,18 +158,21 @@ new Matcher(options)
125
158
 
126
159
  #### Path Tracking Methods
127
160
 
128
- ##### `push(tagName, attrValues)`
161
+ ##### `push(tagName, attrValues, namespace)`
129
162
 
130
163
  Add a tag to the current path. Position and counter are automatically calculated.
131
164
 
132
165
  **Parameters:**
133
166
  - `tagName` (string): Tag name
134
167
  - `attrValues` (object, optional): Attribute key-value pairs (current node only)
168
+ - `namespace` (string, optional): Namespace for the tag
135
169
 
136
170
  **Example:**
137
171
  ```javascript
138
172
  matcher.push("user", { id: "123", type: "admin" });
139
173
  matcher.push("item"); // No attributes
174
+ matcher.push("Envelope", null, "soap"); // With namespace
175
+ matcher.push("Body", { version: "1.1" }, "soap"); // With both
140
176
  ```
141
177
 
142
178
  **Position vs Counter:**
@@ -199,6 +235,14 @@ Get current tag name.
199
235
  const tag = matcher.getCurrentTag(); // "user"
200
236
  ```
201
237
 
238
+ ##### `getCurrentNamespace()`
239
+
240
+ Get current namespace.
241
+
242
+ ```javascript
243
+ const ns = matcher.getCurrentNamespace(); // "soap" or undefined
244
+ ```
245
+
202
246
  ##### `getAttrValue(attrName)`
203
247
 
204
248
  Get attribute value of current node.
@@ -249,13 +293,18 @@ Get current path depth.
249
293
  const depth = matcher.getDepth(); // 3 for "root.users.user"
250
294
  ```
251
295
 
252
- ##### `toString(separator?)`
296
+ ##### `toString(separator?, includeNamespace?)`
253
297
 
254
298
  Get path as string.
255
299
 
300
+ **Parameters:**
301
+ - `separator` (string, optional): Path separator (uses default if not provided)
302
+ - `includeNamespace` (boolean, optional): Whether to include namespaces (default: true)
303
+
256
304
  ```javascript
257
- const path = matcher.toString(); // "root.users.user"
258
- const path2 = matcher.toString('/'); // "root/users/user"
305
+ const path = matcher.toString(); // "root.ns:user.item"
306
+ const path2 = matcher.toString('/'); // "root/ns:user/item"
307
+ const path3 = matcher.toString('.', false); // "root.user.item" (no namespaces)
259
308
  ```
260
309
 
261
310
  ##### `toArray()`
@@ -419,6 +468,48 @@ const expr = new Expression("root.item:first");
419
468
  console.log(matcher.matches(expr)); // false (counter=1, not 0)
420
469
  ```
421
470
 
471
+ ### Example 7: Namespace Support (XML/SOAP)
472
+
473
+ ```javascript
474
+ const matcher = new Matcher();
475
+ const soapExpr = new Expression("soap::Envelope.soap::Body..ns::UserId");
476
+
477
+ // Parse SOAP document
478
+ matcher.push("Envelope", { xmlns: "..." }, "soap");
479
+ matcher.push("Body", null, "soap");
480
+ matcher.push("GetUserRequest", null, "ns");
481
+ matcher.push("UserId", null, "ns");
482
+
483
+ // Match namespaced pattern
484
+ if (matcher.matches(soapExpr)) {
485
+ console.log("Found UserId in SOAP body");
486
+ console.log(matcher.toString()); // "soap:Envelope.soap:Body.ns:GetUserRequest.ns:UserId"
487
+ }
488
+
489
+ // Namespace-specific counters
490
+ matcher.reset();
491
+ matcher.push("root");
492
+ matcher.push("item", null, "ns1"); // ns1::item counter=0
493
+ matcher.pop();
494
+ matcher.push("item", null, "ns2"); // ns2::item counter=0 (different namespace)
495
+ matcher.pop();
496
+ matcher.push("item", null, "ns1"); // ns1::item counter=1
497
+
498
+ const firstNs1Item = new Expression("root.ns1::item:first");
499
+ console.log(matcher.matches(firstNs1Item)); // false (counter=1)
500
+
501
+ const secondNs1Item = new Expression("root.ns1::item:nth(1)");
502
+ console.log(matcher.matches(secondNs1Item)); // true
503
+
504
+ // NO AMBIGUITY: Tags named after position keywords
505
+ matcher.reset();
506
+ matcher.push("root");
507
+ matcher.push("first", null, "ns"); // Tag named "first" with namespace
508
+
509
+ const expr = new Expression("root.ns::first");
510
+ console.log(matcher.matches(expr)); // true - matches namespace "ns", tag "first"
511
+ ```
512
+
422
513
  ## 🏗️ Architecture
423
514
 
424
515
  ### Data Storage Strategy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "path-expression-matcher",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Efficient path tracking and pattern matching for XML/JSON parsers",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
  "./Matcher": "./src/Matcher.js"
11
11
  },
12
12
  "scripts": {
13
- "test": "node test/test.js"
13
+ "test": "node test/namespace_test.js && node test/test.js"
14
14
  },
15
15
  "keywords": [
16
16
  "xml",
package/src/Expression.js CHANGED
@@ -76,51 +76,114 @@ export default class Expression {
76
76
  /**
77
77
  * Parse a single segment
78
78
  * @private
79
- * @param {string} part - Segment string (e.g., "user", "user[id]", "user:first")
79
+ * @param {string} part - Segment string (e.g., "user", "ns::user", "user[id]", "ns::user:first")
80
80
  * @returns {Object} Segment object
81
81
  */
82
82
  _parseSegment(part) {
83
83
  const segment = { type: 'tag' };
84
84
 
85
- // Match pattern: tagname[attr] or tagname[attr=value] or tagname:position
86
- // Examples: user, user[id], user[type=admin], user:first, user[id]:first, user:nth(2)
87
- const match = part.match(/^([^[\]:]+)(?:\[([^\]]+)\])?(?::(\w+(?:\(\d+\))?))?$/);
85
+ // NEW NAMESPACE SYNTAX (v2.0):
86
+ // ============================
87
+ // Namespace uses DOUBLE colon (::)
88
+ // Position uses SINGLE colon (:)
89
+ //
90
+ // Examples:
91
+ // "user" → tag
92
+ // "user:first" → tag + position
93
+ // "user[id]" → tag + attribute
94
+ // "user[id]:first" → tag + attribute + position
95
+ // "ns::user" → namespace + tag
96
+ // "ns::user:first" → namespace + tag + position
97
+ // "ns::user[id]" → namespace + tag + attribute
98
+ // "ns::user[id]:first" → namespace + tag + attribute + position
99
+ // "ns::first" → namespace + tag named "first" (NO ambiguity!)
100
+ //
101
+ // This eliminates all ambiguity:
102
+ // :: = namespace separator
103
+ // : = position selector
104
+ // [] = attributes
105
+
106
+ // Step 1: Extract brackets [attr] or [attr=value]
107
+ let bracketContent = null;
108
+ let withoutBrackets = part;
109
+
110
+ const bracketMatch = part.match(/^([^\[]+)(\[[^\]]*\])(.*)$/);
111
+ if (bracketMatch) {
112
+ withoutBrackets = bracketMatch[1] + bracketMatch[3];
113
+ if (bracketMatch[2]) {
114
+ const content = bracketMatch[2].slice(1, -1);
115
+ if (content) {
116
+ bracketContent = content;
117
+ }
118
+ }
119
+ }
88
120
 
89
- if (!match) {
90
- throw new Error(`Invalid segment pattern: ${part}`);
121
+ // Step 2: Check for namespace (double colon ::)
122
+ let namespace = undefined;
123
+ let tagAndPosition = withoutBrackets;
124
+
125
+ if (withoutBrackets.includes('::')) {
126
+ const nsIndex = withoutBrackets.indexOf('::');
127
+ namespace = withoutBrackets.substring(0, nsIndex).trim();
128
+ tagAndPosition = withoutBrackets.substring(nsIndex + 2).trim(); // Skip ::
129
+
130
+ if (!namespace) {
131
+ throw new Error(`Invalid namespace in pattern: ${part}`);
132
+ }
91
133
  }
92
134
 
93
- segment.tag = match[1].trim();
135
+ // Step 3: Parse tag and position (single colon :)
136
+ let tag = undefined;
137
+ let positionMatch = null;
94
138
 
95
- // Parse attribute condition [attr] or [attr=value]
96
- if (match[2]) {
97
- const attrExpr = match[2];
139
+ if (tagAndPosition.includes(':')) {
140
+ const colonIndex = tagAndPosition.lastIndexOf(':'); // Use last colon for position
141
+ const tagPart = tagAndPosition.substring(0, colonIndex).trim();
142
+ const posPart = tagAndPosition.substring(colonIndex + 1).trim();
98
143
 
99
- if (attrExpr.includes('=')) {
100
- const eqIndex = attrExpr.indexOf('=');
101
- const attrName = attrExpr.substring(0, eqIndex).trim();
102
- const attrValue = attrExpr.substring(eqIndex + 1).trim();
144
+ // Verify position is a valid keyword
145
+ const isPositionKeyword = ['first', 'last', 'odd', 'even'].includes(posPart) ||
146
+ /^nth\(\d+\)$/.test(posPart);
103
147
 
104
- segment.attrName = attrName;
105
- segment.attrValue = attrValue;
148
+ if (isPositionKeyword) {
149
+ tag = tagPart;
150
+ positionMatch = posPart;
106
151
  } else {
107
- segment.attrName = attrExpr.trim();
152
+ // Not a valid position keyword, treat whole thing as tag
153
+ tag = tagAndPosition;
108
154
  }
155
+ } else {
156
+ tag = tagAndPosition;
109
157
  }
110
158
 
111
- // Parse position selector :first, :nth(n), :odd, :even
112
- if (match[3]) {
113
- const posExpr = match[3];
159
+ if (!tag) {
160
+ throw new Error(`Invalid segment pattern: ${part}`);
161
+ }
162
+
163
+ segment.tag = tag;
164
+ if (namespace) {
165
+ segment.namespace = namespace;
166
+ }
167
+
168
+ // Step 4: Parse attributes
169
+ if (bracketContent) {
170
+ if (bracketContent.includes('=')) {
171
+ const eqIndex = bracketContent.indexOf('=');
172
+ segment.attrName = bracketContent.substring(0, eqIndex).trim();
173
+ segment.attrValue = bracketContent.substring(eqIndex + 1).trim();
174
+ } else {
175
+ segment.attrName = bracketContent.trim();
176
+ }
177
+ }
114
178
 
115
- // Check for :nth(n) pattern
116
- const nthMatch = posExpr.match(/^nth\((\d+)\)$/);
179
+ // Step 5: Parse position selector
180
+ if (positionMatch) {
181
+ const nthMatch = positionMatch.match(/^nth\((\d+)\)$/);
117
182
  if (nthMatch) {
118
183
  segment.position = 'nth';
119
184
  segment.positionValue = parseInt(nthMatch[1], 10);
120
- } else if (['first', 'odd', 'even'].includes(posExpr)) {
121
- segment.position = posExpr;
122
185
  } else {
123
- throw new Error(`Invalid position selector: :${posExpr}`);
186
+ segment.position = positionMatch;
124
187
  }
125
188
  }
126
189
 
package/src/Matcher.js CHANGED
@@ -33,8 +33,9 @@ export default class Matcher {
33
33
  * Push a new tag onto the path
34
34
  * @param {string} tagName - Name of the tag
35
35
  * @param {Object} attrValues - Attribute key-value pairs for current node (optional)
36
+ * @param {string} namespace - Namespace for the tag (optional)
36
37
  */
37
- push(tagName, attrValues = null) {
38
+ push(tagName, attrValues = null, namespace = null) {
38
39
  // Remove values from previous current node (now becoming ancestor)
39
40
  if (this.path.length > 0) {
40
41
  const prev = this.path[this.path.length - 1];
@@ -49,8 +50,11 @@ export default class Matcher {
49
50
 
50
51
  const siblings = this.siblingStacks[currentLevel];
51
52
 
53
+ // Create a unique key for sibling tracking that includes namespace
54
+ const siblingKey = namespace ? `${namespace}:${tagName}` : tagName;
55
+
52
56
  // Calculate counter (how many times this tag appeared at this level)
53
- const counter = siblings.get(tagName) || 0;
57
+ const counter = siblings.get(siblingKey) || 0;
54
58
 
55
59
  // Calculate position (total children at this level so far)
56
60
  let position = 0;
@@ -59,7 +63,7 @@ export default class Matcher {
59
63
  }
60
64
 
61
65
  // Update sibling count for this tag
62
- siblings.set(tagName, counter + 1);
66
+ siblings.set(siblingKey, counter + 1);
63
67
 
64
68
  // Create new node
65
69
  const node = {
@@ -68,6 +72,11 @@ export default class Matcher {
68
72
  counter: counter
69
73
  };
70
74
 
75
+ // Store namespace if provided
76
+ if (namespace !== null && namespace !== undefined) {
77
+ node.namespace = namespace;
78
+ }
79
+
71
80
  // Store values only for current node
72
81
  if (attrValues !== null && attrValues !== undefined) {
73
82
  node.values = attrValues;
@@ -87,9 +96,11 @@ export default class Matcher {
87
96
 
88
97
  const node = this.path.pop();
89
98
 
90
- // Clean up sibling tracking for this level
91
- if (this.siblingStacks[this.path.length]) {
92
- delete this.siblingStacks[this.path.length];
99
+ // Clean up sibling tracking for levels deeper than current
100
+ // After pop, path.length is the new depth
101
+ // We need to clean up siblingStacks[path.length + 1] and beyond
102
+ if (this.siblingStacks.length > this.path.length + 1) {
103
+ this.siblingStacks.length = this.path.length + 1;
93
104
  }
94
105
 
95
106
  return node;
@@ -117,6 +128,14 @@ export default class Matcher {
117
128
  return this.path.length > 0 ? this.path[this.path.length - 1].tag : undefined;
118
129
  }
119
130
 
131
+ /**
132
+ * Get current namespace
133
+ * @returns {string|undefined}
134
+ */
135
+ getCurrentNamespace() {
136
+ return this.path.length > 0 ? this.path[this.path.length - 1].namespace : undefined;
137
+ }
138
+
120
139
  /**
121
140
  * Get current node's attribute value
122
141
  * @param {string} attrName - Attribute name
@@ -177,11 +196,17 @@ export default class Matcher {
177
196
  /**
178
197
  * Get path as string
179
198
  * @param {string} separator - Optional separator (uses default if not provided)
199
+ * @param {boolean} includeNamespace - Whether to include namespace in output (default: true)
180
200
  * @returns {string}
181
201
  */
182
- toString(separator) {
202
+ toString(separator, includeNamespace = true) {
183
203
  const sep = separator || this.separator;
184
- return this.path.map(n => n.tag).join(sep);
204
+ return this.path.map(n => {
205
+ if (includeNamespace && n.namespace) {
206
+ return `${n.namespace}:${n.tag}`;
207
+ }
208
+ return n.tag;
209
+ }).join(sep);
185
210
  }
186
211
 
187
212
  /**
@@ -311,6 +336,15 @@ export default class Matcher {
311
336
  return false;
312
337
  }
313
338
 
339
+ // Match namespace if specified in segment
340
+ if (segment.namespace !== undefined) {
341
+ // Segment has namespace - node must match it
342
+ if (segment.namespace !== '*' && segment.namespace !== node.namespace) {
343
+ return false;
344
+ }
345
+ }
346
+ // If segment has no namespace, it matches nodes with or without namespace
347
+
314
348
  // Match attribute name (check if node has this attribute)
315
349
  // Can only check for current node since ancestors don't have values
316
350
  if (segment.attrName !== undefined) {