498 lines
14 KiB
JavaScript
498 lines
14 KiB
JavaScript
/**
|
|
* Matcher - Tracks current path in XML/JSON tree and matches against Expressions
|
|
*
|
|
* The matcher maintains a stack of nodes representing the current path from root to
|
|
* current tag. It only stores attribute values for the current (top) node to minimize
|
|
* memory usage. Sibling tracking is used to auto-calculate position and counter.
|
|
*
|
|
* @example
|
|
* const matcher = new Matcher();
|
|
* matcher.push("root", {});
|
|
* matcher.push("users", {});
|
|
* matcher.push("user", { id: "123", type: "admin" });
|
|
*
|
|
* const expr = new Expression("root.users.user");
|
|
* matcher.matches(expr); // true
|
|
*/
|
|
|
|
/**
|
|
* Names of methods that mutate Matcher state.
|
|
* Any attempt to call these on a read-only view throws a TypeError.
|
|
* @type {Set<string>}
|
|
*/
|
|
const MUTATING_METHODS = new Set(['push', 'pop', 'reset', 'updateCurrent', 'restore']);
|
|
|
|
export default class Matcher {
|
|
/**
|
|
* Create a new Matcher
|
|
* @param {Object} options - Configuration options
|
|
* @param {string} options.separator - Default path separator (default: '.')
|
|
*/
|
|
constructor(options = {}) {
|
|
this.separator = options.separator || '.';
|
|
this.path = [];
|
|
this.siblingStacks = [];
|
|
// Each path node: { tag: string, values: object, position: number, counter: number }
|
|
// values only present for current (last) node
|
|
// Each siblingStacks entry: Map<tagName, count> tracking occurrences at each level
|
|
}
|
|
|
|
/**
|
|
* Push a new tag onto the path
|
|
* @param {string} tagName - Name of the tag
|
|
* @param {Object} attrValues - Attribute key-value pairs for current node (optional)
|
|
* @param {string} namespace - Namespace for the tag (optional)
|
|
*/
|
|
push(tagName, attrValues = null, namespace = null) {
|
|
// Remove values from previous current node (now becoming ancestor)
|
|
if (this.path.length > 0) {
|
|
const prev = this.path[this.path.length - 1];
|
|
prev.values = undefined;
|
|
}
|
|
|
|
// Get or create sibling tracking for current level
|
|
const currentLevel = this.path.length;
|
|
if (!this.siblingStacks[currentLevel]) {
|
|
this.siblingStacks[currentLevel] = new Map();
|
|
}
|
|
|
|
const siblings = this.siblingStacks[currentLevel];
|
|
|
|
// Create a unique key for sibling tracking that includes namespace
|
|
const siblingKey = namespace ? `${namespace}:${tagName}` : tagName;
|
|
|
|
// Calculate counter (how many times this tag appeared at this level)
|
|
const counter = siblings.get(siblingKey) || 0;
|
|
|
|
// Calculate position (total children at this level so far)
|
|
let position = 0;
|
|
for (const count of siblings.values()) {
|
|
position += count;
|
|
}
|
|
|
|
// Update sibling count for this tag
|
|
siblings.set(siblingKey, counter + 1);
|
|
|
|
// Create new node
|
|
const node = {
|
|
tag: tagName,
|
|
position: position,
|
|
counter: counter
|
|
};
|
|
|
|
// Store namespace if provided
|
|
if (namespace !== null && namespace !== undefined) {
|
|
node.namespace = namespace;
|
|
}
|
|
|
|
// Store values only for current node
|
|
if (attrValues !== null && attrValues !== undefined) {
|
|
node.values = attrValues;
|
|
}
|
|
|
|
this.path.push(node);
|
|
}
|
|
|
|
/**
|
|
* Pop the last tag from the path
|
|
* @returns {Object|undefined} The popped node
|
|
*/
|
|
pop() {
|
|
if (this.path.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const node = this.path.pop();
|
|
|
|
// Clean up sibling tracking for levels deeper than current
|
|
// After pop, path.length is the new depth
|
|
// We need to clean up siblingStacks[path.length + 1] and beyond
|
|
if (this.siblingStacks.length > this.path.length + 1) {
|
|
this.siblingStacks.length = this.path.length + 1;
|
|
}
|
|
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Update current node's attribute values
|
|
* Useful when attributes are parsed after push
|
|
* @param {Object} attrValues - Attribute values
|
|
*/
|
|
updateCurrent(attrValues) {
|
|
if (this.path.length > 0) {
|
|
const current = this.path[this.path.length - 1];
|
|
if (attrValues !== null && attrValues !== undefined) {
|
|
current.values = attrValues;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current tag name
|
|
* @returns {string|undefined}
|
|
*/
|
|
getCurrentTag() {
|
|
return this.path.length > 0 ? this.path[this.path.length - 1].tag : undefined;
|
|
}
|
|
|
|
/**
|
|
* Get current namespace
|
|
* @returns {string|undefined}
|
|
*/
|
|
getCurrentNamespace() {
|
|
return this.path.length > 0 ? this.path[this.path.length - 1].namespace : undefined;
|
|
}
|
|
|
|
/**
|
|
* Get current node's attribute value
|
|
* @param {string} attrName - Attribute name
|
|
* @returns {*} Attribute value or undefined
|
|
*/
|
|
getAttrValue(attrName) {
|
|
if (this.path.length === 0) return undefined;
|
|
const current = this.path[this.path.length - 1];
|
|
return current.values?.[attrName];
|
|
}
|
|
|
|
/**
|
|
* Check if current node has an attribute
|
|
* @param {string} attrName - Attribute name
|
|
* @returns {boolean}
|
|
*/
|
|
hasAttr(attrName) {
|
|
if (this.path.length === 0) return false;
|
|
const current = this.path[this.path.length - 1];
|
|
return current.values !== undefined && attrName in current.values;
|
|
}
|
|
|
|
/**
|
|
* Get current node's sibling position (child index in parent)
|
|
* @returns {number}
|
|
*/
|
|
getPosition() {
|
|
if (this.path.length === 0) return -1;
|
|
return this.path[this.path.length - 1].position ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Get current node's repeat counter (occurrence count of this tag name)
|
|
* @returns {number}
|
|
*/
|
|
getCounter() {
|
|
if (this.path.length === 0) return -1;
|
|
return this.path[this.path.length - 1].counter ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Get current node's sibling index (alias for getPosition for backward compatibility)
|
|
* @returns {number}
|
|
* @deprecated Use getPosition() or getCounter() instead
|
|
*/
|
|
getIndex() {
|
|
return this.getPosition();
|
|
}
|
|
|
|
/**
|
|
* Get current path depth
|
|
* @returns {number}
|
|
*/
|
|
getDepth() {
|
|
return this.path.length;
|
|
}
|
|
|
|
/**
|
|
* Get path as string
|
|
* @param {string} separator - Optional separator (uses default if not provided)
|
|
* @param {boolean} includeNamespace - Whether to include namespace in output (default: true)
|
|
* @returns {string}
|
|
*/
|
|
toString(separator, includeNamespace = true) {
|
|
const sep = separator || this.separator;
|
|
return this.path.map(n => {
|
|
if (includeNamespace && n.namespace) {
|
|
return `${n.namespace}:${n.tag}`;
|
|
}
|
|
return n.tag;
|
|
}).join(sep);
|
|
}
|
|
|
|
/**
|
|
* Get path as array of tag names
|
|
* @returns {string[]}
|
|
*/
|
|
toArray() {
|
|
return this.path.map(n => n.tag);
|
|
}
|
|
|
|
/**
|
|
* Reset the path to empty
|
|
*/
|
|
reset() {
|
|
this.path = [];
|
|
this.siblingStacks = [];
|
|
}
|
|
|
|
/**
|
|
* Match current path against an Expression
|
|
* @param {Expression} expression - The expression to match against
|
|
* @returns {boolean} True if current path matches the expression
|
|
*/
|
|
matches(expression) {
|
|
const segments = expression.segments;
|
|
|
|
if (segments.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Handle deep wildcard patterns
|
|
if (expression.hasDeepWildcard()) {
|
|
return this._matchWithDeepWildcard(segments);
|
|
}
|
|
|
|
// Simple path matching (no deep wildcards)
|
|
return this._matchSimple(segments);
|
|
}
|
|
|
|
/**
|
|
* Match simple path (no deep wildcards)
|
|
* @private
|
|
*/
|
|
_matchSimple(segments) {
|
|
// Path must be same length as segments
|
|
if (this.path.length !== segments.length) {
|
|
return false;
|
|
}
|
|
|
|
// Match each segment bottom-to-top
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const segment = segments[i];
|
|
const node = this.path[i];
|
|
const isCurrentNode = (i === this.path.length - 1);
|
|
|
|
if (!this._matchSegment(segment, node, isCurrentNode)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Match path with deep wildcards
|
|
* @private
|
|
*/
|
|
_matchWithDeepWildcard(segments) {
|
|
let pathIdx = this.path.length - 1; // Start from current node (bottom)
|
|
let segIdx = segments.length - 1; // Start from last segment
|
|
|
|
while (segIdx >= 0 && pathIdx >= 0) {
|
|
const segment = segments[segIdx];
|
|
|
|
if (segment.type === 'deep-wildcard') {
|
|
// ".." matches zero or more levels
|
|
segIdx--;
|
|
|
|
if (segIdx < 0) {
|
|
// Pattern ends with "..", always matches
|
|
return true;
|
|
}
|
|
|
|
// Find where next segment matches in the path
|
|
const nextSeg = segments[segIdx];
|
|
let found = false;
|
|
|
|
for (let i = pathIdx; i >= 0; i--) {
|
|
const isCurrentNode = (i === this.path.length - 1);
|
|
if (this._matchSegment(nextSeg, this.path[i], isCurrentNode)) {
|
|
pathIdx = i - 1;
|
|
segIdx--;
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
return false;
|
|
}
|
|
} else {
|
|
// Regular segment
|
|
const isCurrentNode = (pathIdx === this.path.length - 1);
|
|
if (!this._matchSegment(segment, this.path[pathIdx], isCurrentNode)) {
|
|
return false;
|
|
}
|
|
pathIdx--;
|
|
segIdx--;
|
|
}
|
|
}
|
|
|
|
// All segments must be consumed
|
|
return segIdx < 0;
|
|
}
|
|
|
|
/**
|
|
* Match a single segment against a node
|
|
* @private
|
|
* @param {Object} segment - Segment from Expression
|
|
* @param {Object} node - Node from path
|
|
* @param {boolean} isCurrentNode - Whether this is the current (last) node
|
|
* @returns {boolean}
|
|
*/
|
|
_matchSegment(segment, node, isCurrentNode) {
|
|
// Match tag name (* is wildcard)
|
|
if (segment.tag !== '*' && segment.tag !== node.tag) {
|
|
return false;
|
|
}
|
|
|
|
// Match namespace if specified in segment
|
|
if (segment.namespace !== undefined) {
|
|
// Segment has namespace - node must match it
|
|
if (segment.namespace !== '*' && segment.namespace !== node.namespace) {
|
|
return false;
|
|
}
|
|
}
|
|
// If segment has no namespace, it matches nodes with or without namespace
|
|
|
|
// Match attribute name (check if node has this attribute)
|
|
// Can only check for current node since ancestors don't have values
|
|
if (segment.attrName !== undefined) {
|
|
if (!isCurrentNode) {
|
|
// Can't check attributes for ancestor nodes (values not stored)
|
|
return false;
|
|
}
|
|
|
|
if (!node.values || !(segment.attrName in node.values)) {
|
|
return false;
|
|
}
|
|
|
|
// Match attribute value (only possible for current node)
|
|
if (segment.attrValue !== undefined) {
|
|
const actualValue = node.values[segment.attrName];
|
|
// Both should be strings
|
|
if (String(actualValue) !== String(segment.attrValue)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Match position (only for current node)
|
|
if (segment.position !== undefined) {
|
|
if (!isCurrentNode) {
|
|
// Can't check position for ancestor nodes
|
|
return false;
|
|
}
|
|
|
|
const counter = node.counter ?? 0;
|
|
|
|
if (segment.position === 'first' && counter !== 0) {
|
|
return false;
|
|
} else if (segment.position === 'odd' && counter % 2 !== 1) {
|
|
return false;
|
|
} else if (segment.position === 'even' && counter % 2 !== 0) {
|
|
return false;
|
|
} else if (segment.position === 'nth') {
|
|
if (counter !== segment.positionValue) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Create a snapshot of current state
|
|
* @returns {Object} State snapshot
|
|
*/
|
|
snapshot() {
|
|
return {
|
|
path: this.path.map(node => ({ ...node })),
|
|
siblingStacks: this.siblingStacks.map(map => new Map(map))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Restore state from snapshot
|
|
* @param {Object} snapshot - State snapshot
|
|
*/
|
|
restore(snapshot) {
|
|
this.path = snapshot.path.map(node => ({ ...node }));
|
|
this.siblingStacks = snapshot.siblingStacks.map(map => new Map(map));
|
|
}
|
|
|
|
/**
|
|
* Return a read-only view of this matcher.
|
|
*
|
|
* The returned object exposes all query/inspection methods but throws a
|
|
* TypeError if any state-mutating method is called (`push`, `pop`, `reset`,
|
|
* `updateCurrent`, `restore`). Property reads (e.g. `.path`, `.separator`)
|
|
* are allowed but the returned arrays/objects are frozen so callers cannot
|
|
* mutate internal state through them either.
|
|
*
|
|
* @returns {ReadOnlyMatcher} A proxy that forwards read operations and blocks writes.
|
|
*
|
|
* @example
|
|
* const matcher = new Matcher();
|
|
* matcher.push("root", {});
|
|
*
|
|
* const ro = matcher.readOnly();
|
|
* ro.matches(expr); // ✓ works
|
|
* ro.getCurrentTag(); // ✓ works
|
|
* ro.push("child", {}); // ✗ throws TypeError
|
|
* ro.reset(); // ✗ throws TypeError
|
|
*/
|
|
readOnly() {
|
|
const self = this;
|
|
|
|
return new Proxy(self, {
|
|
get(target, prop, receiver) {
|
|
// Block mutating methods
|
|
if (MUTATING_METHODS.has(prop)) {
|
|
return () => {
|
|
throw new TypeError(
|
|
`Cannot call '${prop}' on a read-only Matcher. ` +
|
|
`Obtain a writable instance to mutate state.`
|
|
);
|
|
};
|
|
}
|
|
|
|
const value = Reflect.get(target, prop, receiver);
|
|
|
|
// Freeze array/object properties so callers can't mutate internal
|
|
// state through direct property access (e.g. matcher.path.push(...))
|
|
if (prop === 'path' || prop === 'siblingStacks') {
|
|
return Object.freeze(
|
|
Array.isArray(value)
|
|
? value.map(item =>
|
|
item instanceof Map
|
|
? Object.freeze(new Map(item)) // freeze a copy of each Map
|
|
: Object.freeze({ ...item }) // freeze a copy of each node
|
|
)
|
|
: value
|
|
);
|
|
}
|
|
|
|
// Bind methods so `this` inside them still refers to the real Matcher
|
|
if (typeof value === 'function') {
|
|
return value.bind(target);
|
|
}
|
|
|
|
return value;
|
|
},
|
|
|
|
// Prevent any property assignment on the read-only view
|
|
set(_target, prop) {
|
|
throw new TypeError(
|
|
`Cannot set property '${String(prop)}' on a read-only Matcher.`
|
|
);
|
|
},
|
|
|
|
// Prevent property deletion
|
|
deleteProperty(_target, prop) {
|
|
throw new TypeError(
|
|
`Cannot delete property '${String(prop)}' from a read-only Matcher.`
|
|
);
|
|
}
|
|
});
|
|
}
|
|
} |