'use strict'
const { Selector, Visitor, Inquiry, Query } = require('./traversal.js')
function normalize(node, ...list) {
let res = []
let buf = ''
let ast = []
let reg = []
let last = null
let item
for (let i = 0; i < list.length; i++) {
item = list[i]
if (!item) return
if (Array.isArray(item)) {
const norm = normalize(node, ...item)
if (last && norm.nodes.length) {
norm.nodes[0].left = last
last.right = norm.nodes[0]
}
if (buf.length) {
if (typeof(norm.syntax[0]) == 'string') {
buf += norm.syntax.shift()
if (norm.syntax.length) {
res.push(buf)
buf = ''
}
} else {
res.push(buf)
buf = ''
}
}
if (typeof(norm.syntax[norm.syntax.length-1]) == 'string') {
buf += norm.syntax.pop()
}
res = res.concat(norm.syntax)
ast = ast.concat(norm.nodes)
if (ast.length) last = ast[ast.length-1]
}
else if (typeof(item) == 'string') buf += item
else if (item.constructor.name == 'AST') {
if (buf.length) {
res.push(buf)
buf = ''
}
item.parent = node
if (last) {
item.left = last
last.right = item
}
res.push(item)
ast.push(item)
last = item
} else {
throw new Error('Invalid object found in syntax.')
}
}
res.push(buf)
return {syntax: res, nodes: ast, regions: reg}
}
/** An AST node, string, or array of these
* @typedef {(AST|string|Syntax)} SyntaxArg
*/
/** An array of AST nodes, strings, and arrays of these
* @typedef {Array<SyntaxArg>} Syntax
*/
/** An array of strings or arrays of strings
* @typedef {string|Array<Tags>} Tags
*/
/** A config object that an AST node may be constructed from
* @typedef {Object} ASTConf
* @property {Syntax} syntax - syntax objects. may be strings, AST nodes or arrays of these
* @property {object} attrs - attributes of this node, if any
* @property {Tags} tags - tags of this node, if any
* @property {object} location - location data
*/
/** The ghast abstract syntax tree.
* @constructor
* @param {ASTConf} opt - an ASTConf object
*/
class AST {
constructor(opt) {
const conf = {syntax: [], attrs: {}, tags: [], location: null}
Object.assign(conf, opt)
this.id = conf.id
const norm = normalize(this, ...conf.syntax)
this.syntax = norm.syntax
this.attrs = conf.attrs
this.parent = false
this.left = false
this.right = false
this.nodes = norm.nodes
this.tags = []
this.tag(conf.tags)
this.location = conf.location
if (!this.id) throw new Error('Node must have an ID.')
if (typeof(this.id) != 'string') throw new Error('Node ID must be a string.')
}
get [Symbol.toStringTag]() {
const rm = this.isRoot ? '^' : ''
const lm = this.isLeaf ? '>' : ''
const sm = this.isStem ? '-' : ''
const am = this.hasAttributes ? '=' : ''
const as = Object.entries(this.attrs).map(p=> `${p[0]}:${p[1]}`).join(';')
return `${rm}${sm}${lm}${this.hasTags ? this.tags.join('.')+'.' : ''}${this.id}${am}${as}`
}
//// Identity functions
/** True if this node has no parent. */
get isRoot() { return (!this.parent) }
/** True if this node has no children. */
get isLeaf() { return (this.nodes.length < 1) }
/** True if this node is not a leaf or root. */
get isStem() { return !this.isRoot && !this.isLeaf }
/** True if this node has any tags. */
get hasTags() { return this.tags.length > 0 }
/** True if this node has any attributes. */
get hasAttributes() { return Object.entries(this.attrs).length > 0 }
/** Returns true if the node has all of the given tags.
* @param {...string} tags - zero or more tags to check
* @return {boolean}
*/
hasTag(...tags) {
let has = true
for (let i = 0; i < tags.length; i++) {
if (this.tags.indexOf(tags[i]) < 0) has = false
}
return has
}
/** Return captured syntax as a string. */
get image() {
return this.syntax.map(s=> typeof(s) == 'string' ? s : s.image).join('')
}
//// Navigation
/** Return rightmost child node or null if this node has no children. */
get rightmostNode() { return this.nodes[this.nodes.length-1] || null }
/** Return leftmost child node or null if this node has no children. */
get leftmostNode() { return this.nodes[0] || null }
/** Return rightmost descendant node. If this node is a leaf it will return itself. */
get rightmostLeaf() {
if (this.isLeaf) return this
if (this.rightmostNode.isLeaf) {
return this.rightmostNode
} else {
return this.rightmostNode.rightmostLeaf
}
}
/** Return leftmost descendant node. If this node is a leaf it will return itself. */
get leftmostLeaf() {
if (this.isLeaf) return this
if (this.leftmostNode.isLeaf) {
return this.leftmostNode
} else {
return this.leftmostNode.leftmostLeaf
}
}
/** Return leftward sibling. If root, return self. */
get goLeft() {
if (this.isRoot) return this
if (this.left) return this.left
else return this.parent.goLeft.rightmostLeaf
}
/** Return rightward sibling. If root, return self. */
get goRight() {
if (this.isRoot) return this
if (this.right) return this.right
else return this.parent.goRight.leftmostLeaf
}
/** Return parent. If root, return self. */
get goUp() {
if (this.isRoot) return this
else return this.parent
}
/** Return first (leftmost) child. If this node is a leaf, return self. */
get goDown() {
if (this.isLeaf) return this
else return this.leftmostNode
}
/** Called with each returned node.
* @callback eachNode
* @param {AST} node
*/
/** An array of nodes, or a single node which may be null
* @typedef {(AST[]|?AST)} Nodes
*/
/** Perform selections on the tree from a sequence of traversals. Returns the
* selected nodes if no callback is given.
* @param {...TraverseLike} [traverse] - Zero-or-more Traverse literals
* @param {eachNode} [callback] - A function to be called on each selected node
* @return {AST[]} An array of any AST nodes that were selected
* @example
* // similar to the css selector `A .foo > B`
* node.select('A', {tag: 'foo'}, {id: 'B', depth: 0})
*/
select(...atoms) {
const selector = new Selector(...atoms)
let targets = [this]
let trv
let target
let result
for (let i = 0; i < selector.length; i++) {
trv = selector[i]
result = []
for (let n = 0; n < targets.length; n++) {
target = targets[n]
const r = target.each(trv)
if (r) result = [...new Set(result.concat(r))]
}
targets = result
}
if (selector.cb) {
for (let i = 0; i < result.length; i++) {
selector.cb(result[i])
}
}
return result
}
/** Perform a single traversal of the tree.
* @param {TraverseLike} [traverse] - A Traverse literal
* @param {eachNode} [callback] - A function to be called on each selected node
* @return {Nodes} An array of selected nodes, or a single node if `first` or `last` are specified.
* Returns `null` if `first` or `last` are specified and nothing was selected.
* @example
* node.each() // return all descendants of `node`
* node.each({self: true}) // return `node` and all of its descendants
* node.each('Section') // return all descendants with id `Section`
*/
each(...atoms) {
const inq = Inquiry.make(...atoms)
const trv = inq.traverse
let matched = []
let node
if (trv.self && trv.top) {
if (trv.match(this)) matched.push(this)
}
if (!trv.first || !matched.length) {
if (trv.up) {
if (this.parent) {
if (trv.match(this.parent)) matched.push(this.parent)
if ((trv.depth < 0 || trv.depth > 0) && (!trv.first || !matched.length)) {
const sub = this.parent.each(trv.next())
if (sub?.length) matched = matched.concat(sub)
}
}
} else {
for (let i = 0; i < this.nodes.length; i++) {
node = this.nodes[i]
if (trv.match(node)) matched.push(node)
if (trv.first && matched.length) {
break
} else {
if (trv.depth < 0 || trv.depth > 0) {
const submatch = node.each(trv.next())
if (submatch?.length) {
matched = matched.concat(submatch)
if (trv.first) break
}
}
}
}
}
}
if (inq.cb) {
for (let i = 0; i < matched.length; i++) {
inq.cb(matched[i])
}
}
if (trv.top && trv.first) return matched[0]
else if (trv.top && trv.last) return matched[matched.length-1]
else return matched
}
/** Return the first descendant selected by the given traverse. Same as calling
* `each` with `first` specified.
* @param {TraverseLike} [traverse] - A Traverse literal
* @param {eachNode} [callback] - A function to be called on the selected node, if any
* @return {?AST} The selected node or `null` if nothing was selected
* @example
* // select first 'A' node
* node.first('A')
* // same as above
* node.each({id: 'A', first: true})
*/
first(...atoms) {
const inq = Inquiry.make(...atoms)
inq.traverse.first = true
return this.each(inq)
}
/** Traverse this nodes ancestors. Same as calling `each` with `up` specified.
* @param {TraverseLike} [traverse] - A Traverse literal
* @param {eachNode} [callback] - A function to be called on the selected node, if any
* @return {Nodes} An array of selected nodes, or a single node if `first` or `last` are specified.
* Returns `null` if `first` or `last` are specified and nothing was selected.
* @example
* // select any 'A' ancestors
* node.ancestor('A')
* // same as above
* node.each({id: 'A', up: true})
*/
ancestor(...atoms) {
const inq = Inquiry.make(...atoms)
inq.traverse.up = true
return this.each(inq)
}
/** Return the nth parent node.
* @param {number} [n=0] - ancestor to select
* @param {eachNode} [callback] - A function to be called on the selected node, if any
* @return {?AST} The selected node or `null` if nothing was selected
* @example
* // return third ancestor
* node.climb(2)
* // same as above
* node.each({up: true, last: true, depth: 2})
*/
climb(n=0, cb) {
const inq = Inquiry.make({up: true, last: true, depth: n}, cb)
return this.each(inq)
}
/** Match this node against multiple queries
* @param {...QueryLike} query - one or more QueryLikes to match against
* @return {boolean} Returns true if any of the queries match
*/
match(...query) {
let m = false
for (let i = 0; i < query.length; i++) {
m = m || Query.make(query[i]).match(this)
}
return m
}
/** Walk the tree and match each node against the given visitors. When a visitor matches
* a node its callback is called with the node. Multiple visitors may match each node.
* @param {...VisitorLike} visitors - One-or-more Visitor literals, or Visitor objects
* @example
* node.when(
* ['A', 'B', n=> n.foo()],
* [{id: 'C', tag: 'x y'}, {tag: 'c'}, n=> n.bar()],
* [{id: 'Y', leaf: true}, {id: 'Z', leaf: true}, n=> n.baz()],
* )
*/
when(...visitors) {
visitors = visitors.map(v=> new Visitor(...v))
let visitor
this.each({self: true}, node=> {
for (let i = 0; i < visitors.length; i++) {
if (node.match(...visitors[i])) visitors[i].cb(node)
}
})
}
/** Replace child node `a` with node `b`
* If `b` is not given, replace self with `a`
* @param {AST} a - the child node to replaced, or the node to replace self with
* @param {AST} [b] - the node to replace child node `a` with
* @return {AST} Returns the replacement node
*/
replace(a, b) {
let parent
if (b) parent = this
else {
b = a
a = this
parent = this.parent
}
b.parent = a.parent
b.left = a.left
b.right = a.right
parent.syntax.splice(parent.syntax.indexOf(a), 1, b)
parent.nodes.splice(parent.nodes.indexOf(a), 1, b)
return b
}
/** Mutate this node in place, replacing any of its properties
* with those specified in the config object
* @param {ASTConf} opt - an ASTConf object
* @return {AST} Returns the new replacement node
*/
mutate(opt) {
const conf = {id: this.id, syntax: this.syntax, attrs: this.attrs, tags: this.tags}
Object.assign(conf, opt)
return this.replace(new AST(conf))
}
/** Remove a child node n. If n is not given, remove this node.
* with those specified in the config object
* @param {AST} [n] - an AST node
*/
remove(n) {
let parent
if (n) parent = this
else {
parent = this.parent
n = this
}
parent.syntax.splice(parent.syntax.indexOf(n), 1)
parent.nodes.splice(parent.nodes.indexOf(n), 1)
if (n.left) n.left.right = n.right
if (n.right) n.right.left = n.left
}
//// Composable methods
/** Assign an attribute
* @param {string|Object} a - the key to assign, or an object to merge with the nodes attributes
* @param {*} b - the value to assign to key `a`
* @return {ast} Returns this node
*/
attr(a, b) {
if (typeof(a) == 'string') this.attrs[a] = b
else Object.assign(this.attrs, a)
return this
}
/** Merge the attributes of this node with all of its descendant nodes,
* and return the value of `k`
* @param {string} k - the key to read
* @return {*} The value of `k`
*/
read(k) {
return Object.assign(...this.each({self: true}).map(x=> x.attrs))[k]
}
/** Assign one or more tags
* @param {Tags} tags - one or more tags to assign
* @return {AST} Returns this node
*/
tag(...tags) {
let tag
for (let i = 0; i < tags.length; i += 1) {
tag = tags[i]
if (typeof(tag) == 'string') {
if (tag.match(/ /g)) this.tag(...tag.split(' '))
else if (!(tag in this.tags)) this.tags.push(tag)
}
else if (Array.isArray(tag)) this.tag(...tag)
else throw new Error(`Invalid tag: '${tag}'`)
}
return this
}
/** Set location data for this node
* @param {Object} data - location data to be assigned
* @return {AST} Returns this node
*/
loc(data) {
this.location = data
return this
}
}
function classification(helper, ...tags) {
const c = (...args)=> helper(...args).tag(...tags)
c.classify = (...t)=> classification(c, ...t, ...tags)
return c
}
/** A simple wrapper around AST#new. Creates a node from an id and
* zero-or-more syntax items.
* @param {string} id - the id of the new node
* @param {...SyntaxArg} [syntax] - the id of the new node
* @return {AST} The created node
*/
const ast =(id, ...syntax)=> {
return new AST({id, syntax})
}
ast.classify =(...tags)=> {
return classification(ast, ...tags)
}
ast.locate =(loc)=> {
const c = (...args)=> ast(...args).loc(loc())
c.classify = (...t)=> classification(c, ...t)
return c
}
module.exports = { AST, ast }