import * as dom from './dom.js'
import * as grammar from './xpathPattern3.js'
import util from 'util'
/**
* Error parsing an XPath expression, with prettier output.
*
* @extends {Error}
*/
export class XPathSyntaxError extends Error {
/**
* Creates an instance of XPathSyntaxError.
*
* @param {Error} e Error thrown from peg parser
* @param {string} pattern The pattern that was being parsed
*/
constructor(e, pattern) {
super(`Syntax error in "${pattern}": ${e.message}`, {cause: e})
this.pattern = pattern
// @ts-ignore
if (e.location) {
// @ts-ignore
this.location = e.location
}
}
/**
* Format the error for the console.
*
* @param {number} depth How deep can we go? 0 to stop.
* @param {util.InspectOptionsStylized} options Inspection options
* @returns {string}
*/
[util.inspect.custom](depth, options) {
if (!options.colors || !options.stylize) {
return this.toString()
}
let ret = 'Error: '
ret += this.pattern.slice(0, this.location.start.offset)
ret += options.stylize(this.pattern.slice(this.location.start.offset, this.location.end.offset), 'regexp')
ret += this.pattern.slice(this.location.end.offset)
return ret
}
}
/**
* @typedef {Array<string|number|dom.Node>} XPathResult
*/
/**
* @callback XPathFun
* @param {...XPathResult} params
* @return {XPathResult}
*/
/**
* @type {Record<string,XPathFun>}
* @private
*/
const XPathFunctions = {
count(context) {
return [context.length]
},
}
/**
* An XPath expression for querying an XML document
*/
export class XPath {
/**
* Creates an instance of XPath.
*
* @param {string} pattern
* @throws {TypeError} Not a string
* @throws {XPathSyntaxError} Invalid syntax in the pattern
*/
constructor(pattern) {
if (typeof pattern !== 'string') {
throw new TypeError('pattern required')
}
this.pattern = pattern
try {
this.start = grammar.parse(pattern, {
impl: this,
})
} catch (e) {
throw new XPathSyntaxError(/** @type {Error} */(e), pattern)
}
}
/**
* Staring with the given context Node, execute the expression
*
* @param {dom.Node} context The DOM Node from which to base the expression's
* query
* @returns {XPathResult} resuts
*/
execute(context) {
return this._eval([context], this.start)
}
/**
* @callback OpFunction
* @param {number} i The offset into the context nodes list
* @param {dom.Node} context The current context node
* @param {...any} args Extra arguments for the function
* @returns {XPathResult}
* @private
*/
/**
* @jsdoc-remove-next-tag
* @typedef {[OpFunction, ...any]} Functor
*/
/**
* Evaluate a function within some nodes, concatenating the results.
*
* @param {dom.Node[]} context
* @param {Functor} functor The function and extra arguments to
* call on each node in context
* @returns {XPathResult}
* @private
*/
_eval(context, [func, ...args]) {
if (typeof func !== 'function') {
return [func, ...args]
}
return context.reduce(
(p, c, i) => {
const eret = func.call(this, i, c, ...args)
return p.concat(eret)
}, /** @type {XPathResult} */([])
)
}
/**
* Get the root node of the doc
*
* @param {number} num Unused
* @param {dom.Node} context What node to start from
* @param {Functor} [relative] Query to run relative to root
* @returns {XPathResult}
* @private
*/
_root(num, context, relative) {
const doc = context && context.document
if (doc) {
if (relative) {
return this._eval([doc], relative)
}
return [doc]
}
return []
}
/**
* Child elements with the given name.
*
* @param {number} num Unused
* @param {dom.Node} context
* @param {string} name
* @returns {XPathResult}
* @private
*/
// eslint-disable-next-line class-methods-use-this
_nameTest(num, context, name) {
if (Array.isArray(context)) {
// Results of _all
return context.filter(
n => (n instanceof dom.Element) && n.name.local === name
)
} else if (context instanceof dom.Document) {
return [...context.elements(name)]
} else if (context instanceof dom.Element) {
return [...context.elements(name)]
} else if (context instanceof dom.Attribute) {
if (context.name.local === name) {
return [context]
}
}
return []
}
/**
* All children, with "star".
*
* @param {number} num Unused
* @param {dom.Node} context
* @param {string} star Should always be "*" for now.
* @returns {XPathResult}
* @private
*/
// eslint-disable-next-line class-methods-use-this
_nameTestWildcard(num, context, star) {
if (star !== '*') {
throw new Error(`Unimplemented wildcard: "${star}"`)
}
if (Array.isArray(context)) {
return context.filter(e => e instanceof dom.Element)
} else if (context instanceof dom.Attribute) {
return [context]
} else if (context instanceof dom.Element) {
return [...context.elements()]
} else if (context instanceof dom.Document) {
const {root} = context
if (!root) {
throw new Error('Invalid document')
}
return [root]
}
return []
}
/**
* Relative to the given context
*
* @param {number} num Unused
* @param {dom.Node} context
* @param {Functor} first First chunk of relative chain
* @param {Functor[]} steps
* @returns {XPathResult}
* @private
*/
_relative(num, context, first, steps) {
return steps.reduce(
(prevContext, step) => this._eval(
/** @type {dom.Node[]} */ (prevContext), step
),
this._eval([context], first)
)
}
/**
* Single- or double-slash steps.
*
* @param {number} num Unused
* @param {dom.Node} context Parent
* @param {"/"|"//"} slash Single or double?
* @param {Functor} expr RHS
* @returns {XPathResult}
* @private
*/
_step(num, context, slash, expr) {
if (slash === '//') {
if (!(context instanceof dom.ParentNode)) {
throw new Error('Invalid parent node for //')
}
const descendants = [...context.descendantElements()]
// @ts-ignore -- I don't understand this one
return this._eval([descendants], expr)
}
return this._eval([context], expr)
}
/**
* Attribute check
*
* @param {number} num Unused
* @param {dom.Node} context
* @param {Functor} expr
* @returns {XPathResult}
* @private
*/
_attrib(num, context, expr) {
if (Array.isArray(context)) {
return context.reduce((p, v) => {
if (v instanceof dom.Element) {
p = p.concat(this._eval(v.att, expr))
}
return p
}, [])
} else if (!(context instanceof dom.Element)) {
throw new Error('Can only take attributes of elements')
}
return this._eval(context.att, expr)
}
/**
* "//" at the beginning of the pattern.
*
* @param {number} num Unused
* @param {dom.Node} context
* @param {Functor} expr
* @returns {XPathResult}
* @private
*/
_all(num, context, expr) {
// Example:
// (fn:root(self::node()) treat as
// document-node())/descendant-or-self::node()/
if (!(context instanceof dom.ParentNode)) {
throw new Error('Ivalid context for beginning //')
}
// @ts-ignore -- Don't understand this either
return this._eval([[context, ...context.descendants()]], expr)
}
/**
* Retrieve the text from the node.
*
* @param {number} num Unused
* @param {dom.Node} context
* @returns {XPathResult}
* @private
*/
// eslint-disable-next-line class-methods-use-this
_textTest(num, context) {
if (typeof context === 'string') {
return [context]
}
if (typeof context.text !== 'function') {
throw new Error('Cannot get text from this node')
}
return [context.text()]
}
/**
* The current node.
*
* @param {number} num Unused
* @param {dom.Node} context
* @returns {XPathResult}
* @private
*/
// eslint-disable-next-line class-methods-use-this
_dot(num, context) {
return [context]
}
/**
* Filter a list of nodes with a set of predicates.
*
* @param {number} num Unused
* @param {dom.Node} context
* @param {Functor} expr
* @param {Functor[]} predicates
* @returns {XPathResult}
* @private
*/
_filter(num, context, expr, predicates) {
return predicates.reduce(
(prevContext, pred) => this._eval(
/** @type {dom.Node[]} */ (prevContext), pred
),
this._eval([context], expr)
)
}
/**
*
* @param {number} num Offset into the list one segment higher
* @param {dom.Node} context
* @param {number|Functor} expr
* @returns {XPathResult}
* @private
*/
_pred(num, context, expr) {
if (typeof expr === 'number') {
return (expr === num + 1) ? [context] : []
} else if (Array.isArray(expr)) {
return (this._eval([context], expr).length > 0) ? [context] : []
}
throw new Error(`Unimplemented predicate in "${this.pattern}": "${util.inspect(expr)}"`)
}
/**
* Process an expression
*
* @param {dom.Node} context
* @param {Functor} expr
* @returns {string|number|dom.Node|undefined}
* @private
*/
_expr(context, expr) {
if (Array.isArray(expr)) {
const ret = this._eval([context], expr).shift()
// @ts-ignore
if (ret && (typeof ret.text === 'function')) {
// @ts-ignore
return ret.text()
}
return ret
}
return expr
}
/**
* Compare left expression to right expression using operator op.
*
* @param {number} num
* @param {dom.Node} context
* @param {Functor} left
* @param {string} op
* @param {Functor} right
* @returns {boolean[]}
* @private
*/
// eslint-disable-next-line max-params
_compare(num, context, left, op, right) {
const eLeft = this._expr(context, left)
const eRight = this._expr(context, right)
switch (op) {
case '=':
case 'eq':
if (eLeft === eRight) {
return [true]
}
break
case '!=':
case 'ne':
if (eLeft !== eRight) {
return [true]
}
break
default:
throw new Error(`Unimplemented op: "${op}"`)
}
return []
}
/**
* Get the parent of the current node.
*
* @param {number} num Unused
* @param {dom.Node} context
* @returns {XPathResult}
* @private
*/
// eslint-disable-next-line class-methods-use-this
_parent(num, context) {
if (Array.isArray(context)) {
return context.reduce((p, v) => {
if (v.parent) {
p.push(v)
}
return p
}, [])
}
if (!context.parent) {
throw new Error('No parent')
}
return [context.parent]
}
/**
* Comment in a pattern. Careful no-op.
*
* @param {number} num Unused
* @param {dom.Node} context
* @returns {XPathResult}
* @private
*/
// eslint-disable-next-line class-methods-use-this
_comment(num, context) {
if (Array.isArray(context)) {
return context.filter(n => n instanceof dom.Comment)
} else if (context instanceof dom.ParentNode) {
return context.children.filter(n => n instanceof dom.Comment)
}
return []
}
/**
* Multiple expressions in parallel.
*
* @param {number} num Unused
* @param {dom.Node} context
* @param {Functor[]} expressions
* @returns {XPathResult}
* @private
*/
_comma(num, context, expressions) {
return expressions.reduce(
(p, v) => p.concat(this._eval([context], v)),
/** @type {XPathResult} */ ([])
)
}
/**
* Execute functions.
*
* @param {number} num
* @param {dom.Node} context
* @param {string} fn
* @param {Functor[]} params
* @returns {XPathResult}
* @private
*/
_fn(num, context, fn, params) {
const fun = XPathFunctions[fn]
if (typeof fun !== 'function') {
throw new Error(`Unimplemented function: "${fn}"`)
}
const eparams = params.map(
v => this._eval([context], v)
)
return fun.apply(this, eparams)
}
/**
* Some operation that hasn't been implemented yet.
*
* @param {number} num
* @param {dom.Node} context
* @param {string} op
* @param {...any} args
* @private
*/
// eslint-disable-next-line class-methods-use-this
_unimplemented(num, context, op, ...args) {
throw new Error(`Unimplemented: "${op}"`)
// Should this be an option?
// console.error('UNIMPLEMENTED:', op)
// return []
}
}