Rule.js

import Composite from './Composite'
import Context from './Context'

const INHERIT = Symbol('INHERIT')

/**
 * A nestable parsing rule
 * @extends Composite
 */
class Rule extends Composite {
	/**
	 * Creates a rule instance with the options in the specified object.
	 * @param {object} Df=null
	 * The rule definition object that contains the options as its properties.
	 * Definition objects can be **nested**.
	 * A nested definition is interpreted as a **sub-rule**.
	 * The property name for a nested definition must start with `$` (dollar sign)
	 *
	 * ###### Available Options:
	 * @param {string} Df.name
	 * The name of this rule. Only for debug purpose
	 * @param {string|RegExp} Df.from
	 * The pattern that indicates the begining point of this rule.
	 * If the current chunk matched with this pattern,
	 * this rule will be activated, and the new context will start parsing
	 * from the next chunk
	 * @param Df.start
	 * Alias of `from`
	 * @param {string|RegExp} Df.to
	 * The pattern that indicates the end point of this rule.
	 * If the current chunk matched with this pattern,
	 * this rule will be deactivated, and the current context will be finalized
	 * @param Df.end Alias of `to`
	 *
	 * @param {function} Df.onStart
	 * The callback which is called when this rule gets activated.<br>
	 * If this returns `false`, the {@link Parser} will read the current chunk
	 * again
	 * ###### Parameters:
	 * @param {Context} Df.onStart.cx The current context
	 * @param {string} Df.onStart.chunk
	 * The current chunk which has matched with `from`
	 * @param {number|string[]} Df.onStart.matches
	 * If the `from` pattern is a string, the index of the matched string
	 * in the chunk.<br>
	 * If the `from` pattern is a RegExp, the regex matching results array
	 * @param Df.init Alias of `onStart`
	 *
	 * @param {function} Df.onActive
	 * The callback which is called for every single chunk.<br>
	 * If this returns `false`, the {@link Parser} will read the current chunk
	 * again
	 * ###### Parameters:
	 * @param {Context} Df.onActive.cx The current context
	 * @param {string} Df.onActive.chunk The current chunk
	 * @param Df.parse Alias of `onActive`
	 *
	 * @param {function} Df.onEnd
	 * The callback which is called when this rule gets deactivated.
	 * If this returns `false`, the {@link Parser} will read the current chunk
	 * again
	 * ###### Parameters:
	 * @param {Context} Df.onEnd.cx The current context
	 * @param {string} Df.onEnd.chunk
	 * The current chunk which has matched with `to`
	 * @param {number|string[]} Df.onEnd.matches
	 * If the `to` pattern is a string, the index of the matched string
	 * in the chunk.<br>
	 * If the `to` pattern is a RegExp, the regex matching results array
	 * @param Df.fin Alias of `onEnd`
	 *
	 * @param {function} Df.onOutline
	 * **Debug purpose only.**
	 * You can customize the output of {@link Context#outline}
	 * with this callback
	 * ###### Must Return:
	 * The output `string`
	 * ###### Parameters:
	 * @param {Context} Df.onOutline.cx The context to express
	 * @param Df.express Alias of `onOutline`
	 *
	 * @param {object} Df.on
	 * The container for the another aliases of
	 * `onStart`, `onActive`, `onEnd`, `onOutline`
	 * ###### Properties:
	 * @param {function} Df.on.start Alias of `onStart`
	 * @param {function} Df.on.active Alias of `onActive`
	 * @param {function} Df.on.end Alias of `onEnd`
	 * @param {function} Df.on.outline Alias of `onOutline`
	 *
	 * @param {boolean} Df.isRecursive=false Whether this rule is recursive
	 * @param Df.recursive Alias of `isRecursive`
	 * @param Df.recurse Alias of `isRecursive`
	 * @param {boolean} Df.endsWithParent=false
	 * If `true`, the parent rule can end even when this rule is active
	 * @param {string|RegExp} Df.splitter='\n'
	 * The chunk splitter. When the {@link Parser} reached at
	 * the chunk splitter, the substring from the previous chunk splitter
	 * is passed to the rule as a chunk. The default splitter is a line-break
	 * @param {string} Df.encoding=Rule.INHERIT
	 * The encoding to use for converting the buffer to a chunk string.
	 * Falls back to `'utf8'`
	 * @param {object} Df.$any
	 * A sub-rule definition. The property name can be any string
	 * but must start with `$` (dollar sign)
	 */
	constructor(Df = null) {
		super()
		if (!Df) Df = {}
		this._name = Df.name || null
		this._from = Df.from || Df.start || null
		this._to = Df.to || Df.end || null
		this._isRecursive = Df.isRecursive || Df.recursive || Df.recurse || null
		this._endsWithParent = Df.endsWithParent || null
		this._splitter = Df.splitter || null
		this._encoding = Df.encoding || INHERIT
		this._onStart   = Df.onStart   || (Df.on && Df.on.start)   || Df.init    || null
		this._onActive  = Df.onActive  || (Df.on && Df.on.active)  || Df.parse   || null
		this._onEnd     = Df.onEnd     || (Df.on && Df.on.end)     || Df.fin     || null
		this._onOutline = Df.onOutline || (Df.on && Df.on.outline) || Df.express || null

		// Sub rules
		for (let i in Df) {
			if (!Df[i]) continue
			let m = i.match(/^\$(.*)$/)
			if (!m) continue
			if (!Df[i].name && m[1]) Df[i].name = m[1]
			this.addChild(new Rule(Df[i]))
		}
	}

