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 +97 -6
- package/package.json +2 -2
- package/src/Expression.js +88 -25
- package/src/Matcher.js +42 -8
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"
|
|
85
|
-
"root..user[type=admin]"
|
|
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();
|
|
258
|
-
const path2 = matcher.toString('/');
|
|
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.
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
135
|
+
// Step 3: Parse tag and position (single colon :)
|
|
136
|
+
let tag = undefined;
|
|
137
|
+
let positionMatch = null;
|
|
94
138
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
148
|
+
if (isPositionKeyword) {
|
|
149
|
+
tag = tagPart;
|
|
150
|
+
positionMatch = posPart;
|
|
106
151
|
} else {
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
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 =>
|
|
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) {
|