diff --git a/lib/exception.js b/lib/exception.js deleted file mode 100644 index b2fdc00..0000000 --- a/lib/exception.js +++ /dev/null @@ -1,68 +0,0 @@ -const errorProps = [ - 'description', - 'fileName', - 'lineNumber', - 'endLineNumber', - 'message', - 'name', - 'number', - 'stack' -]; - -function Exception(message, node) { - let loc = node && node.loc, - line, - endLineNumber, - column, - endColumn; - - if (loc) { - line = loc.start.line; - endLineNumber = loc.end.line; - column = loc.start.column; - endColumn = loc.end.column; - - message += ' - ' + line + ':' + column; - } - - let tmp = Error.prototype.constructor.call(this, message); - - // Unfortunately errors are not enumerable in Chrome (at least), so `for prop in tmp` doesn't work. - for (let idx = 0; idx < errorProps.length; idx++) { - this[errorProps[idx]] = tmp[errorProps[idx]]; - } - - /* istanbul ignore else */ - if (Error.captureStackTrace) { - Error.captureStackTrace(this, Exception); - } - - try { - if (loc) { - this.lineNumber = line; - this.endLineNumber = endLineNumber; - - // Work around issue under safari where we can't directly set the column value - /* istanbul ignore next */ - if (Object.defineProperty) { - Object.defineProperty(this, 'column', { - value: column, - enumerable: true - }); - Object.defineProperty(this, 'endColumn', { - value: endColumn, - enumerable: true - }); - } else { - this.column = column; - this.endColumn = endColumn; - } - } - } catch (nop) { - /* Ignore if the browser is very particular */ - } -} - -Exception.prototype = new Error(); - -export default Exception; diff --git a/lib/exception.ts b/lib/exception.ts new file mode 100644 index 0000000..12b887c --- /dev/null +++ b/lib/exception.ts @@ -0,0 +1,67 @@ +import type { HasLocation } from './types/ast.js'; + +export default class Exception extends Error { + readonly lineNumber: number | undefined; + readonly endLineNumber: number | undefined; + readonly column: number | undefined; + readonly endColumn: number | undefined; + + readonly description: string | undefined; + + constructor(message: string, node?: HasLocation) { + const loc = node?.loc; + let line; + let endLineNumber; + let column; + let endColumn; + + if (loc) { + line = loc.start.line; + endLineNumber = loc.end.line; + column = loc.start.column; + endColumn = loc.end.column; + + message += ' - ' + line + ':' + column; + } + + super(message); + + /* istanbul ignore else */ + if (hasCaptureStackTrace(Error)) { + Error.captureStackTrace(this, Exception); + } + + try { + if (loc) { + this.lineNumber = line; + this.endLineNumber = endLineNumber; + + // Work around issue under safari where we can't directly set the column value + /* istanbul ignore next */ + if (Object.defineProperty) { + Object.defineProperty(this, 'column', { + value: column, + enumerable: true, + }); + Object.defineProperty(this, 'endColumn', { + value: endColumn, + enumerable: true, + }); + } else { + this.column = column; + this.endColumn = endColumn; + } + } + } catch (nop) { + /* Ignore if the browser is very particular */ + } + } +} + +type CapturableError = typeof Error & { + captureStackTrace: (error: Error, constructor: Function) => void; +}; + +function hasCaptureStackTrace(error: typeof Error): error is CapturableError { + return 'captureStackTrace' in error; +} diff --git a/lib/helpers.js b/lib/helpers.js deleted file mode 100644 index ef567e2..0000000 --- a/lib/helpers.js +++ /dev/null @@ -1,237 +0,0 @@ -import Exception from './exception.js'; - -function validateClose(open, close) { - close = close.path ? close.path.original : close; - - if (open.path.original !== close) { - let errorNode = { loc: open.path.loc }; - - throw new Exception( - open.path.original + " doesn't match " + close, - errorNode - ); - } -} - -export function SourceLocation(source, locInfo) { - this.source = source; - this.start = { - line: locInfo.first_line, - column: locInfo.first_column, - }; - this.end = { - line: locInfo.last_line, - column: locInfo.last_column, - }; -} - -export function id(token) { - if (/^\[.*\]$/.test(token)) { - return token.substring(1, token.length - 1); - } else { - return token; - } -} - -export function stripFlags(open, close) { - return { - open: open.charAt(2) === '~', - close: close.charAt(close.length - 3) === '~', - }; -} - -export function stripComment(comment) { - return comment.replace(/^\{\{~?!-?-?/, '').replace(/-?-?~?\}\}$/, ''); -} - -export function preparePath(data, sexpr, parts, loc) { - loc = this.locInfo(loc); - - let original; - - if (data) { - original = '@'; - } else if (sexpr) { - original = sexpr.original + '.'; - } else { - original = ''; - } - - let tail = []; - let depth = 0; - - for (let i = 0, l = parts.length; i < l; i++) { - let part = parts[i].part; - // If we have [] syntax then we do not treat path references as operators, - // i.e. foo.[this] resolves to approximately context.foo['this'] - let isLiteral = parts[i].original !== part; - let separator = parts[i].separator; - - let partPrefix = separator === '.#' ? '#' : ''; - - original += (separator || '') + part; - - if (!isLiteral && (part === '..' || part === '.' || part === 'this')) { - if (tail.length > 0) { - throw new Exception('Invalid path: ' + original, { loc }); - } else if (part === '..') { - depth++; - } - } else { - tail.push(`${partPrefix}${part}`); - } - } - - let head = sexpr || tail.shift(); - - return { - type: 'PathExpression', - this: original.startsWith('this.'), - data, - depth, - head, - tail, - parts: head ? [head, ...tail] : tail, - original, - loc, - }; -} - -export function prepareMustache(path, params, hash, open, strip, locInfo) { - // Must use charAt to support IE pre-10 - let escapeFlag = open.charAt(3) || open.charAt(2), - escaped = escapeFlag !== '{' && escapeFlag !== '&'; - - let decorator = /\*/.test(open); - return { - type: decorator ? 'Decorator' : 'MustacheStatement', - path, - params, - hash, - escaped, - strip, - loc: this.locInfo(locInfo), - }; -} - -export function prepareRawBlock(openRawBlock, contents, close, locInfo) { - validateClose(openRawBlock, close); - - locInfo = this.locInfo(locInfo); - let program = { - type: 'Program', - body: contents, - strip: {}, - loc: locInfo, - }; - - return { - type: 'BlockStatement', - path: openRawBlock.path, - params: openRawBlock.params, - hash: openRawBlock.hash, - program, - openStrip: {}, - inverseStrip: {}, - closeStrip: {}, - loc: locInfo, - }; -} - -export function prepareBlock( - openBlock, - program, - inverseAndProgram, - close, - inverted, - locInfo -) { - if (close && close.path) { - validateClose(openBlock, close); - } - - let decorator = /\*/.test(openBlock.open); - - program.blockParams = openBlock.blockParams; - - let inverse, inverseStrip; - - if (inverseAndProgram) { - if (decorator) { - throw new Exception( - 'Unexpected inverse block on decorator', - inverseAndProgram - ); - } - - if (inverseAndProgram.chain) { - inverseAndProgram.program.body[0].closeStrip = close.strip; - } - - inverseStrip = inverseAndProgram.strip; - inverse = inverseAndProgram.program; - } - - if (inverted) { - inverted = inverse; - inverse = program; - program = inverted; - } - - return { - type: decorator ? 'DecoratorBlock' : 'BlockStatement', - path: openBlock.path, - params: openBlock.params, - hash: openBlock.hash, - program, - inverse, - openStrip: openBlock.strip, - inverseStrip, - closeStrip: close && close.strip, - loc: this.locInfo(locInfo), - }; -} - -export function prepareProgram(statements, loc) { - if (!loc && statements.length) { - const firstLoc = statements[0].loc, - lastLoc = statements[statements.length - 1].loc; - - /* istanbul ignore else */ - if (firstLoc && lastLoc) { - loc = { - source: firstLoc.source, - start: { - line: firstLoc.start.line, - column: firstLoc.start.column, - }, - end: { - line: lastLoc.end.line, - column: lastLoc.end.column, - }, - }; - } - } - - return { - type: 'Program', - body: statements, - strip: {}, - loc: loc, - }; -} - -export function preparePartialBlock(open, program, close, locInfo) { - validateClose(open, close); - - return { - type: 'PartialBlockStatement', - name: open.path, - params: open.params, - hash: open.hash, - program, - openStrip: open.strip, - closeStrip: close && close.strip, - loc: this.locInfo(locInfo), - }; -} diff --git a/lib/helpers.ts b/lib/helpers.ts new file mode 100644 index 0000000..4d68f69 --- /dev/null +++ b/lib/helpers.ts @@ -0,0 +1,348 @@ +import Exception from './exception.js'; +import type { + CloseBlock, + InverseChain, + LocInfo, + OpenBlock, + OpenPartialBlock, + OpenRawBlock, + Part, + SourcePosition, +} from './types/types.js'; +import type * as ast from './types/ast.js'; +import { assert } from './utils.js'; +import type { ParseOptions, SyntaxOptions } from './parse.js'; + +export class ParserHelpers { + #options: ParseOptions; + readonly syntax: SyntaxOptions; + + constructor(options: ParseOptions) { + this.#options = options; + + let squareSyntax: SyntaxOptions['square']; + + if (typeof options.syntax?.square === 'function') { + squareSyntax = options.syntax.square; + } else if (options?.syntax?.square === 'node') { + squareSyntax = arrayLiteralNode; + } else { + squareSyntax = 'string'; + } + + let hashSyntax; + + if (typeof options?.syntax?.hash === 'function') { + hashSyntax = options.syntax.hash; + } else { + hashSyntax = hashLiteralNode; + } + + this.syntax = { + square: squareSyntax, + hash: hashSyntax, + }; + } + + locInfo = (locInfo: LocInfo) => { + return new SourceLocation(this.#options.srcName, locInfo); + }; + + id = (token: string) => { + if (/^\[.*\]$/.test(token)) { + return token.substring(1, token.length - 1); + } else { + return token; + } + }; + + stripFlags = (open: string, close: string) => { + return { + open: open.charAt(2) === '~', + close: close.charAt(close.length - 3) === '~', + }; + }; + + stripComment = (comment: string) => { + return comment.replace(/^\{\{~?!-?-?/, '').replace(/-?-?~?\}\}$/, ''); + }; + + preparePath = ( + data: boolean, + sexpr: ast.PathExpression | false, + parts: Part[], + locInfo: LocInfo + ) => { + const loc = this.locInfo(locInfo); + + let original; + + if (data) { + original = '@'; + } else if (sexpr) { + original = sexpr.original + '.'; + } else { + original = ''; + } + + let tail = []; + let depth = 0; + + for (let i = 0, l = parts.length; i < l; i++) { + let part = parts[i].part; + // If we have [] syntax then we do not treat path references as operators, + // i.e. foo.[this] resolves to approximately context.foo['this'] + let isLiteral = parts[i].original !== part; + let separator = parts[i].separator; + + let partPrefix = separator === '.#' ? '#' : ''; + + original += (separator || '') + part; + + if (!isLiteral && (part === '..' || part === '.' || part === 'this')) { + if (tail.length > 0) { + throw new Exception('Invalid path: ' + original, { loc }); + } else if (part === '..') { + depth++; + } + } else { + tail.push(`${partPrefix}${part}`); + } + } + + let head = sexpr || tail.shift(); + + return { + type: 'PathExpression', + this: original.startsWith('this.'), + data, + depth, + head, + tail, + parts: head ? [head, ...tail] : tail, + original, + loc, + }; + }; + + prepareMustache = ( + path: ast.PathExpression, + params: ast.Expr[], + hash: ast.Hash, + openToken: string, + strip: ast.StripFlags, + locInfo: LocInfo + ) => { + // Must use charAt to support IE pre-10 + let escapeFlag = openToken.charAt(3) || openToken.charAt(2), + escaped = escapeFlag !== '{' && escapeFlag !== '&'; + + let decorator = /\*/.test(openToken); + return { + type: decorator ? 'Decorator' : 'MustacheStatement', + path, + params, + hash, + escaped, + strip, + loc: this.locInfo(locInfo), + }; + }; + + prepareRawBlock = ( + openRawBlock: OpenRawBlock, + contents: ast.Statement[], + closeToken: string, + locInfo: LocInfo + ): ast.BlockStatement => { + validateClose(openRawBlock, closeToken); + + const loc = this.locInfo(locInfo); + let program: ast.Program = { + type: 'Program', + body: contents, + strip: {}, + loc, + }; + + assert(openRawBlock.path.type === 'PathExpression', 'Mustache path'); + + return { + type: 'BlockStatement', + path: openRawBlock.path, + params: openRawBlock.params, + hash: openRawBlock.hash, + program, + openStrip: {}, + inverseStrip: {}, + closeStrip: {}, + loc, + }; + }; + + prepareBlock = ( + openBlock: OpenBlock, + program: ast.Program, + inverseAndProgram: InverseChain, + close: CloseBlock, + inverted: boolean, + locInfo: LocInfo + ) => { + if (close && close.path) { + validateClose(openBlock, close); + } + + let decorator = /\*/.test(openBlock.open); + + program.blockParams = openBlock.blockParams; + + let inverse, inverseStrip; + + if (inverseAndProgram) { + if (decorator) { + throw new Exception( + 'Unexpected inverse block on decorator', + inverseAndProgram + ); + } + + if (inverseAndProgram.chain) { + const first = inverseAndProgram.program.body[0]; + + assert( + first.type === 'BlockStatement', + `BUG: the first statement after an 'else' chain must be a block statement. This should be enforced by the parser and this error should never occur.` + ); + + if (first.type === 'BlockStatement') { + first.closeStrip = close.strip; + } + } + + inverseStrip = inverseAndProgram.strip; + inverse = inverseAndProgram.program; + } + + if (inverted) { + const initialInverse = inverse as ast.Program; + inverse = program; + program = initialInverse; + } + + return { + type: decorator ? 'DecoratorBlock' : 'BlockStatement', + path: openBlock.path, + params: openBlock.params, + hash: openBlock.hash, + program, + inverse, + openStrip: openBlock.strip, + inverseStrip, + closeStrip: close && close.strip, + loc: this.locInfo(locInfo), + }; + }; + + prepareProgram = (statements: ast.Statement[], loc?: ast.SourceLocation) => { + if (!loc && statements.length) { + const firstLoc = statements[0].loc; + const lastLoc = statements[statements.length - 1].loc; + + if (firstLoc === lastLoc) { + loc = firstLoc; + } else { + loc = new SourceLocation(firstLoc.source, { + first_line: firstLoc.start.line, + first_column: firstLoc.start.column, + last_line: lastLoc.end.line, + last_column: lastLoc.end.column, + }); + } + } + + return { + type: 'Program', + body: statements, + strip: {}, + loc, + }; + }; + + preparePartialBlock = ( + open: OpenPartialBlock, + program: ast.Program, + close: CloseBlock, + locInfo: LocInfo + ) => { + validateClose(open, close); + + return { + type: 'PartialBlockStatement', + name: open.path, + params: open.params, + hash: open.hash, + program, + openStrip: open.strip, + closeStrip: close && close.strip, + loc: this.locInfo(locInfo), + }; + }; +} + +function validateClose( + open: OpenBlock | OpenRawBlock | OpenPartialBlock, + close: CloseBlock | string +) { + const closeString = typeof close === 'string' ? close : close.path.original; + + if (open.path.type !== 'PathExpression') { + throw new Exception(`Unexpected block open (expected a path)`, open.path); + } + + if (open.path.original !== closeString) { + throw new Exception( + `${open.path.original} doesn't match ${closeString}`, + open.path + ); + } +} + +export class SourceLocation implements ast.SourceLocation { + source: string | undefined; + start: SourcePosition; + end: SourcePosition; + + constructor(source: string | undefined, locInfo: LocInfo) { + this.source = source; + this.start = { + line: locInfo.first_line, + column: locInfo.first_column, + }; + this.end = { + line: locInfo.last_line, + column: locInfo.last_column, + }; + } +} + +function arrayLiteralNode( + array: ast.Expr[], + loc: ast.SourceLocation +): ast.ArrayLiteral { + return { + type: 'ArrayLiteral', + items: array, + loc, + }; +} + +function hashLiteralNode( + hash: ast.Hash, + loc: ast.SourceLocation +): ast.HashLiteral { + return { + type: 'HashLiteral', + pairs: hash.pairs, + loc, + }; +} diff --git a/lib/index.js b/lib/index.ts similarity index 100% rename from lib/index.js rename to lib/index.ts diff --git a/lib/parse.js b/lib/parse.js deleted file mode 100644 index 9927b5f..0000000 --- a/lib/parse.js +++ /dev/null @@ -1,73 +0,0 @@ -import parser from './parser.js'; -import WhitespaceControl from './whitespace-control.js'; -import * as Helpers from './helpers.js'; - -let baseHelpers = {}; - -for (let helper in Helpers) { - if (Object.prototype.hasOwnProperty.call(Helpers, helper)) { - baseHelpers[helper] = Helpers[helper]; - } -} - -export function parseWithoutProcessing(input, options) { - // Just return if an already-compiled AST was passed in. - if (input.type === 'Program') { - return input; - } - - parser.yy = baseHelpers; - - // Altering the shared object here, but this is ok as parser is a sync operation - parser.yy.locInfo = function (locInfo) { - return new Helpers.SourceLocation(options && options.srcName, locInfo); - }; - - let squareSyntax; - - if (typeof options?.syntax?.square === 'function') { - squareSyntax = options.syntax.square; - } else if (options?.syntax?.square === 'node') { - squareSyntax = arrayLiteralNode; - } else { - squareSyntax = 'string'; - } - - let hashSyntax; - - if (typeof options?.syntax?.hash === 'function') { - hashSyntax = options.syntax.hash; - } else { - hashSyntax = hashLiteralNode; - } - - parser.yy.syntax = { - square: squareSyntax, - hash: hashSyntax, - }; - - return parser.parse(input); -} - -function arrayLiteralNode(array, loc) { - return { - type: 'ArrayLiteral', - items: array, - loc, - }; -} - -function hashLiteralNode(hash, loc) { - return { - type: 'HashLiteral', - pairs: hash.pairs, - loc, - }; -} - -export function parse(input, options) { - let ast = parseWithoutProcessing(input, options); - let strip = new WhitespaceControl(options); - - return strip.accept(ast); -} diff --git a/lib/parse.ts b/lib/parse.ts new file mode 100644 index 0000000..d205111 --- /dev/null +++ b/lib/parse.ts @@ -0,0 +1,61 @@ +import { ParserHelpers } from './helpers.js'; +import PARSER from './parser.js'; +import type * as ast from './types/ast.js'; +import type { BaseNode } from './types/types.js'; +import WhitespaceControl from './whitespace-control.js'; + +const parser = PARSER as { + parse: (input: string) => ast.Program; + yy: ParserHelpers; +}; + +export function parseWithoutProcessing( + input: string | T, + options: ParseOptions = {} +) { + // Just return if an already-compiled AST was passed in. + if (typeof input !== 'string') { + return input; + } + + parser.yy = new ParserHelpers(options); + + return parser.parse(input); +} + +export function parse( + input: string | T, + options: ParseOptions = {} +) { + let ast = parseWithoutProcessing(input, options); + let strip = new WhitespaceControl(options); + + return strip.accept(ast); +} +export interface ParseOptions { + srcName?: string; + ignoreStandalone?: boolean; + syntax?: SyntaxOptions; +} + +export interface SyntaxOptions { + hash?: + | 'node' + | (( + hash: ast.Hash, + loc: ast.SourceLocation, + options: SyntaxFnOptions + ) => BaseNode); + square?: + | 'string' + | 'node' + | (( + params: ast.Expr[], + loc: ast.SourceLocation, + options: SyntaxFnOptions + ) => BaseNode); +} + +export interface SyntaxFnOptions { + yy: ParserHelpers; +} diff --git a/lib/printer.js b/lib/printer.js deleted file mode 100644 index 739acf2..0000000 --- a/lib/printer.js +++ /dev/null @@ -1,211 +0,0 @@ -/* eslint-disable new-cap */ -import Visitor from './visitor.js'; - -export function print(ast) { - return new PrintVisitor().accept(ast); -} - -export function PrintVisitor() { - this.padding = 0; -} - -PrintVisitor.prototype = new Visitor(); - -PrintVisitor.prototype.pad = function (string) { - let out = ''; - - for (let i = 0, l = this.padding; i < l; i++) { - out += ' '; - } - - out += string + '\n'; - return out; -}; - -PrintVisitor.prototype.Program = function (program) { - let out = '', - body = program.body, - i, - l; - - if (program.blockParams) { - let blockParams = 'BLOCK PARAMS: ['; - for (i = 0, l = program.blockParams.length; i < l; i++) { - blockParams += ' ' + program.blockParams[i]; - } - blockParams += ' ]'; - out += this.pad(blockParams); - } - - for (i = 0, l = body.length; i < l; i++) { - out += this.accept(body[i]); - } - - this.padding--; - - return out; -}; - -PrintVisitor.prototype.MustacheStatement = function (mustache) { - if (mustache.params.length > 0 || mustache.hash) { - return this.pad('{{ ' + this.callBody(mustache) + ' }}'); - } else { - return this.pad('{{ ' + this.accept(mustache.path) + ' }}'); - } -}; -PrintVisitor.prototype.Decorator = function (mustache) { - return this.pad('{{ DIRECTIVE ' + this.callBody(mustache) + ' }}'); -}; - -PrintVisitor.prototype.BlockStatement = PrintVisitor.prototype.DecoratorBlock = - function (block) { - let out = ''; - - out += this.pad( - (block.type === 'DecoratorBlock' ? 'DIRECTIVE ' : '') + 'BLOCK:' - ); - this.padding++; - out += this.pad(this.callBody(block)); - if (block.program) { - out += this.pad('PROGRAM:'); - this.padding++; - out += this.accept(block.program); - this.padding--; - } - if (block.inverse) { - if (block.program) { - this.padding++; - } - out += this.pad('{{^}}'); - this.padding++; - out += this.accept(block.inverse); - this.padding--; - if (block.program) { - this.padding--; - } - } - this.padding--; - - return out; - }; - -PrintVisitor.prototype.PartialStatement = function (partial) { - let content = 'PARTIAL:' + partial.name.original; - if (partial.params[0]) { - content += ' ' + this.accept(partial.params[0]); - } - if (partial.hash) { - content += ' ' + this.accept(partial.hash); - } - return this.pad('{{> ' + content + ' }}'); -}; -PrintVisitor.prototype.PartialBlockStatement = function (partial) { - let content = 'PARTIAL BLOCK:' + partial.name.original; - if (partial.params[0]) { - content += ' ' + this.accept(partial.params[0]); - } - if (partial.hash) { - content += ' ' + this.accept(partial.hash); - } - - content += ' ' + this.pad('PROGRAM:'); - this.padding++; - content += this.accept(partial.program); - this.padding--; - - return this.pad('{{> ' + content + ' }}'); -}; - -PrintVisitor.prototype.ContentStatement = function (content) { - return this.pad("CONTENT[ '" + content.value + "' ]"); -}; - -PrintVisitor.prototype.CommentStatement = function (comment) { - return this.pad("{{! '" + comment.value + "' }}"); -}; - -PrintVisitor.prototype.SubExpression = function (sexpr) { - return `(${this.callBody(sexpr)})`; -}; - -PrintVisitor.prototype.callBody = function (callExpr) { - let params = callExpr.params, - paramStrings = [], - hash; - - for (let i = 0, l = params.length; i < l; i++) { - paramStrings.push(this.accept(params[i])); - } - - params = - paramStrings.length === 0 ? '' : ' [' + paramStrings.join(', ') + ']'; - - hash = callExpr.hash ? ' ' + this.accept(callExpr.hash) : ''; - - return this.accept(callExpr.path) + params + hash; -}; - -PrintVisitor.prototype.PathExpression = function (id) { - let head = - typeof id.head === 'string' ? id.head : `[${this.accept(id.head)}]`; - let path = [head, ...id.tail].join('/'); - return 'p%' + prefix(id) + path; -}; - -function prefix(path) { - if (path.data) { - return '@'; - } else if (path.this) { - return 'this.'; - } else { - return ''; - } -} - -PrintVisitor.prototype.StringLiteral = function (string) { - return '"' + string.value + '"'; -}; - -PrintVisitor.prototype.NumberLiteral = function (number) { - return 'n%' + number.value; -}; - -PrintVisitor.prototype.BooleanLiteral = function (bool) { - return 'b%' + bool.value; -}; - -PrintVisitor.prototype.UndefinedLiteral = function () { - return 'UNDEFINED'; -}; - -PrintVisitor.prototype.NullLiteral = function () { - return 'NULL'; -}; - -PrintVisitor.prototype.ArrayLiteral = function (array) { - return `Array[${array.items.map((item) => this.accept(item)).join(', ')}]`; -}; - -PrintVisitor.prototype.HashLiteral = function (hash) { - return `Hash{${this.hashPairs(hash)}}`; -}; - -PrintVisitor.prototype.Hash = function (hash) { - return `HASH{${this.hashPairs(hash)}}`; -}; - -PrintVisitor.prototype.hashPairs = function (hash) { - let pairs = hash.pairs, - joinedPairs = []; - - for (let i = 0, l = pairs.length; i < l; i++) { - joinedPairs.push(this.HashPair(pairs[i])); - } - - return joinedPairs.join(' '); -}; - -PrintVisitor.prototype.HashPair = function (pair) { - return pair.key + '=' + this.accept(pair.value); -}; -/* eslint-enable new-cap */ diff --git a/lib/printer.ts b/lib/printer.ts new file mode 100644 index 0000000..eaeedaf --- /dev/null +++ b/lib/printer.ts @@ -0,0 +1,217 @@ +import Visitor from './visitor.js'; +import type * as ast from './types/ast.js'; + +export function print(ast: ast.VisitableNode) { + return new PrintVisitor().accept(ast); +} + +export class PrintVisitor extends Visitor { + padding = 0; + + pad(string: string) { + let out = ''; + + for (let i = 0, l = this.padding; i < l; i++) { + out += ' '; + } + + out += string + '\n'; + return out; + } + + Program(program: ast.Program) { + let out = '', + body = program.body, + i, + l; + + if (program.blockParams) { + let blockParams = 'BLOCK PARAMS: ['; + for (i = 0, l = program.blockParams.length; i < l; i++) { + blockParams += ' ' + program.blockParams[i]; + } + blockParams += ' ]'; + out += this.pad(blockParams); + } + + for (i = 0, l = body.length; i < l; i++) { + out += this.accept(body[i]); + } + + this.padding--; + + return out; + } + + callNode(callExpr: ast.CallNode) { + let params = callExpr.params; + let paramStrings = []; + + for (let i = 0, l = params.length; i < l; i++) { + paramStrings.push(this.accept(params[i])); + } + + const paramString = + paramStrings.length === 0 ? '' : ' [' + paramStrings.join(', ') + ']'; + + const hashString = callExpr.hash ? ' ' + this.accept(callExpr.hash) : ''; + + return `${this.accept(callExpr.path)}${paramString}${hashString}`; + } + + MustacheStatement(mustache: ast.MustacheStatement) { + if (mustache.params.length > 0 || mustache.hash) { + return this.pad('{{ ' + this.callNode(mustache) + ' }}'); + } else { + return this.pad('{{ ' + this.accept(mustache.path) + ' }}'); + } + } + + Decorator(mustache: ast.Decorator) { + return this.pad('{{ DIRECTIVE ' + this.callNode(mustache) + ' }}'); + } + + BlockStatement(block: ast.BlockStatement): string { + return this.#BlockStatement(block); + } + + DecoratorBlock(block: ast.DecoratorBlock): string { + return this.#BlockStatement(block); + } + + #BlockStatement(block: ast.BlockStatement | ast.DecoratorBlock) { + let out = ''; + + out += this.pad( + (block.type === 'DecoratorBlock' ? 'DIRECTIVE ' : '') + 'BLOCK:' + ); + this.padding++; + out += this.pad(this.callNode(block)); + if (block.program) { + out += this.pad('PROGRAM:'); + this.padding++; + out += this.accept(block.program); + this.padding--; + } + if (block.inverse) { + if (block.program) { + this.padding++; + } + out += this.pad('{{^}}'); + this.padding++; + out += this.accept(block.inverse); + this.padding--; + if (block.program) { + this.padding--; + } + } + this.padding--; + + return out; + } + + PartialStatement(partial: ast.PartialStatement) { + // @ts-expect-error + let content = 'PARTIAL:' + partial.name.original; + if (partial.params[0]) { + content += ' ' + this.accept(partial.params[0]); + } + if (partial.hash) { + content += ' ' + this.accept(partial.hash); + } + return this.pad('{{> ' + content + ' }}'); + } + + PartialBlockStatement(partial: ast.PartialBlockStatement) { + // @ts-expect-error + let content = 'PARTIAL BLOCK:' + partial.name.original; + if (partial.params[0]) { + content += ' ' + this.accept(partial.params[0]); + } + if (partial.hash) { + content += ' ' + this.accept(partial.hash); + } + + content += ' ' + this.pad('PROGRAM:'); + this.padding++; + content += this.accept(partial.program); + this.padding--; + + return this.pad('{{> ' + content + ' }}'); + } + + ContentStatement(content: ast.ContentStatement) { + return this.pad("CONTENT[ '" + content.value + "' ]"); + } + + CommentStatement(comment: ast.CommentStatement) { + return this.pad("{{! '" + comment.value + "' }}"); + } + + SubExpression(subExpression: ast.SubExpression) { + return `(${this.callNode(subExpression)})`; + } + + PathExpression(id: ast.PathExpression) { + let head = + typeof id.head === 'string' ? id.head : `[${this.accept(id.head)}]`; + let path = [head, ...id.tail].join('/'); + return 'p%' + prefix(id) + path; + } + + StringLiteral(string: ast.StringLiteral) { + return '"' + string.value + '"'; + } + + NumberLiteral(number: ast.NumberLiteral) { + return 'n%' + number.value; + } + + BooleanLiteral(bool: ast.BooleanLiteral) { + return 'b%' + bool.value; + } + + UndefinedLiteral() { + return 'UNDEFINED'; + } + + NullLiteral() { + return 'NULL'; + } + + ArrayLiteral(array: ast.ArrayLiteral) { + return `Array[${array.items.map((item) => this.accept(item)).join(', ')}]`; + } + + HashLiteral(hash: ast.HashLiteral) { + return `Hash{${this.hashPairs(hash.pairs)}}`; + } + + Hash(hash: ast.Hash) { + return `HASH{${this.hashPairs(hash.pairs)}}`; + } + + hashPairs(pairs: ast.HashPair[]) { + const joinedPairs = []; + + for (let i = 0, l = pairs.length; i < l; i++) { + joinedPairs.push(this.HashPair(pairs[i])); + } + + return joinedPairs.join(' '); + } + + HashPair(pair: ast.HashPair) { + return pair.key + '=' + this.accept(pair.value); + } +} + +function prefix(path: ast.PathExpression) { + if (path.data) { + return '@'; + } else if (path.this) { + return 'this.'; + } else { + return ''; + } +} diff --git a/lib/types/ast.d.ts b/lib/types/ast.d.ts new file mode 100644 index 0000000..1c8f470 --- /dev/null +++ b/lib/types/ast.d.ts @@ -0,0 +1,228 @@ +export type Literal = CollectionLiteral | PrimitiveLiteral; +export type PrimitiveLiteral = + | StringLiteral + | BooleanLiteral + | NumberLiteral + | UndefinedLiteral + | NullLiteral; +export type Statement = + | MustacheStatement + | Decorator + | BlockStatement + | PartialStatement + | ContentStatement + | CommentStatement; +export type CollectionLiteral = HashLiteral | ArrayLiteral; +export type Expr = SubExpression | PathExpression | Literal; +export type Internal = Hash | HashPair; +export type CallNode = + | SubExpression + | MustacheStatement + | Decorator + | BlockStatement + | DecoratorBlock; + +export type VisitableNode = + | Program + | MustacheStatement + | Decorator + | BlockStatement + | DecoratorBlock + | PartialStatement + | PartialBlockStatement + | ContentStatement + | CommentStatement + | SubExpression + | PathExpression + | Literal + | Internal; + +export type VisitableChildren = { + [P in VisitableNode['type']]: Extract< + VisitableNode, + { type: P } + > extends infer N + ? { + [K in keyof N]: N[K] extends VisitableNode ? K : never; + }[keyof N] & + string + : never; +}; + +export interface HasLocation { + loc: SourceLocation; +} + +export interface Node extends HasLocation { + type: string; +} + +export interface SourceLocation { + source?: string | undefined; + start: Position; + end: Position; +} + +export interface Position { + line: number; + column: number; +} + +export interface Program extends Node { + type: 'Program'; + body: Statement[]; + blockParams?: string[]; + /** @compat */ + strip: {}; + chained?: boolean; +} + +export interface BaseStatement extends Node {} + +export interface MustacheStatement extends BaseStatement, WithArgsNode { + type: 'MustacheStatement'; + path: CollectionLiteral | SubExpression | PathExpression; + escaped: boolean; + strip: StripFlags; +} + +export interface Decorator extends MustacheStatement {} + +export interface BaseBlockStatement extends BaseStatement, WithArgsNode { + /** + * This is very restricted compared to other call nodes + * because the opening path must be repeated as part of + * the block close (i.e. {{#foo}}{{/foo}}). + */ + path: PathExpression; + program: Program; + inverse?: Program; + openStrip: StripFlags; + inverseStrip: StripFlags; + closeStrip: StripFlags; +} + +export interface BlockStatement extends BaseBlockStatement { + type: 'BlockStatement'; +} + +export interface DecoratorBlock extends BaseBlockStatement { + type: 'DecoratorBlock'; +} + +export interface PartialStatement extends BaseStatement, WithArgsNode { + type: 'PartialStatement'; + name: PathExpression | SubExpression; + indent: string; + strip: StripFlags; +} + +export interface PartialBlockStatement extends BaseBlockStatement { + type: 'PartialBlockStatement'; + name: PathExpression | SubExpression; + program: Program; + openStrip: StripFlags; + closeStrip: StripFlags; +} + +export interface ContentStatement extends BaseStatement { + type: 'ContentStatement'; + value: string; + original: string; + rightStripped?: boolean; + leftStripped?: boolean; +} + +export interface CommentStatement extends BaseStatement { + type: 'CommentStatement'; + value: string; + strip: StripFlags; +} + +export interface BaseExpression extends Node {} + +export interface SubExpression extends BaseExpression, WithArgsNode { + type: 'SubExpression'; + path: CollectionLiteral | SubExpression | PathExpression; +} + +export interface PathExpression extends BaseExpression { + type: 'PathExpression'; + data: boolean; + this: boolean; + depth: number; + parts: (string | SubExpression)[]; + head: SubExpression | string; + tail: string[]; + original: string; +} + +export interface BasePrimitiveLiteral extends BaseExpression {} + +export interface StringLiteral extends BasePrimitiveLiteral { + type: 'StringLiteral'; + value: string; + original: string; +} + +export interface BooleanLiteral extends BasePrimitiveLiteral { + type: 'BooleanLiteral'; + value: boolean; + original: boolean; +} + +export interface NumberLiteral extends BasePrimitiveLiteral { + type: 'NumberLiteral'; + value: number; + original: number; +} + +export interface UndefinedLiteral extends BasePrimitiveLiteral { + type: 'UndefinedLiteral'; +} + +export interface NullLiteral extends BasePrimitiveLiteral { + type: 'NullLiteral'; +} + +export interface Hash extends Node { + type: 'Hash'; + pairs: HashPair[]; +} + +export interface BaseCollectionLiteral extends BaseExpression {} + +export interface HashLiteral extends BaseCollectionLiteral { + type: 'HashLiteral'; + pairs: HashPair[]; +} + +export interface ArrayLiteral extends BaseCollectionLiteral { + type: 'ArrayLiteral'; + items: Expr[]; +} + +export interface HashPair extends Node { + type: 'HashPair'; + key: string; + value: Expr; +} + +export interface StripFlags { + open?: boolean; + close?: boolean; + openStandalone?: boolean; + closeStandalone?: boolean; + inlineStandalone?: boolean; +} + +export interface WithArgsNode { + params: Expr[]; + hash: Hash; +} + +export interface helpers { + helperExpression(node: Node): boolean; + scopeId(path: PathExpression): boolean; + simpleId(path: PathExpression): boolean; +} diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts new file mode 100644 index 0000000..2eafe56 --- /dev/null +++ b/lib/types/types.d.ts @@ -0,0 +1,240 @@ +import * as ast from './ast.js'; + +export type BaseNode = ast.Node; + +export interface InverseChain extends ast.HasLocation { + strip: StripFlags; + program: Program; + chain?: boolean; +} + +export interface Part { + part: string; + original: string; + separator?: string; +} + +export interface Program { + type: 'Program'; + /** + * The root node of a program has no `loc` if it's empty. + */ + loc: SourceLocation | undefined; + blockParams?: string[]; + body: Statement[]; + chained?: boolean; + strip: StripFlags; +} + +export interface CommentStatement extends BaseNode { + type: 'CommentStatement'; + value: string; + strip: StripFlags; +} + +export interface PartialStatement extends BaseNode { + type: 'PartialStatement'; + name: Expression; + params: Expression[]; + hash: Hash; + indent: string; + strip: StripFlags; +} + +export interface BlockStatement extends BaseNode { + type: 'BlockStatement'; + path: Expression; + params: Expression[]; + hash: Hash; + program: Program | undefined; + inverse?: Program | undefined; + openStrip: StripFlags; + inverseStrip: StripFlags | undefined; + closeStrip: StripFlags; +} + +export interface DecoratorBlock extends BaseNode { + type: 'DecoratorBlock'; + path: Expression; + params: Expression[]; + hash: Hash; + program: Program; + inverse?: undefined; + inverseStrip?: undefined; + openStrip: StripFlags; + closeStrip: StripFlags; +} + +export interface PartialBlockStatement extends BaseNode { + type: 'PartialBlockStatement'; + name: Expression; + params: Expression[]; + hash: Hash; + program: Program; + inverse?: undefined; + inverseStrip?: undefined; + openStrip: StripFlags; + closeStrip: StripFlags; +} + +export type Statement = + | MustacheStatement + | Content + | BlockStatement + | PartialStatement + | PartialBlockStatement; + +export interface MustacheStatement extends BaseNode { + type: 'Decorator' | 'MustacheStatement'; + path: Expression; + params: Expression[]; + hash: Hash; + escaped: boolean; + strip: StripFlags; +} + +export interface PathExpression extends BaseNode { + readonly original: string; + readonly this: boolean; + readonly data: boolean; + readonly depth: number; + readonly parts: (string | SubExpression)[]; + readonly head: string | SubExpression | undefined; + readonly tail: string[]; +} + +export interface SubExpression extends BaseNode { + readonly original: string; +} + +export interface Hash { + readonly pairs: HashPair[]; +} + +export interface StripFlags { + readonly open?: boolean; + readonly close?: boolean; + readonly openStandalone?: boolean; + readonly closeStandalone?: boolean; + readonly inlineStandalone?: boolean; +} + +export interface HashPair { + readonly key: string; + readonly value: Expression; +} + +export interface ParserPart { + readonly part: string; + readonly original: string; + readonly separator: string; +} + +export interface Content extends BaseNode { + type: 'ContentStatement'; + original: string; + value: string; +} + +export type Expression = SubExpression | PathExpression; + +export interface SourcePosition { + line: number; + column: number; +} + +export interface SourceLocation { + source: string | undefined; + start: SourcePosition; + end: SourcePosition; +} + +export interface CallNode { + path: ast.Expr; + params: ast.Expr[]; + hash: ast.Hash; +} + +export interface OpenPartial { + strip: ast.StripFlags; +} + +export interface OpenPartialBlock extends CallNode { + strip: ast.StripFlags; +} + +export interface OpenRawBlock extends CallNode, BaseNode {} + +export interface OpenBlock extends CallNode { + open: string; + blockParams: string[]; + strip: StripFlags; +} + +export interface OpenInverse extends CallNode { + blockParams: string[]; + strip: StripFlags; +} + +export interface CloseBlock { + readonly path: PathExpression; + strip: StripFlags; +} + +export type AcceptedNode = Program; + +/// JISON TYPES /// + +export interface Parser { + parse: (input: string) => Program; + yy: YY; +} + +export interface YY { + locInfo(locInfo: LocInfo): SourceLocation; + preparePath( + this: YY, + data: boolean, + sexpr: { expr: SubExpression; sep: string } | false, + parts: ParserPart[], + locInfo: LocInfo + ): PathExpression; + + prepareMustache( + this: YY, + path: PathExpression, + params: Expression[], + hash: Hash, + open: string, + strip: StripFlags, + locInfo: LocInfo + ): MustacheStatement; + + prepareRawBlock( + this: YY, + openRawBlock: OpenRawBlock, + contents: Content[], + close: string, + locInfo: LocInfo + ): BlockStatement; + + prepareBlock( + this: YY, + openBlock: OpenBlock, + program: Program, + inverseChain: InverseChain, + close: CloseBlock, + inverted: boolean, + locInfo: LocInfo + ): BlockStatement | DecoratorBlock; +} + +/** + * The `LocInfo` object comes from the generated `jison` parser. + */ +export interface LocInfo { + first_line: number; + first_column: number; + last_line: number; + last_column: number; +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..6dd6c8a --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,5 @@ +export function assert(condition: unknown, msg?: string): asserts condition { + if (!condition) { + throw new Error(msg ?? 'Assertion failed'); + } +} diff --git a/lib/visitor.js b/lib/visitor.js deleted file mode 100644 index e9b5d68..0000000 --- a/lib/visitor.js +++ /dev/null @@ -1,136 +0,0 @@ -import Exception from './exception.js'; - -function Visitor() { - this.parents = []; -} - -Visitor.prototype = { - constructor: Visitor, - mutating: false, - - // Visits a given value. If mutating, will replace the value if necessary. - acceptKey: function (node, name) { - let value = this.accept(node[name]); - if (this.mutating) { - // Hacky sanity check: This may have a few false positives for type for the helper - // methods but will generally do the right thing without a lot of overhead. - if (value && !Visitor.prototype[value.type]) { - throw new Exception( - 'Unexpected node type "' + - value.type + - '" found when accepting ' + - name + - ' on ' + - node.type - ); - } - node[name] = value; - } - }, - - // Performs an accept operation with added sanity check to ensure - // required keys are not removed. - acceptRequired: function (node, name) { - this.acceptKey(node, name); - - if (!node[name]) { - throw new Exception(node.type + ' requires ' + name); - } - }, - - // Traverses a given array. If mutating, empty responses will be removed - // for child elements. - acceptArray: function (array) { - for (let i = 0, l = array.length; i < l; i++) { - this.acceptKey(array, i); - - if (!array[i]) { - array.splice(i, 1); - i--; - l--; - } - } - }, - - accept: function (object) { - if (!object) { - return; - } - - /* istanbul ignore next: Sanity code */ - if (!this[object.type]) { - throw new Exception('Unknown type: ' + object.type, object); - } - - if (this.current) { - this.parents.unshift(this.current); - } - this.current = object; - - let ret = this[object.type](object); - - this.current = this.parents.shift(); - - if (!this.mutating || ret) { - return ret; - } else if (ret !== false) { - return object; - } - }, - - Program: function (program) { - this.acceptArray(program.body); - }, - - MustacheStatement: visitSubExpression, - Decorator: visitSubExpression, - - BlockStatement: visitBlock, - DecoratorBlock: visitBlock, - - PartialStatement: visitPartial, - PartialBlockStatement: function (partial) { - visitPartial.call(this, partial); - - this.acceptKey(partial, 'program'); - }, - - ContentStatement: function (/* content */) {}, - CommentStatement: function (/* comment */) {}, - - SubExpression: visitSubExpression, - - PathExpression: function (/* path */) {}, - - StringLiteral: function (/* string */) {}, - NumberLiteral: function (/* number */) {}, - BooleanLiteral: function (/* bool */) {}, - UndefinedLiteral: function (/* literal */) {}, - NullLiteral: function (/* literal */) {}, - - Hash: function (hash) { - this.acceptArray(hash.pairs); - }, - HashPair: function (pair) { - this.acceptRequired(pair, 'value'); - }, -}; - -function visitSubExpression(mustache) { - this.acceptRequired(mustache, 'path'); - this.acceptArray(mustache.params); - this.acceptKey(mustache, 'hash'); -} -function visitBlock(block) { - visitSubExpression.call(this, block); - - this.acceptKey(block, 'program'); - this.acceptKey(block, 'inverse'); -} -function visitPartial(partial) { - this.acceptRequired(partial, 'name'); - this.acceptArray(partial.params); - this.acceptKey(partial, 'hash'); -} - -export default Visitor; diff --git a/lib/visitor.ts b/lib/visitor.ts new file mode 100644 index 0000000..11a12c3 --- /dev/null +++ b/lib/visitor.ts @@ -0,0 +1,250 @@ +import Exception from './exception.js'; +import type * as ast from './types/ast.js'; + +export default class Visitor { + parents: ast.VisitableNode[] = []; + mutating = false; + current: ast.VisitableNode | undefined; + + acceptField(node: N, name: keyof N): void { + this.#acceptKey(node, name, node); + } + + acceptItem( + array: ast.VisitableNode[], + item: number, + parent: ast.VisitableNode + ): void { + this.#acceptKey(array, item, parent); + } + + #acceptKey( + container: ast.VisitableNode | ast.VisitableNode[], + name: string | symbol | number, + parent: ast.VisitableNode + ) { + const node = Reflect.get(container, name) as ast.VisitableNode; + let value = this.accept(node); + + if (this.mutating) { + if (isObject(value) && !this.#isVisitable(value)) { + throw new Exception( + `${unexpectedVisitorReturn(value)} when accepting ${String( + name + )} on ${parent.type}`, + node + ); + } + Reflect.set(container, name, value); + } + } + + acceptRequired( + node: N, + name: ast.VisitableChildren[N['type']] + ): void { + const original = node[name] as ast.VisitableNode; + this.acceptField(node, name); + + if (!node[name]) { + throw new Exception( + `Visitor removed \`${name}\` (${original.loc.start.line}:${original.loc.start.column}) from ${node.type}, but \`${name}\` is required`, + node + ); + } + } + + // Traverses a given array. If mutating, empty responses will be removed + // for child elements. + acceptArray(array: ast.VisitableNode[], parent: ast.VisitableNode) { + for (let i = 0, l = array.length; i < l; i++) { + this.#acceptKey(array, i, parent); + + if (!array[i]) { + array.splice(i, 1); + i--; + l--; + } + } + } + + accept(node: ast.VisitableNode | null | undefined): unknown { + const obj = node; + + if (!obj) { + return undefined; + } + + if (!this[obj.type]) { + throw new Exception('Unknown type: ' + obj.type, obj); + } + + if (this.current) { + this.parents.unshift(this.current); + } + + this.current = obj; + + const visit = this[obj.type] as ( + node: typeof obj + ) => ReturnType['type']]>; + + let ret = visit.call(this, obj); + + this.current = this.parents.shift(); + + if (!this.mutating || ret) { + return ret; + } else if (ret !== false) { + return obj; + } else { + return; + } + } + + Program(program: ast.Program): unknown { + this.acceptArray(program.body, program); + return; + } + + MustacheStatement(mustache: ast.MustacheStatement): unknown { + this.callNode(mustache); + return; + } + + Decorator(mustache: ast.Decorator): unknown { + this.callNode(mustache); + return; + } + + BlockStatement(block: ast.BlockStatement): unknown { + this.#visitBlock(block); + return; + } + + DecoratorBlock(block: ast.DecoratorBlock): unknown { + this.#visitBlock(block); + return; + } + + PartialStatement(partial: ast.PartialStatement): unknown { + this.#visitPartial(partial); + return; + } + + PartialBlockStatement(partial: ast.PartialBlockStatement): unknown { + this.#visitPartial(partial); + this.acceptField(partial, 'program'); + return; + } + + ContentStatement(_content: ast.ContentStatement): unknown { + return; + } + + CommentStatement(_comment: ast.CommentStatement): unknown { + return; + } + + SubExpression(sexpr: ast.SubExpression): unknown; + /** + * Passing a `CallNode` to `SubExpression` is deprecated. + * + * @deprecated Call {@linkcode callNode} instead of SubExpression if you aren't passing a SubExpression. + */ + SubExpression(sexpr: ast.CallNode): unknown; + SubExpression(sexpr: ast.CallNode): unknown { + this.callNode(sexpr); + return; + } + + PathExpression(_path: ast.PathExpression): unknown { + return; + } + + StringLiteral(_string: ast.StringLiteral): unknown { + return; + } + + NumberLiteral(_number: ast.NumberLiteral): unknown { + return; + } + + BooleanLiteral(_boolean: ast.BooleanLiteral): unknown { + return; + } + + UndefinedLiteral(_undefined: ast.UndefinedLiteral): unknown { + return; + } + + NullLiteral(_null: ast.NullLiteral): unknown { + return; + } + + ArrayLiteral(array: ast.ArrayLiteral): unknown { + this.acceptArray(array.items, array); + return; + } + + HashLiteral(hash: ast.HashLiteral): unknown { + this.acceptArray(hash.pairs, hash); + return; + } + + Hash(hash: ast.Hash): unknown { + this.acceptArray(hash.pairs, hash); + return; + } + + HashPair(pair: ast.HashPair): unknown { + this.acceptRequired(pair, 'value'); + return; + } + + callNode(callNode: ast.CallNode) { + this.acceptRequired(callNode, 'path'); + this.acceptArray(callNode.params, callNode); + this.acceptField(callNode, 'hash'); + } + + #visitBlock(block: ast.BlockStatement | ast.DecoratorBlock) { + this.callNode(block); + + this.acceptField(block, 'program'); + this.acceptField(block, 'inverse'); + } + + #visitPartial(partial: ast.PartialStatement | ast.PartialBlockStatement) { + this.acceptRequired(partial, 'name'); + this.acceptArray(partial.params, partial); + this.acceptField(partial, 'hash'); + } + + #isVisitable(obj: VisitorReturn): obj is ast.Node { + const type = Reflect.get(obj, 'type'); + if (type === undefined || typeof type !== 'string') { + return false; + } + + return Reflect.has(this, type); + } +} + +type VisitorReturn = { type?: unknown }; + +function isObject(obj: unknown): obj is VisitorReturn { + return !!obj; +} + +function unexpectedVisitorReturn(value: VisitorReturn) { + if (value.type === undefined) { + return `Unexpected visitor return value (no 'type' property)`; + } else if (typeof value.type !== 'string') { + return `Unexpected visitor return value (type is ${ + value.type === null ? 'null' : typeof value.type + }, not a string)`; + } else { + return `Unexpected visitor return value (type of "${value.type}" is not a visitable node type)`; + } +} diff --git a/lib/whitespace-control.js b/lib/whitespace-control.js deleted file mode 100644 index 8d98c14..0000000 --- a/lib/whitespace-control.js +++ /dev/null @@ -1,233 +0,0 @@ -import Visitor from './visitor.js'; - -function WhitespaceControl(options = {}) { - this.options = options; -} -WhitespaceControl.prototype = new Visitor(); - -WhitespaceControl.prototype.Program = function (program) { - const doStandalone = !this.options.ignoreStandalone; - - let isRoot = !this.isRootSeen; - this.isRootSeen = true; - - let body = program.body; - for (let i = 0, l = body.length; i < l; i++) { - let current = body[i], - strip = this.accept(current); - - if (!strip) { - continue; - } - - let _isPrevWhitespace = isPrevWhitespace(body, i, isRoot), - _isNextWhitespace = isNextWhitespace(body, i, isRoot), - openStandalone = strip.openStandalone && _isPrevWhitespace, - closeStandalone = strip.closeStandalone && _isNextWhitespace, - inlineStandalone = - strip.inlineStandalone && _isPrevWhitespace && _isNextWhitespace; - - if (strip.close) { - omitRight(body, i, true); - } - if (strip.open) { - omitLeft(body, i, true); - } - - if (doStandalone && inlineStandalone) { - omitRight(body, i); - - if (omitLeft(body, i)) { - // If we are on a standalone node, save the indent info for partials - if (current.type === 'PartialStatement') { - // Pull out the whitespace from the final line - current.indent = /([ \t]+$)/.exec(body[i - 1].original)[1]; - } - } - } - if (doStandalone && openStandalone) { - omitRight((current.program || current.inverse).body); - - // Strip out the previous content node if it's whitespace only - omitLeft(body, i); - } - if (doStandalone && closeStandalone) { - // Always strip the next node - omitRight(body, i); - - omitLeft((current.inverse || current.program).body); - } - } - - return program; -}; - -WhitespaceControl.prototype.BlockStatement = - WhitespaceControl.prototype.DecoratorBlock = - WhitespaceControl.prototype.PartialBlockStatement = - function (block) { - this.accept(block.program); - this.accept(block.inverse); - - // Find the inverse program that is involved with whitespace stripping. - let program = block.program || block.inverse, - inverse = block.program && block.inverse, - firstInverse = inverse, - lastInverse = inverse; - - if (inverse && inverse.chained) { - firstInverse = inverse.body[0].program; - - // Walk the inverse chain to find the last inverse that is actually in the chain. - while (lastInverse.chained) { - lastInverse = lastInverse.body[lastInverse.body.length - 1].program; - } - } - - let strip = { - open: block.openStrip.open, - close: block.closeStrip.close, - - // Determine the standalone candidacy. Basically flag our content as being possibly standalone - // so our parent can determine if we actually are standalone - openStandalone: isNextWhitespace(program.body), - closeStandalone: isPrevWhitespace((firstInverse || program).body), - }; - - if (block.openStrip.close) { - omitRight(program.body, null, true); - } - - if (inverse) { - let inverseStrip = block.inverseStrip; - - if (inverseStrip.open) { - omitLeft(program.body, null, true); - } - - if (inverseStrip.close) { - omitRight(firstInverse.body, null, true); - } - if (block.closeStrip.open) { - omitLeft(lastInverse.body, null, true); - } - - // Find standalone else statements - if ( - !this.options.ignoreStandalone && - isPrevWhitespace(program.body) && - isNextWhitespace(firstInverse.body) - ) { - omitLeft(program.body); - omitRight(firstInverse.body); - } - } else if (block.closeStrip.open) { - omitLeft(program.body, null, true); - } - - return strip; - }; - -WhitespaceControl.prototype.Decorator = - WhitespaceControl.prototype.MustacheStatement = function (mustache) { - return mustache.strip; - }; - -WhitespaceControl.prototype.PartialStatement = - WhitespaceControl.prototype.CommentStatement = function (node) { - /* istanbul ignore next */ - let strip = node.strip || {}; - return { - inlineStandalone: true, - open: strip.open, - close: strip.close, - }; - }; - -function isPrevWhitespace(body, i, isRoot) { - if (i === undefined) { - i = body.length; - } - - // Nodes that end with newlines are considered whitespace (but are special - // cased for strip operations) - let prev = body[i - 1], - sibling = body[i - 2]; - if (!prev) { - return isRoot; - } - - if (prev.type === 'ContentStatement') { - return (sibling || !isRoot ? /\r?\n\s*?$/ : /(^|\r?\n)\s*?$/).test( - prev.original - ); - } -} -function isNextWhitespace(body, i, isRoot) { - if (i === undefined) { - i = -1; - } - - let next = body[i + 1], - sibling = body[i + 2]; - if (!next) { - return isRoot; - } - - if (next.type === 'ContentStatement') { - return (sibling || !isRoot ? /^\s*?\r?\n/ : /^\s*?(\r?\n|$)/).test( - next.original - ); - } -} - -// Marks the node to the right of the position as omitted. -// I.e. {{foo}}' ' will mark the ' ' node as omitted. -// -// If i is undefined, then the first child will be marked as such. -// -// If multiple is truthy then all whitespace will be stripped out until non-whitespace -// content is met. -function omitRight(body, i, multiple) { - let current = body[i == null ? 0 : i + 1]; - if ( - !current || - current.type !== 'ContentStatement' || - (!multiple && current.rightStripped) - ) { - return; - } - - let original = current.value; - current.value = current.value.replace( - multiple ? /^\s+/ : /^[ \t]*\r?\n?/, - '' - ); - current.rightStripped = current.value !== original; -} - -// Marks the node to the left of the position as omitted. -// I.e. ' '{{foo}} will mark the ' ' node as omitted. -// -// If i is undefined then the last child will be marked as such. -// -// If multiple is truthy then all whitespace will be stripped out until non-whitespace -// content is met. -function omitLeft(body, i, multiple) { - let current = body[i == null ? body.length - 1 : i - 1]; - if ( - !current || - current.type !== 'ContentStatement' || - (!multiple && current.leftStripped) - ) { - return; - } - - // We omit the last node if it's whitespace only and not preceded by a non-content node. - let original = current.value; - current.value = current.value.replace(multiple ? /\s+$/ : /[ \t]+$/, ''); - current.leftStripped = current.value !== original; - return current.leftStripped; -} - -export default WhitespaceControl; diff --git a/lib/whitespace-control.ts b/lib/whitespace-control.ts new file mode 100644 index 0000000..12c5a1d --- /dev/null +++ b/lib/whitespace-control.ts @@ -0,0 +1,285 @@ +import Visitor from './visitor.js'; +import type * as ast from './types/ast.js'; + +interface WhitespaceControlOptions { + ignoreStandalone?: boolean; +} + +export default class WhitespaceControl extends Visitor { + options: WhitespaceControlOptions; + isRootSeen = false; + + constructor(options: WhitespaceControlOptions) { + super(); + this.options = options; + } + + Program(program: ast.Program) { + const doStandalone = !this.options.ignoreStandalone; + + let isRoot = !this.isRootSeen; + this.isRootSeen = true; + + let body = program.body; + for (let i = 0, l = body.length; i < l; i++) { + let current = body[i]; + const strip = this.accept(current) as ast.StripFlags | undefined; + + if (!strip) { + continue; + } + + let _isPrevWhitespace = isPrevWhitespace(body, i, isRoot); + let _isNextWhitespace = isNextWhitespace(body, i, isRoot); + let openStandalone = strip.openStandalone && _isPrevWhitespace; + let closeStandalone = strip.closeStandalone && _isNextWhitespace; + let inlineStandalone = + strip.inlineStandalone && _isPrevWhitespace && _isNextWhitespace; + + if (strip.close) { + omitRight(body, i, true); + } + + if (strip.open) { + omitLeft(body, i, true); + } + + if (doStandalone && inlineStandalone) { + omitRight(body, i); + + if (omitLeft(body, i)) { + // If we are on a standalone node, save the indent info for partials + if (current.type === 'PartialStatement') { + // Pull out the whitespace from the final line + // @ts-expect-error + current.indent = /([ \t]+$)/.exec(body[i - 1].original)[1]; + } + } + } + if (doStandalone && openStandalone) { + // @ts-expect-error + omitRight((current.program || current.inverse).body); + + // Strip out the previous content node if it's whitespace only + omitLeft(body, i); + } + if (doStandalone && closeStandalone) { + // Always strip the next node + omitRight(body, i); + + // @ts-expect-error + omitLeft((current.inverse || current.program).body); + } + } + + return program; + } + + BlockStatement(block: ast.BlockStatement) { + return this.#blockStatement(block); + } + + DecoratorBlock(block: ast.DecoratorBlock) { + return this.#blockStatement(block); + } + + PartialBlockStatement(block: ast.PartialBlockStatement) { + return this.#blockStatement(block); + } + + #blockStatement( + block: ast.PartialBlockStatement | ast.BlockStatement | ast.DecoratorBlock + ) { + this.accept(block.program); + this.accept(block.inverse); + + // Find the inverse program that is involved with whitespace stripping. + let program = block.program || block.inverse; + let inverse = block.program && block.inverse; + let firstInverse = inverse; + let lastInverse = inverse; + + if (inverse?.chained) { + // @ts-expect-error + firstInverse = inverse.body[0].program; + + // Walk the inverse chain to find the last inverse that is actually in the chain. + while (lastInverse?.chained) { + // @ts-expect-error + lastInverse = lastInverse.body[lastInverse.body.length - 1].program; + } + } + + let strip = { + open: block.openStrip.open, + close: block.closeStrip.close, + + // Determine the standalone candidacy. Basically flag our content as being possibly standalone + // so our parent can determine if we actually are standalone + openStandalone: isNextWhitespace(program.body), + closeStandalone: isPrevWhitespace((firstInverse || program).body), + }; + + if (block.openStrip.close) { + omitRight(program.body, null, true); + } + + if (inverse) { + let inverseStrip = block.inverseStrip; + + if (inverseStrip?.open) { + omitLeft(program.body, null, true); + } + + if (inverseStrip?.close && firstInverse) { + omitRight(firstInverse.body, null, true); + } + if (block.closeStrip.open && lastInverse) { + omitLeft(lastInverse.body, null, true); + } + + // Find standalone else statements + if ( + !this.options.ignoreStandalone && + isPrevWhitespace(program.body) && + // @ts-expect-error + isNextWhitespace(firstInverse.body) + ) { + omitLeft(program.body); + // @ts-expect-error + omitRight(firstInverse.body); + } + } else if (block.closeStrip.open) { + omitLeft(program.body, null, true); + } + + return strip; + } + + Decorator(node: ast.Decorator) { + return node.strip; + } + + MustacheStatement(node: ast.MustacheStatement) { + return node.strip; + } + + PartialStatement(node: ast.PartialStatement) { + return this.#statement(node); + } + + CommentStatement(node: ast.CommentStatement) { + return this.#statement(node); + } + + #statement(node: ast.PartialStatement | ast.CommentStatement) { + let strip = node.strip || {}; + return { + inlineStandalone: true, + open: strip.open, + close: strip.close, + }; + } +} + +function isPrevWhitespace(body: ast.Statement[], i?: number, isRoot?: boolean) { + if (i === undefined) { + i = body.length; + } + + // Nodes that end with newlines are considered whitespace (but are special + // cased for strip operations) + let prev = body[i - 1], + sibling = body[i - 2]; + if (!prev) { + return isRoot; + } + + if (prev.type === 'ContentStatement') { + return (sibling || !isRoot ? /\r?\n\s*?$/ : /(^|\r?\n)\s*?$/).test( + prev.original + ); + } +} + +function isNextWhitespace(body: ast.Statement[], i?: number, isRoot?: boolean) { + if (i === undefined) { + i = -1; + } + + let next = body[i + 1], + sibling = body[i + 2]; + if (!next) { + return isRoot; + } + + if (next.type === 'ContentStatement') { + return (sibling || !isRoot ? /^\s*?\r?\n/ : /^\s*?(\r?\n|$)/).test( + next.original + ); + } +} + +// Marks the node to the right of the position as omitted. +// I.e. {{foo}}' ' will mark the ' ' node as omitted. +// +// If i is undefined, then the first child will be marked as such. +// +// If multiple is truthy then all whitespace will be stripped out until non-whitespace +// content is met. +function omitRight( + body: ast.Statement[], + i?: number | null, + multiple?: boolean +) { + let current = body[i == null ? 0 : i + 1]; + if ( + !current || + current.type !== 'ContentStatement' || + (!multiple && current.rightStripped) + ) { + return; + } + + let original = current.value; + current.value = current.value.replace( + multiple ? /^\s+/ : /^[ \t]*\r?\n?/, + '' + ); + + current.rightStripped = current.value !== original; +} + +// Marks the node to the left of the position as omitted. +// I.e. ' '{{foo}} will mark the ' ' node as omitted. +// +// If i is undefined then the last child will be marked as such. +// +// If multiple is truthy then all whitespace will be stripped out until non-whitespace +// content is met. +function omitLeft( + body: ast.Statement[], + i?: number | null, + multiple?: boolean +) { + let current = body[i == null ? body.length - 1 : i - 1]; + if ( + !current || + current.type !== 'ContentStatement' || + (!multiple && current.leftStripped) + ) { + return; + } + + // We omit the last node if it's whitespace only and not preceded by a non-content node. + let original = current.value; + current.value = current.value.replace(multiple ? /\s+$/ : /[ \t]+$/, ''); + current.leftStripped = current.value !== original; + return current.leftStripped; +} + +// export default WhitespaceControl; + +function hasInverse( + block: ast.PartialBlockStatement | ast.BlockStatement | ast.DecoratorBlock +) {} diff --git a/package.json b/package.json index a52f128..4639609 100644 --- a/package.json +++ b/package.json @@ -36,9 +36,9 @@ "lint": "eslint .", "prepublishOnly": "pnpm run build", "build": "npm-run-all build:parser build:esm build:cjs", - "build:cjs": "tsc --module nodenext --moduleResolution nodenext --target es5 --outDir dist/cjs", - "build:esm": "tsc --module es2015 --target es5 --outDir dist/esm", - "build:jison": "jison -m js src/handlebars.yy src/handlebars.l -o lib/parser.js", + "build:cjs": "tsc --module nodenext --moduleResolution nodenext --target es2020 --outDir dist/cjs", + "build:esm": "tsc --module es2020 --outDir dist/esm", + "build:jison": "node scripts/compile-parser.cjs", "build:parser": "npm-run-all build:jison build:parser-suffix", "build:parser-suffix": "combine-files lib/parser.js,src/parser-suffix.js lib/parser.js", "pretest": "pnpm run build", diff --git a/scripts/compile-parser.cjs b/scripts/compile-parser.cjs new file mode 100644 index 0000000..dfb9f16 --- /dev/null +++ b/scripts/compile-parser.cjs @@ -0,0 +1,19 @@ +const cli = require('jison/lib/cli'); + +// This is a workaround for https://github.com/zaach/jison/pull/352 having never been merged +const oldProcessGrammars = cli.processGrammars; + +cli.processGrammars = function (...args) { + const grammar = oldProcessGrammars.call(this, ...args); + grammar.options = grammar.options ?? {}; + grammar.options['token-stack'] = true; + return grammar; +}; + +cli.main({ + moduleType: 'js', + file: 'src/handlebars.yy', + lexfile: 'src/handlebars.l', + outfile: 'lib/parser.js', + 'token-stack': true, +}); diff --git a/spec/parser.js b/spec/parser.js index 93ad216..5c1e9df 100644 --- a/spec/parser.js +++ b/spec/parser.js @@ -1,5 +1,5 @@ import { parse, print } from '../dist/esm/index.js'; -import { equals, equalsAst, shouldThrow } from './utils.js'; +import { equals, equalsAst, equalsJSON, shouldThrow } from './utils.js'; describe('parser', function () { function astFor(template) { @@ -516,26 +516,17 @@ describe('parser', function () { ); // We really need a deep equals but for now this should be stable... - equals( - JSON.stringify(p.loc), - JSON.stringify({ - start: { line: 1, column: 0 }, - end: { line: 7, column: 4 }, - }) - ); - equals( - JSON.stringify(p.body[1].program.loc), - JSON.stringify({ - start: { line: 2, column: 13 }, - end: { line: 4, column: 7 }, - }) - ); - equals( - JSON.stringify(p.body[1].inverse.loc), - JSON.stringify({ - start: { line: 4, column: 15 }, - end: { line: 6, column: 5 }, - }) - ); + equalsJSON(p.loc, { + start: { line: 1, column: 0 }, + end: { line: 7, column: 4 }, + }); + equalsJSON(p.body[1].program.loc, { + start: { line: 2, column: 13 }, + end: { line: 4, column: 7 }, + }); + equalsJSON(p.body[1].inverse.loc, { + start: { line: 4, column: 15 }, + end: { line: 6, column: 5 }, + }); }); }); diff --git a/spec/utils.js b/spec/utils.js index 503eff0..d1fb43e 100644 --- a/spec/utils.js +++ b/spec/utils.js @@ -23,8 +23,7 @@ if (Error.captureStackTrace) { export function equals(actual, expected, msg) { if (actual !== expected) { const error = new AssertError( - `\n Actual: ${actual} Expected: ${expected}` + - (msg ? `\n${msg}` : ''), + msg ?? `Expected actual to equal expected.`, equals ); error.expected = expected; @@ -33,6 +32,21 @@ export function equals(actual, expected, msg) { } } +export function equalsJSON(actual, expected, msg) { + const actualJSON = JSON.stringify(actual, null, 2); + const expectedJSON = JSON.stringify(expected, null, 2); + + if (actualJSON !== expectedJSON) { + const error = new AssertError( + msg ?? `Expected equivalent JSON serialization.`, + equalsJSON + ); + error.expected = expectedJSON; + error.actual = actualJSON; + throw error; + } +} + export function equalsAst(source, expected, options) { const msg = typeof options === 'string' ? options : options?.msg; const parserOptions = @@ -99,28 +113,56 @@ export function shouldThrow(callback, type, msg) { failed = true; } catch (caught) { if (type && !(caught instanceof type)) { - throw new AssertError('Type failure: ' + caught); - } - if ( - msg && - !(msg.test ? msg.test(caught.message) : msg === caught.message) - ) { - throw new AssertError( - 'Throw mismatch: Expected ' + - caught.message + - ' to match ' + - msg + - '\n\n' + - caught.stack, + const error = new AssertError( + `An error was thrown, but it had the wrong type. Original error:\n${snippet( + caught.stack + )}`, shouldThrow ); + error.expected = type.name; + error.actual = caught.constructor.name; + throw error; + } + + if (msg) { + if (typeof msg === 'string') { + if (msg !== caught.message) { + const error = new AssertError( + `Error message didn't match.\n\n${snippet(caught.stack)}` + + shouldThrow + ); + error.expected = msg; + error.actual = caught.message; + throw error; + } + } else if (msg instanceof RegExp) { + if (!msg.test(caught.message)) { + const error = new AssertError( + `Error message didn't match.\n\n${snippet(caught.stack)}` + + shouldThrow + ); + error.expected = msg; + error.actual = caught.message; + throw error; + } + } } } + if (failed) { - throw new AssertError('It failed to throw', shouldThrow); + throw new AssertError('Expected a thrown exception', shouldThrow); } } function astFor(template, options = {}) { let ast = parse(template, options); return print(ast); } + +function snippet(string) { + return string + .split('\n') + .map(function (line) { + return ' | ' + line; + }) + .join('\n'); +} diff --git a/spec/visitor.js b/spec/visitor.js index 4135466..2f83955 100644 --- a/spec/visitor.js +++ b/spec/visitor.js @@ -98,7 +98,7 @@ describe('Visitor', function () { visitor.accept(ast); }, Exception, - 'MustacheStatement requires path' + 'Visitor removed `path` (1:2) from MustacheStatement, but `path` is required - 1:0' ); }); it('should throw when returning non-node responses', function () { @@ -115,7 +115,7 @@ describe('Visitor', function () { visitor.accept(ast); }, Exception, - 'Unexpected node type "undefined" found when accepting path on MustacheStatement' + `Unexpected visitor return value (no 'type' property) when accepting path on MustacheStatement - 1:2` ); }); }); diff --git a/src/handlebars.yy b/src/handlebars.yy index ed26c2c..f7870e6 100644 --- a/src/handlebars.yy +++ b/src/handlebars.yy @@ -64,7 +64,7 @@ openInverseChain ; inverseAndProgram - : INVERSE program -> { strip: yy.stripFlags($1, $1), program: $2 } + : INVERSE program -> { strip: yy.stripFlags($1, $1), program: $2, loc: yy.locInfo(@$) } ; inverseChain @@ -73,13 +73,13 @@ inverseChain program = yy.prepareProgram([inverse], $2.loc); program.chained = true; - $$ = { strip: $1.strip, program: program, chain: true }; + $$ = { strip: $1.strip, program: program, chain: true, loc: yy.locInfo(@$) }; } | inverseAndProgram -> $1 ; closeBlock - : OPEN_ENDBLOCK helperName CLOSE -> {path: $2, strip: yy.stripFlags($1, $3)} + : OPEN_ENDBLOCK helperName CLOSE -> {path: $2, strip: yy.stripFlags($1, $3), loc: yy.locInfo(@$)} ; mustache diff --git a/tsconfig.json b/tsconfig.json index fb59719..42a856f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,18 +13,17 @@ // Enhance Strictness "strict": true, "suppressImplicitAnyIndexErrors": false, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitReturns": true, "newLine": "LF", - "allowJs": true + "allowJs": true, }, "include": [ "lib/**/*.js", + "lib/**/*.ts", "lib/**/*.d.ts" ], "exclude": [ "dist", + "lib/parser.js", "node_modules", ".vscode" ]