	/**
	 * The enum for rule properties,
	 * which means the actual property value inherits from the parent rule
	 * @type {Symbol}
	 * @const
	 */
	static get INHERIT() {
		return INHERIT
	}

	/**
	 * The name of this rule
	 * @type {string}
	 * @default ''
	 */
	get name() {
		return this.get('_name', '')
	}

	set name(X) {
		this.set('_name', X)
	}

	/**
	 * The start pattern
	 * @type {RegExp}
	 * @default null
	 */
	get from() {
		return this.get('_from', null)
	}

	set from(X) {
		this.set('_from', X)
	}

	/**
	 * The start pattern
	 * @deprecated Use {@link Rule#from} instead
	 * @type {RegExp}
	 * @default null
	 */
	get start() {
		console.warn(`rule.start is deprecated. Use rule.from instead`)
		return this.from
	}

	set start(X) {
		console.warn(`rule.start is deprecated. Use rule.from instead`)
		this.from = X
	}

	/**
	 * The end pattern
	 * @type {RegExp}
	 * @default null
	 */
	get to() {
		return this.get('_to', null)
	}

	set to(X) {
		this.set('_to', X)
	}

	/**
	 * The end pattern
	 * @deprecated Use {@link Rule#to} instead
	 * @type {RegExp}
	 * @default null
	 */
	get end() {
		console.warn(`rule.end is deprecated. Use rule.to instead`)
		return this.to
	}

	set end(X) {
		console.warn(`rule.end is deprecated. Use rule.to instead`)
		this.to = X
	}

	/**
	 * The event handler which is called when this rule is activated
	 * @type {function}
	 * @default null
	 */
	get onStart() {
		return this.get('_onStart', null)
	}

	set onStart(X) {
		this.set('_onStart', X)
	}

	/**
	 * The event handler which is called every time
	 * the parser reached at {@link Rule#splitter}
	 * @type {function}
	 * @default null
	 */
	get onActive() {
		return this.get('_onActive', null)
	}

	set onActive(X) {
		this.set('_onActive', X)
	}
	/**
	 * The event handler which is called when this rule is deactivated
	 * @type {function}
	 * @default null
	 */
	get onEnd() {
		return this.get('_onEnd', null)
	}

	set onEnd(X) {
		this.set('_onEnd', X)
	}

	/**
	 * **Debug purpose only.**
	 * The callback which runs when {@link Context#outline} is called
	 * @type {function}
	 * @default null
	 */
	get onOutline() {
		return this.get('_onOutline', null)
	}

	set onOutline(X) {
		this.set('_onOutline', X)
	}

	/**
	 * Whether this rule is recursive
	 * @type {boolean}
	 * @default false
	 */
	get isRecursive() {
		return this.get('_isRecursive', false)
	}

	set isRecursive(X) {
		this.set('_isRecursive', X)
	}

	/**
	 * Whether the current context can also be ended by the parent context rule
	 * @type {boolean}
	 * @default false
	 */
	get endsWithParent() {
		return this.get('_endsWithParent', false)
	}

	set endsWithParent(X) {
		this.set('_endsWithParent', X)
	}

	/**
	 * The chunk splitter
	 * @type {string|RegExp}
	 * @default '\n'
	 */
	get splitter() {
		return this.get('_splitter', '\n')
	}

	set splitter(X) {
		this.set('_splitter', X)
	}

	/**
	 * The encoding to decode buffers. Falls back to `'utf8'`
	 * @type {string}
	 * @default Rule.INHERIT
	 */
	get encoding() {
		return this.get('_encoding', 'utf8')
	}

	set encoding(X) {
		this.set('_encoding', X)
	}

	/**
	 * @param {string} Name The name of the property to get
	 * @param {mixed} Fallback The value which the property falls back to
	 * @param {boolean} Inherits=true Whether or not to inherit the parent's value
	 * @return {mixed}
	 * @private
	 */
	get(Name, Fallback, Inherits = true) {
		let prop = this[Name]
		return Inherits && prop == INHERIT ? (
			this.hasParent ?
			this.parent.get(Name, Fallback, Inherits) : Fallback
		) : (prop == null ? Fallback : prop)
	}

