570 lines
14 KiB
JavaScript
570 lines
14 KiB
JavaScript
import ExpressionSet from "./ExpressionSet.js";
|
|
|
|
/**
|
|
* MatcherView - A lightweight read-only view over a Matcher's internal state.
|
|
*
|
|
* Created once by Matcher and reused across all callbacks. Holds a direct
|
|
* reference to the parent Matcher so it always reflects current parser state
|
|
* with zero copying or freezing overhead.
|
|
*
|
|
* Users receive this via {@link Matcher#readOnly} or directly from parser
|
|
* callbacks. It exposes all query and matching methods but has no mutation
|
|
* methods — misuse is caught at the TypeScript level rather than at runtime.
|
|
*
|
|
* @example
|
|
* const matcher = new Matcher();
|
|
* const view = matcher.readOnly();
|
|
*
|
|
* matcher.push("root", {});
|
|
* view.getCurrentTag(); // "root"
|
|
* view.getDepth(); // 1
|
|
*/
|
|
export class MatcherView {
|
|
/**
|
|
* @param {Matcher} matcher - The parent Matcher instance to read from.
|
|
*/
|
|
constructor(matcher) {
|
|
this._matcher = matcher;
|
|
}
|
|
|
|
/**
|
|
* Get the path separator used by the parent matcher.
|
|
* @returns {string}
|
|
*/
|
|
get separator() {
|
|
return this._matcher.separator;
|
|
}
|
|
|
|
/**
|
|
* Get current tag name.
|
|
* @returns {string|undefined}
|
|
*/
|
|
getCurrentTag() {
|
|
const path = this._matcher.path;
|
|
return path.length > 0 ? path[path.length - 1].tag : undefined;
|
|
}
|
|
|
|
/**
|
|
* Get current namespace.
|
|
* @returns {string|undefined}
|
|
*/
|
|
getCurrentNamespace() {
|
|
const path = this._matcher.path;
|
|
return path.length > 0 ? path[path.length - 1].namespace : undefined;
|
|
}
|
|
|
|
/**
|
|
* Get current node's attribute value.
|
|
* @param {string} attrName
|
|
* @returns {*}
|
|
*/
|
|
getAttrValue(attrName) {
|
|
const path = this._matcher.path;
|
|
if (path.length === 0) return undefined;
|
|
return path[path.length - 1].values?.[attrName];
|
|
}
|
|
|
|
/**
|
|
* Check if current node has an attribute.
|
|
* @param {string} attrName
|
|
* @returns {boolean}
|
|
*/
|
|
hasAttr(attrName) {
|
|
const path = this._matcher.path;
|
|
if (path.length === 0) return false;
|
|
const current = path[path.length - 1];
|
|
return current.values !== undefined && attrName in current.values;
|
|
}
|
|
|
|
/**
|
|
* Get current node's sibling position (child index in parent).
|
|
* @returns {number}
|
|
*/
|
|
getPosition() {
|
|
const path = this._matcher.path;
|
|
if (path.length === 0) return -1;
|
|
return path[path.length - 1].position ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Get current node's repeat counter (occurrence count of this tag name).
|
|
* @returns {number}
|
|
*/
|
|
getCounter() {
|
|
const path = this._matcher.path;
|
|
if (path.length === 0) return -1;
|
|
return path[path.length - 1].counter ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Get current node's sibling index (alias for getPosition).
|
|
* @returns {number}
|
|
* @deprecated Use getPosition() or getCounter() instead
|
|
*/
|
|
getIndex() {
|
|
return this.getPosition();
|
|
}
|
|
|
|
/**
|
|
* Get current path depth.
|
|
* @returns {number}
|
|
*/
|
|
getDepth() {
|
|
return this._matcher.path.length;
|
|
}
|
|
|
|
/**
|
|
* Get path as string.
|
|
* @param {string} [separator] - Optional separator (uses default if not provided)
|
|
* @param {boolean} [includeNamespace=true]
|
|
* @returns {string}
|
|
*/
|
|
toString(separator, includeNamespace = true) {
|
|
return this._matcher.toString(separator, includeNamespace);
|
|
}
|
|
|
|
/**
|
|
* Get path as array of tag names.
|
|
* @returns {string[]}
|
|
*/
|
|
toArray() {
|
|
return this._matcher.path.map(n => n.tag);
|
|
}
|
|
|
|
/**
|
|
* Match current path against an Expression.
|
|
* @param {Expression} expression
|
|
* @returns {boolean}
|
|
*/
|
|
matches(expression) {
|
|
return this._matcher.matches(expression);
|
|
}
|
|
|
|
/**
|
|
* Match any expression in the given set against the current path.
|
|
* @param {ExpressionSet} exprSet
|
|
* @returns {boolean}
|
|
*/
|
|
matchesAny(exprSet) {
|
|
return exprSet.matchesAny(this._matcher);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Use {@link Matcher#readOnly} to obtain a {@link MatcherView} safe to pass to
|
|
* user callbacks — it always reflects current state with no Proxy overhead.
|
|
*
|
|
* @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
|
|
*/
|
|
export default class Matcher {
|
|
/**
|
|
* Create a new Matcher.
|
|
* @param {Object} [options={}]
|
|
* @param {string} [options.separator='.'] - Default path separator
|
|
*/
|
|
constructor(options = {}) {
|
|
this.separator = options.separator || '.';
|
|
this.path = [];
|
|
this.siblingStacks = [];
|
|
// Each path node: { tag, values, position, counter, namespace? }
|
|
// values only present for current (last) node
|
|
// Each siblingStacks entry: Map<tagName, count> tracking occurrences at each level
|
|
this._pathStringCache = null;
|
|
this._view = new MatcherView(this);
|
|
}
|
|
|
|
/**
|
|
* Push a new tag onto the path.
|
|
* @param {string} tagName
|
|
* @param {Object|null} [attrValues=null]
|
|
* @param {string|null} [namespace=null]
|
|
*/
|
|
push(tagName, attrValues = null, namespace = null) {
|
|
this._pathStringCache = null;
|
|
|
|
// Remove values from previous current node (now becoming ancestor)
|
|
if (this.path.length > 0) {
|
|
this.path[this.path.length - 1].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
|
|
};
|
|
|
|
if (namespace !== null && namespace !== undefined) {
|
|
node.namespace = namespace;
|
|
}
|
|
|
|
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;
|
|
this._pathStringCache = null;
|
|
|
|
const node = this.path.pop();
|
|
|
|
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
|
|
*/
|
|
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
|
|
* @returns {*}
|
|
*/
|
|
getAttrValue(attrName) {
|
|
if (this.path.length === 0) return undefined;
|
|
return this.path[this.path.length - 1].values?.[attrName];
|
|
}
|
|
|
|
/**
|
|
* Check if current node has an attribute.
|
|
* @param {string} attrName
|
|
* @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).
|
|
* @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=true]
|
|
* @returns {string}
|
|
*/
|
|
toString(separator, includeNamespace = true) {
|
|
const sep = separator || this.separator;
|
|
const isDefault = (sep === this.separator && includeNamespace === true);
|
|
|
|
if (isDefault) {
|
|
if (this._pathStringCache !== null) {
|
|
return this._pathStringCache;
|
|
}
|
|
const result = this.path.map(n =>
|
|
(n.namespace) ? `${n.namespace}:${n.tag}` : n.tag
|
|
).join(sep);
|
|
this._pathStringCache = result;
|
|
return result;
|
|
}
|
|
|
|
return this.path.map(n =>
|
|
(includeNamespace && n.namespace) ? `${n.namespace}:${n.tag}` : 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._pathStringCache = null;
|
|
this.path = [];
|
|
this.siblingStacks = [];
|
|
}
|
|
|
|
/**
|
|
* Match current path against an Expression.
|
|
* @param {Expression} expression
|
|
* @returns {boolean}
|
|
*/
|
|
matches(expression) {
|
|
const segments = expression.segments;
|
|
|
|
if (segments.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (expression.hasDeepWildcard()) {
|
|
return this._matchWithDeepWildcard(segments);
|
|
}
|
|
|
|
return this._matchSimple(segments);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_matchSimple(segments) {
|
|
if (this.path.length !== segments.length) {
|
|
return false;
|
|
}
|
|
|
|
for (let i = 0; i < segments.length; i++) {
|
|
if (!this._matchSegment(segments[i], this.path[i], i === this.path.length - 1)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_matchWithDeepWildcard(segments) {
|
|
let pathIdx = this.path.length - 1;
|
|
let segIdx = segments.length - 1;
|
|
|
|
while (segIdx >= 0 && pathIdx >= 0) {
|
|
const segment = segments[segIdx];
|
|
|
|
if (segment.type === 'deep-wildcard') {
|
|
segIdx--;
|
|
|
|
if (segIdx < 0) {
|
|
return true;
|
|
}
|
|
|
|
const nextSeg = segments[segIdx];
|
|
let found = false;
|
|
|
|
for (let i = pathIdx; i >= 0; i--) {
|
|
if (this._matchSegment(nextSeg, this.path[i], i === this.path.length - 1)) {
|
|
pathIdx = i - 1;
|
|
segIdx--;
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
return false;
|
|
}
|
|
} else {
|
|
if (!this._matchSegment(segment, this.path[pathIdx], pathIdx === this.path.length - 1)) {
|
|
return false;
|
|
}
|
|
pathIdx--;
|
|
segIdx--;
|
|
}
|
|
}
|
|
|
|
return segIdx < 0;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_matchSegment(segment, node, isCurrentNode) {
|
|
if (segment.tag !== '*' && segment.tag !== node.tag) {
|
|
return false;
|
|
}
|
|
|
|
if (segment.namespace !== undefined) {
|
|
if (segment.namespace !== '*' && segment.namespace !== node.namespace) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (segment.attrName !== undefined) {
|
|
if (!isCurrentNode) {
|
|
return false;
|
|
}
|
|
|
|
if (!node.values || !(segment.attrName in node.values)) {
|
|
return false;
|
|
}
|
|
|
|
if (segment.attrValue !== undefined) {
|
|
if (String(node.values[segment.attrName]) !== String(segment.attrValue)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (segment.position !== undefined) {
|
|
if (!isCurrentNode) {
|
|
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' && counter !== segment.positionValue) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Match any expression in the given set against the current path.
|
|
* @param {ExpressionSet} exprSet
|
|
* @returns {boolean}
|
|
*/
|
|
matchesAny(exprSet) {
|
|
return exprSet.matchesAny(this);
|
|
}
|
|
|
|
/**
|
|
* Create a snapshot of current state.
|
|
* @returns {Object}
|
|
*/
|
|
snapshot() {
|
|
return {
|
|
path: this.path.map(node => ({ ...node })),
|
|
siblingStacks: this.siblingStacks.map(map => new Map(map))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Restore state from snapshot.
|
|
* @param {Object} snapshot
|
|
*/
|
|
restore(snapshot) {
|
|
this._pathStringCache = null;
|
|
this.path = snapshot.path.map(node => ({ ...node }));
|
|
this.siblingStacks = snapshot.siblingStacks.map(map => new Map(map));
|
|
}
|
|
|
|
/**
|
|
* Return the read-only {@link MatcherView} for this matcher.
|
|
*
|
|
* The same instance is returned on every call — no allocation occurs.
|
|
* It always reflects the current parser state and is safe to pass to
|
|
* user callbacks without risk of accidental mutation.
|
|
*
|
|
* @returns {MatcherView}
|
|
*
|
|
* @example
|
|
* const view = matcher.readOnly();
|
|
* // pass view to callbacks — it stays in sync automatically
|
|
* view.matches(expr); // ✓
|
|
* view.getCurrentTag(); // ✓
|
|
* // view.push(...) // ✗ method does not exist — caught by TypeScript
|
|
*/
|
|
readOnly() {
|
|
return this._view;
|
|
}
|
|
} |