210 lines
6.4 KiB
JavaScript
210 lines
6.4 KiB
JavaScript
/**
|
||
* ExpressionSet - An indexed collection of Expressions for efficient bulk matching
|
||
*
|
||
* Instead of iterating all expressions on every tag, ExpressionSet pre-indexes
|
||
* them at insertion time by depth and terminal tag name. At match time, only
|
||
* the relevant bucket is evaluated — typically reducing checks from O(E) to O(1)
|
||
* lookup plus O(small bucket) matches.
|
||
*
|
||
* Three buckets are maintained:
|
||
* - `_byDepthAndTag` — exact depth + exact tag name (tightest, used first)
|
||
* - `_wildcardByDepth` — exact depth + wildcard tag `*` (depth-matched only)
|
||
* - `_deepWildcards` — expressions containing `..` (cannot be depth-indexed)
|
||
*
|
||
* @example
|
||
* import { Expression, ExpressionSet } from 'fast-xml-tagger';
|
||
*
|
||
* // Build once at config time
|
||
* const stopNodes = new ExpressionSet();
|
||
* stopNodes.add(new Expression('root.users.user'));
|
||
* stopNodes.add(new Expression('root.config.setting'));
|
||
* stopNodes.add(new Expression('..script'));
|
||
*
|
||
* // Query on every tag — hot path
|
||
* if (stopNodes.matchesAny(matcher)) { ... }
|
||
*/
|
||
export default class ExpressionSet {
|
||
constructor() {
|
||
/** @type {Map<string, import('./Expression.js').default[]>} depth:tag → expressions */
|
||
this._byDepthAndTag = new Map();
|
||
|
||
/** @type {Map<number, import('./Expression.js').default[]>} depth → wildcard-tag expressions */
|
||
this._wildcardByDepth = new Map();
|
||
|
||
/** @type {import('./Expression.js').default[]} expressions containing deep wildcard (..) */
|
||
this._deepWildcards = [];
|
||
|
||
/** @type {Set<string>} pattern strings already added — used for deduplication */
|
||
this._patterns = new Set();
|
||
|
||
/** @type {boolean} whether the set is sealed against further additions */
|
||
this._sealed = false;
|
||
}
|
||
|
||
/**
|
||
* Add an Expression to the set.
|
||
* Duplicate patterns (same pattern string) are silently ignored.
|
||
*
|
||
* @param {import('./Expression.js').default} expression - A pre-constructed Expression instance
|
||
* @returns {this} for chaining
|
||
* @throws {TypeError} if called after seal()
|
||
*
|
||
* @example
|
||
* set.add(new Expression('root.users.user'));
|
||
* set.add(new Expression('..script'));
|
||
*/
|
||
add(expression) {
|
||
if (this._sealed) {
|
||
throw new TypeError(
|
||
'ExpressionSet is sealed. Create a new ExpressionSet to add more expressions.'
|
||
);
|
||
}
|
||
|
||
// Deduplicate by pattern string
|
||
if (this._patterns.has(expression.pattern)) return this;
|
||
this._patterns.add(expression.pattern);
|
||
|
||
if (expression.hasDeepWildcard()) {
|
||
this._deepWildcards.push(expression);
|
||
return this;
|
||
}
|
||
|
||
const depth = expression.length;
|
||
const lastSeg = expression.segments[expression.segments.length - 1];
|
||
const tag = lastSeg?.tag;
|
||
|
||
if (!tag || tag === '*') {
|
||
// Can index by depth but not by tag
|
||
if (!this._wildcardByDepth.has(depth)) this._wildcardByDepth.set(depth, []);
|
||
this._wildcardByDepth.get(depth).push(expression);
|
||
} else {
|
||
// Tightest bucket: depth + tag
|
||
const key = `${depth}:${tag}`;
|
||
if (!this._byDepthAndTag.has(key)) this._byDepthAndTag.set(key, []);
|
||
this._byDepthAndTag.get(key).push(expression);
|
||
}
|
||
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Add multiple expressions at once.
|
||
*
|
||
* @param {import('./Expression.js').default[]} expressions - Array of Expression instances
|
||
* @returns {this} for chaining
|
||
*
|
||
* @example
|
||
* set.addAll([
|
||
* new Expression('root.users.user'),
|
||
* new Expression('root.config.setting'),
|
||
* ]);
|
||
*/
|
||
addAll(expressions) {
|
||
for (const expr of expressions) this.add(expr);
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Check whether a pattern string is already present in the set.
|
||
*
|
||
* @param {import('./Expression.js').default} expression
|
||
* @returns {boolean}
|
||
*/
|
||
has(expression) {
|
||
return this._patterns.has(expression.pattern);
|
||
}
|
||
|
||
/**
|
||
* Number of expressions in the set.
|
||
* @type {number}
|
||
*/
|
||
get size() {
|
||
return this._patterns.size;
|
||
}
|
||
|
||
/**
|
||
* Seal the set against further modifications.
|
||
* Useful to prevent accidental mutations after config is built.
|
||
* Calling add() or addAll() on a sealed set throws a TypeError.
|
||
*
|
||
* @returns {this}
|
||
*/
|
||
seal() {
|
||
this._sealed = true;
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Whether the set has been sealed.
|
||
* @type {boolean}
|
||
*/
|
||
get isSealed() {
|
||
return this._sealed;
|
||
}
|
||
|
||
/**
|
||
* Test whether the matcher's current path matches any expression in the set.
|
||
*
|
||
* Evaluation order (cheapest → most expensive):
|
||
* 1. Exact depth + tag bucket — O(1) lookup, typically 0–2 expressions
|
||
* 2. Depth-only wildcard bucket — O(1) lookup, rare
|
||
* 3. Deep-wildcard list — always checked, but usually small
|
||
*
|
||
* @param {import('./Matcher.js').default} matcher - Matcher instance (or readOnly view)
|
||
* @returns {boolean} true if any expression matches the current path
|
||
*
|
||
* @example
|
||
* if (stopNodes.matchesAny(matcher)) {
|
||
* // handle stop node
|
||
* }
|
||
*/
|
||
matchesAny(matcher) {
|
||
return this.findMatch(matcher) !== null;
|
||
}
|
||
/**
|
||
* Find and return the first Expression that matches the matcher's current path.
|
||
*
|
||
* Uses the same evaluation order as matchesAny (cheapest → most expensive):
|
||
* 1. Exact depth + tag bucket
|
||
* 2. Depth-only wildcard bucket
|
||
* 3. Deep-wildcard list
|
||
*
|
||
* @param {import('./Matcher.js').default} matcher - Matcher instance (or readOnly view)
|
||
* @returns {import('./Expression.js').default | null} the first matching Expression, or null
|
||
*
|
||
* @example
|
||
* const expr = stopNodes.findMatch(matcher);
|
||
* if (expr) {
|
||
* // access expr.config, expr.pattern, etc.
|
||
* }
|
||
*/
|
||
findMatch(matcher) {
|
||
const depth = matcher.getDepth();
|
||
const tag = matcher.getCurrentTag();
|
||
|
||
// 1. Tightest bucket — most expressions live here
|
||
const exactKey = `${depth}:${tag}`;
|
||
const exactBucket = this._byDepthAndTag.get(exactKey);
|
||
if (exactBucket) {
|
||
for (let i = 0; i < exactBucket.length; i++) {
|
||
if (matcher.matches(exactBucket[i])) return exactBucket[i];
|
||
}
|
||
}
|
||
|
||
// 2. Depth-matched wildcard-tag expressions
|
||
const wildcardBucket = this._wildcardByDepth.get(depth);
|
||
if (wildcardBucket) {
|
||
for (let i = 0; i < wildcardBucket.length; i++) {
|
||
if (matcher.matches(wildcardBucket[i])) return wildcardBucket[i];
|
||
}
|
||
}
|
||
|
||
// 3. Deep wildcards — cannot be pre-filtered by depth or tag
|
||
for (let i = 0; i < this._deepWildcards.length; i++) {
|
||
if (matcher.matches(this._deepWildcards[i])) return this._deepWildcards[i];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|