	/**
	 * @param {string} Name The name of the property to set
	 * @param {mixed} Value The value to set to the property
	 * @private
	 */
	set(Name, Value) {
		if (this[Name] != null)
			throw new Error(`The property cannot be changed`)
		this[Name] = Value
	}

	/**
	 * Performs matching the specified chunk with the start pattern
	 * @param {string} Chunk The chunk to match
	 * @return {mixed} The matching result
	 */
	startsWith(Chunk) {
		return Rule.checkEnclosure(Chunk, this.from)
	}

	/**
	 * Performs matching the specified chunk with the end pattern
	 * @param {string} Chunk The chunk to match
	 * @return {mixed} The matching result
	 */
	endsWith(Chunk) {
		return Rule.checkEnclosure(Chunk, this.to)
	}

	/**
	 * @private
	 * @param {string} Chunk
	 * @param {boolean|string|function|RegExp|mixed[]} Cond Condition
	 * @return {mixed}
	 */
	static checkEnclosure(Chunk, Cond) {
		if (!Cond) return false
		switch (typeof Cond) {
		case 'boolean':
			return true
		case 'string':
			let idx = Chunk.indexOf(Cond)
			return idx < 0 ? false : idx
		case 'function':
			return Cond(Chunk)
		}
		if (Cond instanceof RegExp) return Chunk.match(Cond)
		if (Array.isArray(Cond)) {
			for (let item of Cond) {
				let r = Rule.checkEnclosure(Chunk, item)
				if (r) return r
			}
		}
		return false
	}

	/**
	 * Sets an event handler
	 * @param {string} Ev
	 * The event identifier
	 * ###### Available Events:
	 * + `'start'`: Occurs when the current chunk matched with {@link Rule#from}
	 * + `'active'`: While this rule is active, occurs every time the parser reached at {@link Rule#splitter}
	 * + `'end'`: Occurs when the current chunk matched with {@link Rule#to}
	 * + `'outline'`: Occurs when {@link Context#outline} is called. **Debug purpose only**
	 * @param {function} Fn
	 * The event handler.
	 * Returning `false` makes the parser read the current chunk again
	 * ###### Parameters:
	 * @param {Context} Fn.cx The current context
	 * @param {string} Fn.chunk
	 * The current chunk.<br>
	 * **Only for `start`, `active` and `end`**
	 * @param {number|string[]} Fn.matches
	 * The matching result of {@link Rule#from}/{@link Rule#to}.<br>
	 * **Only for `start` and `end` events**
	 */
	on(Ev, Fn) {
		switch (Ev) {
		case 'start':
		case 'active':
		case 'end':
		case 'outline':
			this.set('_on' + Ev[0].toUpperCase() + Ev.slice(1), Fn)
			break
		default:
			throw new Error('No such event')
		}
	}

	/**
	 * Initializes a context
	 * @param {Context} Cx The context to initialize
	 * @param {string} Chunk='' The chunk that matched with the `start` condition
	 * @param {string[]} MatchResult=null The matching result of the `start` condition
	 * @return {boolean}
	 * The result of `init` callback.
	 * If `init` is not specified, `true` will be returned
	 */
	init(Cx, Chunk = '', MatchResult = null) {
		let r = true // Goes next chunk
		if (this.onStart) {
			r = this.onStart(Cx, Chunk, MatchResult)
			if (typeof r == 'undefined') r = true
		}
		return r
	}

	/**
	 * Finalizes a context
	 * @param {Context} Cx The context to finalize
	 * @param {string} Chunk='' The chunk that matched with the `end` condition
	 * @param {string[]} MatchResult=null The maching result of the `end` condition
	 * @return {boolean}
	 * Result of `fin` callback.
	 * If `fin` is not specified, `true` will be returned
	 */
	fin(Cx, Chunk = '', MatchResult = null) {
		let r = true // Goes next chunk
		if (this.onEnd) {
			r = this.onEnd(Cx, Chunk, MatchResult)
			if (typeof r == 'undefined') r = true
		}
		return r
	}

	/**
	 * Parses a chunk
	 * @param {Context} Cx The current context
	 * @param {string} Chunk='' The chunk to parse
	 * @return {boolean}
	 * The result of `parse` callback.
	 * If `parse` is not specified, `true` will be returned
	 */
	parse(Cx, Chunk = '') {
		let r = true // Goes next chunk
		if (this.onActive) {
			r = this.onActive(Cx, Chunk)
			if (typeof r == 'undefined') r = true
		}
		return r
	}

	/**
	 * Expresses a context. **Debug purpose only.**
	 * @param {Context} Cx The context to express
	 * @return {string}
	 */
	express(Cx) {
		return this.onOutline ? this.onOutline(Cx) : (this.name || 'anonymous')
	}
}

export default Rule