/* Parser for conditional comments */ import evalExpr = require('./eval-expr') import getExpr = require('./get-expr') import R = require('./regexes') interface StateInfo { state: State block: Block } type IfDirective = 'if' | 'ifset' | 'ifnset' // branch type const enum Block { NONE, IF, ELSE, } // status const enum State { WORKING, TESTING, ENDING, } // Want this to check endif scope const ENDIF_MASK = Block.IF | Block.ELSE /** * Matches a line with a directive without its line-ending because it can be * at the end of the file with no last EOL. * * $1: Directive without the '#' ('if', 'elif', 'else', etc) * $2: Possible expression (can be empty or have a comment) */ const S_RE_BASE = /^[ \t\f\v]*(?:@)#(if|ifn?set|elif|else|endif|set|unset|error)(?:(?=[ \t])(.*)|\/\/.*)?$/.source /** * Conditional comments parser * * @param {object} props - The global options */ class Parser { private _cc = [{ block: Block.NONE, state: State.WORKING, }] constructor (private options: JsccProps) { } /** * Returns a regex that matches lines with directives through all the buffer. * * @returns {RegExp} regex with the flags `global` and `multiline` */ public getRegex () { return RegExp(S_RE_BASE.replace('@', this.options.prefixes), 'gm') } /** * Parses conditional comments to determinate if we need disable the output. * * @param {Array} match - Object with the key/value of the directive * @returns {boolean} Output state, `false` to hide the output. */ public parse (match: RegExpExecArray) { const key = match[1] const expr = this._normalize(key, match[2]) let ccInfo = this._cc[this._cc.length - 1] switch (key) { // #if* pushes WORKING or TESTING, unless the state is ENDING case 'if': case 'ifset': case 'ifnset': ccInfo = this._pushState(ccInfo, key, expr) break case 'elif': case 'else': // #elif swap the state, unless it is ENDING // #else set the state to WORKING or ENDING this._handleElses(ccInfo, key, expr) break case 'endif': // #endif pops the state ccInfo = this._popState(ccInfo, key) break default: // #set #unset #error is processed for working blocks only this._handleInstruction(key, expr, ccInfo.state) } return ccInfo.state === State.WORKING } /** * Check unclosed blocks before vanish. * * @returns {boolean} `true` if no error. */ public close () { const cc = this._cc const err = cc.length !== 1 || cc[0].state !== State.WORKING if (err) { this._emitError('Unexpected end of file') } } /** * Internal error handler. * This wrap a call to `options.errorHandler` that throws an exception. * * _NOTE:_ Sending `Error` enhances coverage of errorHandler that must * be prepared to receive Error objects in addition to strings. * * @param {string} message - Description of the error */ private _emitError (message: string) { this.options.errorHandler(new Error(message)) } /** * Retrieve the required expression with the jscc comment removed. * It is necessary to skip quoted strings and avoid truncation * of expressions like "file:///path" * * @param {string} key The key name * @param {string} expr The extracted expression * @returns {string} Normalized expression. */ private _normalize (key: string, expr: string) { // anything after `#else/#endif` is ignored if (key === 'else' || key === 'endif') { return '' } // ...other keywords must have an expression if (!expr) { this._emitError(`Expression expected for #${key}`) } // get a normalized expression return getExpr(key, expr) } /** * Throws if the current block is not of the expected type. */ private _checkBlock (ccInfo: StateInfo, key: string) { const block = ccInfo.block const mask = key === 'endif' ? ENDIF_MASK : Block.IF if (block === Block.NONE || block !== (block & mask)) { this._emitError(`Unexpected #${key}`) } } /** * Push a `#if`, `#ifset`, or `#ifnset` directive */ private _pushState (ccInfo: StateInfo, key: IfDirective, expr: string) { ccInfo = { block: Block.IF, state: ccInfo.state === State.ENDING ? State.ENDING : this._getIfValue(key, expr) ? State.WORKING : State.TESTING, } this._cc.push(ccInfo) return ccInfo } /** * Handles `#elif` and `#else` directives. */ private _handleElses (ccInfo: StateInfo, key: string, expr: string) { this._checkBlock(ccInfo, key) if (key === 'else') { ccInfo.block = Block.ELSE ccInfo.state = ccInfo.state === State.TESTING ? State.WORKING : State.ENDING } else if (ccInfo.state === State.WORKING) { ccInfo.state = State.ENDING } else if (ccInfo.state === State.TESTING && this._getIfValue('if', expr)) { ccInfo.state = State.WORKING } } /** * Pop the if, ifset, or ifnset directives after endif. */ private _popState (ccInfo: StateInfo, key: string) { this._checkBlock(ccInfo, key) const cc = this._cc cc.pop() return cc[cc.length - 1] } /** * Handles an instruction that change a varname or emit an error * (currenty #set, #unset, and #error). * * @param expr Normalized expression */ private _handleInstruction (key: string, expr: string, state: State) { if (state === State.WORKING) { switch (key) { case 'set': this._set(expr) break case 'unset': this._unset(expr) break case 'error': expr = String(evalExpr(this.options, expr)) this._emitError(expr) } } } /** * Evaluates an expression and add the result to the `values` property. * * @param expr Expression normalized in the "varname=value" format */ private _set (expr: string) { const match = expr.match(R.ASSIGNMENT) if (match) { const varname = match[1] const exprStr = match[2] || '' this.options.values[varname] = exprStr ? evalExpr(this.options, exprStr.trim()) : undefined } else { this._emitError(`Invalid memvar name or assignment: ${expr}`) } } /** * Remove the definition of a variable. * * @param varname Variable name */ private _unset (varname: string) { if (varname.match(R.VARNAME)) { delete this.options.values[varname] } else { this._emitError(`Invalid memvar name "${varname}"`) } } /** * Evaluates the expression of a `#if`, `#ifset`, or `#ifnset` directive. * * For `#ifset` and #ifnset, the value is evaluated here, * For `#if`, it calls `evalExpr`. * * @param key The key name * @param expr The extracted expression * @returns Evaluated expression. */ private _getIfValue (key: IfDirective, expr: string) { // Returns the raw value for #if expressions if (key === 'if') { return evalExpr(this.options, expr) ? 1 : 0 } // Returns a boolean-like number for ifdef/ifndef let yes = expr in this.options.values ? 1 : 0 if (key === 'ifnset') { yes ^= 1 // invert } return yes } } export = Parser