Source: Obj.js

'use strict'

var _eval = require('../_eval'),
	ParseError = require('./ParseError'),
	Mixin = require('./Mixin')

/**
 * @class
 * @param {number} sourceLine
 */
function Obj(sourceLine) {
	/** @param {string[]} */
	this.lines = []
	/**
	 * @member {Object}
	 * @property {number} begin
	 * @property {number} end
	 */
	this.source = {
		begin: sourceLine,
		end: sourceLine
	}
	/** @member {boolean} */
	this.parsed = false
	/** @member {Object|Array|string|Mixin} */
	this.value = null
}

/**
 * Add one more line as the source of the Obj
 * @param {string} line
 * @throws if already parsed
 */
Obj.prototype.push = function (line) {
	if (this.parsed) {
		throw new Error('Can\t push, Obj already parsed')
	}
	if (line.trim() === '' && !this.lines.length) {
		// Ignore the first blank line
		this.source.begin++
		return
	}
	this.lines.push(line)
	this.source.end++
}

/**
 * Parses the object content and make it ready to execute
 * @returns {Obj} itself
 */
Obj.prototype.parse = function () {
	if (!this.parsed) {
		if (!(this._parseArray() ||
			this._parseObject() ||
			this._parseMixin() ||
			this._parseJS())) {
			throw new ParseError('Invalid syntax', this)
		}
		this.parsed = true

		// Remove now useless data
		delete this.lines
		delete this.source
	}
	return this
}

/**
 * Execute and return the result for the parsed Obj
 * @param {Object} context
 * @param {string} name A string like '<' + description + '>' to be part of a thrown execption
 * @returns {*}
 * @throws if not parsed
 */
Obj.prototype.execute = function (context, name) {
	var r, key

	if (!this.parsed) {
		throw new Error('Can\'t execute, Obj not parsed')
	}

	if (Array.isArray(this.value)) {
		return this.value.map(function (each, i) {
			return each.execute(context, name + '.' + i)
		})
	} else if (typeof this.value === 'string') {
		return _eval(this.value, context, name)
	} else if (this.value instanceof Mixin) {
		return this.value.execute(context, name)
	} else {
		r = Object.create(null)
		for (key in this.value) {
			r[key] = this.value[key].execute(context, name + '.' + key)
		}
		return r
	}
}

/**
 * Get an empty Obj already parsed
 * This obj, when parsed, will give `{}`
 * @returns {Obj}
 */
Obj.empty = function () {
	var obj = new Obj(0)
	obj.push('({})')
	return obj.parse()
}

module.exports = Obj

/**
 * @returns {boolean} false if it's probably not an array
 * @throws {ParseError} if invalid syntax
 * @private
 */
Obj.prototype._parseArray = function () {
	var i, line, obj

	if (!this.lines.length || this.lines[0][0] !== '*') {
		// An array must start with a '*'
		return false
	}

	// Split each array element
	this.value = []
	for (i = 0; i < this.lines.length; i++) {
		line = this.lines[i]
		if (line[0] === '*') {
			// A new element
			if (line[1] !== '\t') {
				throw new ParseError('Expected a "\t" after "*"', this)
			}
			if (obj) {
				this.value.push(obj.parse())
			}
			obj = new Obj(this.source.begin + i)
			obj.push(line.substr(2))
		} else if (line[0] === '\t') {
			// Last obj continuation
			obj.push(line.substr(1))
		} else {
			throw new ParseError('Expected either a "*" or "\t"', this)
		}
	}
	this.value.push(obj.parse())
	return true
}

/**
 * @returns {boolean} false if it's probably not an object
 * @throws {ParseError} if invalid syntax
 * @private
 */
Obj.prototype._parseObject = function () {
	var i, line, obj, match, key

	if (!this.lines.length || !this.lines[0].match(/^[a-z$_][a-z0-9$_]*:/i)) {
		// An object must start with '_key_:'
		return false
	}

	// Split each object key element
	this.value = Object.create(null)
	for (i = 0; i < this.lines.length; i++) {
		line = this.lines[i]
		if ((match = line.match(/^([a-z$_][a-z0-9$_]*):/i))) {
			// A new key
			if (obj) {
				this.value[key] = obj.parse()
			}
			key = match[1]
			obj = new Obj(this.source.begin + i)
			obj.push(line.substr(key.length + 1).trim())
		} else if (line[0] === '\t') {
			// Last obj continuation
			obj.push(line.substr(1))
		} else {
			throw new ParseError('Expected either "_key_:" or "\t"', this)
		}
	}
	this.value[key] = obj.parse()
	return true
}

/**
 * @returns {boolean} false if it's probably not a mixin
 * @throws {ParseError} if invalid syntax
 * @private
 */
Obj.prototype._parseMixin = function () {
	var path, str, pos, value

	if (this.lines.length !== 1 ||
		!(path = readPath(this.lines[0])) ||
		!path.newStr.match(/^with(out)? /)) {
		// Must start with a path followed by 'with' or 'without'
		return false
	}

	// Base object
	this.value = new Mixin
	this.value.base = path.parts
	str = path.newStr

	// Without
	if (str.indexOf('without ') === 0) {
		str = eat(str, 7)
		while ((path = readPath(str))) {
			this.value.removals.push(path.parts)
			str = path.newStr

			if (!str || str[0] === ';') {
				// End of 'without' list
				str = eat(str, 1)
				break
			} else if (str[0] === ',') {
				str = eat(str, 1)
			} else {
				throw new ParseError('Expected either ";" or "," after path ' + path.name, this)
			}
		}
	}

	// With
	while (str.indexOf('with ') === 0) {
		str = eat(str, 4)
		path = readPath(str)
		if (!path) {
			throw new ParseError('Expected a path after "with"', this)
		}
		str = path.newStr
		pos = str.indexOf(';')
		value = pos === -1 ? str : str.substr(0, pos)
		if (!value) {
			throw new ParseError('Expected a value for path ' + path.name, this)
		}
		this.value.additions.push({
			path: path.parts,
			value: value
		})
		str = pos === -1 ? '' : eat(str, pos + 1)
	}

	if (str) {
		throw new ParseError('Could not parse as mixin: "' + str + '"', this)
	}

	return true
}

/**
 * Try to extract a path from the beginning of the string
 * @param {string} str
 * @returns {Object} with keys 'path' and 'newStr' or null if no path could be read
 */
function readPath(str) {
	var match, parts
	match = str.match(/^(([0-9]+|[a-zA-Z$_][a-zA-Z0-9$_]*)(\.([0-9]+|[a-zA-Z$_][a-zA-Z0-9$_]*))*)/)
	if (match) {
		parts = match[1].split('.').map(function (each) {
			return /^[0-9]+$/.test(each) ? Number(each) : each
		})
		return {
			name: match[1],
			parts: parts,
			newStr: eat(str, match[1].length)
		}
	}
}

/**
 * Remove `n` chars and blank chars from the beginning of the string
 * @param {string} str
 * @param {number} n
 * @returns {string}
 */
function eat(str, n) {
	return str.substr(n).trimLeft()
}

/**
 * @returns {boolean} false if it's probably not a JS expression
 * @throws {ParseError} if invalid syntax
 * @private
 */
Obj.prototype._parseJS = function () {
	if (this.lines.length !== 1) {
		return false
	}
	this.value = this.lines[0]
	return true
}