edumanagerpro2/node_modules/path-expression-matcher/src/Matcher.js

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;
}
}