UNPKG

ember-source

Version:

A JavaScript framework for creating ambitious web applications

1,804 lines (1,731 loc) 360 kB
import { a as isPresentArray, g as getFirst, b as getLast, u as unwrap, f as asPresentArray, e as expect, d as dict, m as mapPresentArray, h as exhausted } from './collections-C3Y8z_9v.js'; import { a as assert } from './assert-Zqc4wiAV.js'; import { assertNever } from '../@glimmer/util/index.js'; import { s as setLocalDebugType } from './debug-brand-B1TWjOCH.js'; import { a as assign } from './object-utils-AijlD-JH.js'; import { o as opcodes } from './opcodes-CplRyHl_.js'; import { C as CURRIED_MODIFIER, b as CURRIED_HELPER, a as CURRIED_COMPONENT } from './curried-BZnYakIg.js'; import { a as WellKnownTagNames, W as WellKnownAttrNames } from './well-known-_EVO9RaV.js'; const Char = { NBSP: 0xa0, QUOT: 0x22, LT: 0x3c, GT: 0x3e, AMP: 0x26 }; // \x26 is ampersand, \xa0 is non-breaking space const ATTR_VALUE_REGEX_TEST = /["\x26\xa0]/u; const ATTR_VALUE_REGEX_REPLACE = new RegExp(ATTR_VALUE_REGEX_TEST.source, 'gu'); const TEXT_REGEX_TEST = /[&<>\xa0]/u; const TEXT_REGEX_REPLACE = new RegExp(TEXT_REGEX_TEST.source, 'gu'); function attrValueReplacer(char) { switch (char.charCodeAt(0)) { case Char.NBSP: return '&nbsp;'; case Char.QUOT: return '&quot;'; case Char.AMP: return '&amp;'; default: return char; } } function textReplacer(char) { switch (char.charCodeAt(0)) { case Char.NBSP: return '&nbsp;'; case Char.AMP: return '&amp;'; case Char.LT: return '&lt;'; case Char.GT: return '&gt;'; default: return char; } } function escapeAttrValue(attrValue) { if (ATTR_VALUE_REGEX_TEST.test(attrValue)) { return attrValue.replace(ATTR_VALUE_REGEX_REPLACE, attrValueReplacer); } return attrValue; } function escapeText(text) { if (TEXT_REGEX_TEST.test(text)) { return text.replace(TEXT_REGEX_REPLACE, textReplacer); } return text; } function sortByLoc(a, b) { // If either is invisible, don't try to order them if (a.loc.isInvisible || b.loc.isInvisible) { return 0; } if (a.loc.startPosition.line < b.loc.startPosition.line) { return -1; } if (a.loc.startPosition.line === b.loc.startPosition.line && a.loc.startPosition.column < b.loc.startPosition.column) { return -1; } if (a.loc.startPosition.line === b.loc.startPosition.line && a.loc.startPosition.column === b.loc.startPosition.column) { return 0; } return 1; } const voidMap = new Set(['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']); const NON_WHITESPACE = /^\S/u; /** * Examples when true: * - link * - liNK * * Examples when false: * - Link (component) */ function isVoidTag(tag) { return voidMap.has(tag.toLowerCase()) && tag[0]?.toLowerCase() === tag[0]; } class Printer { buffer = ''; options; constructor(options) { this.options = options; } /* This is used by _all_ methods on this Printer class that add to `this.buffer`, it allows consumers of the printer to use alternate string representations for a given node. The primary use case for this are things like source -> source codemod utilities. For example, ember-template-recast attempts to always preserve the original string formatting in each AST node if no modifications are made to it. */ handledByOverride(node, ensureLeadingWhitespace = false) { if (this.options.override !== undefined) { let result = this.options.override(node, this.options); if (typeof result === 'string') { if (ensureLeadingWhitespace && NON_WHITESPACE.test(result)) { result = ` ${result}`; } this.buffer += result; return true; } } return false; } Node(node) { switch (node.type) { case 'MustacheStatement': case 'BlockStatement': case 'MustacheCommentStatement': case 'CommentStatement': case 'TextNode': case 'ElementNode': case 'AttrNode': case 'Block': case 'Template': return this.TopLevelStatement(node); case 'StringLiteral': case 'BooleanLiteral': case 'NumberLiteral': case 'UndefinedLiteral': case 'NullLiteral': case 'PathExpression': case 'SubExpression': return this.Expression(node); case 'ConcatStatement': // should have an AttrNode parent return this.ConcatStatement(node); case 'Hash': return this.Hash(node); case 'HashPair': return this.HashPair(node); case 'ElementModifierStatement': return this.ElementModifierStatement(node); } } Expression(expression) { switch (expression.type) { case 'StringLiteral': case 'BooleanLiteral': case 'NumberLiteral': case 'UndefinedLiteral': case 'NullLiteral': return this.Literal(expression); case 'PathExpression': return this.PathExpression(expression); case 'SubExpression': return this.SubExpression(expression); } } Literal(literal) { switch (literal.type) { case 'StringLiteral': return this.StringLiteral(literal); case 'BooleanLiteral': return this.BooleanLiteral(literal); case 'NumberLiteral': return this.NumberLiteral(literal); case 'UndefinedLiteral': return this.UndefinedLiteral(literal); case 'NullLiteral': return this.NullLiteral(literal); } } TopLevelStatement(statement) { switch (statement.type) { case 'MustacheStatement': return this.MustacheStatement(statement); case 'BlockStatement': return this.BlockStatement(statement); case 'MustacheCommentStatement': return this.MustacheCommentStatement(statement); case 'CommentStatement': return this.CommentStatement(statement); case 'TextNode': return this.TextNode(statement); case 'ElementNode': return this.ElementNode(statement); case 'Block': return this.Block(statement); case 'Template': return this.Template(statement); case 'AttrNode': // should have element return this.AttrNode(statement); } } Template(template) { this.TopLevelStatements(template.body); } Block(block) { /* When processing a template like: ```hbs {{#if whatever}} whatever {{else if somethingElse}} something else {{else}} fallback {{/if}} ``` The AST still _effectively_ looks like: ```hbs {{#if whatever}} whatever {{else}}{{#if somethingElse}} something else {{else}} fallback {{/if}}{{/if}} ``` The only way we can tell if that is the case is by checking for `block.chained`, but unfortunately when the actual statements are processed the `block.body[0]` node (which will always be a `BlockStatement`) has no clue that its ancestor `Block` node was chained. This "forwards" the `chained` setting so that we can check it later when processing the `BlockStatement`. */ if (block.chained) { let firstChild = block.body[0]; firstChild.chained = true; } if (this.handledByOverride(block)) { return; } this.TopLevelStatements(block.body); } TopLevelStatements(statements) { statements.forEach(statement => this.TopLevelStatement(statement)); } ElementNode(el) { if (this.handledByOverride(el)) { return; } this.OpenElementNode(el); this.TopLevelStatements(el.children); this.CloseElementNode(el); } OpenElementNode(el) { this.buffer += `<${el.tag}`; const parts = [...el.attributes, ...el.modifiers, ...el.comments].sort(sortByLoc); for (const part of parts) { this.buffer += ' '; switch (part.type) { case 'AttrNode': this.AttrNode(part); break; case 'ElementModifierStatement': this.ElementModifierStatement(part); break; case 'MustacheCommentStatement': this.MustacheCommentStatement(part); break; } } if (el.blockParams.length) { this.BlockParams(el.blockParams); } if (el.selfClosing) { this.buffer += ' /'; } this.buffer += '>'; } CloseElementNode(el) { if (el.selfClosing || isVoidTag(el.tag)) { return; } this.buffer += `</${el.tag}>`; } AttrNode(attr) { if (this.handledByOverride(attr)) { return; } let { name, value } = attr; this.buffer += name; const isAttribute = !name.startsWith('@'); const shouldElideValue = isAttribute && value.type == 'TextNode' && value.chars.length === 0; if (!shouldElideValue) { this.buffer += '='; this.AttrNodeValue(value); } } AttrNodeValue(value) { if (value.type === 'TextNode') { let quote = '"'; if (this.options.entityEncoding === 'raw') { if (value.chars.includes('"') && !value.chars.includes("'")) { quote = "'"; } } this.buffer += quote; this.TextNode(value, quote); this.buffer += quote; } else { this.Node(value); } } TextNode(text, isInAttr) { if (this.handledByOverride(text)) { return; } if (this.options.entityEncoding === 'raw') { if (isInAttr && text.chars.includes(isInAttr)) { this.buffer += escapeAttrValue(text.chars); } else { this.buffer += text.chars; } } else if (isInAttr) { this.buffer += escapeAttrValue(text.chars); } else { this.buffer += escapeText(text.chars); } } MustacheStatement(mustache) { if (this.handledByOverride(mustache)) { return; } this.buffer += mustache.trusting ? '{{{' : '{{'; if (mustache.strip.open) { this.buffer += '~'; } this.Expression(mustache.path); this.Params(mustache.params); this.Hash(mustache.hash); if (mustache.strip.close) { this.buffer += '~'; } this.buffer += mustache.trusting ? '}}}' : '}}'; } BlockStatement(block) { if (this.handledByOverride(block)) { return; } if (block.chained) { this.buffer += block.inverseStrip.open ? '{{~' : '{{'; this.buffer += 'else '; } else { this.buffer += block.openStrip.open ? '{{~#' : '{{#'; } this.Expression(block.path); this.Params(block.params); this.Hash(block.hash); if (block.program.blockParams.length) { this.BlockParams(block.program.blockParams); } if (block.chained) { this.buffer += block.inverseStrip.close ? '~}}' : '}}'; } else { this.buffer += block.openStrip.close ? '~}}' : '}}'; } this.Block(block.program); if (block.inverse) { if (!block.inverse.chained) { this.buffer += block.inverseStrip.open ? '{{~' : '{{'; this.buffer += 'else'; this.buffer += block.inverseStrip.close ? '~}}' : '}}'; } this.Block(block.inverse); } if (!block.chained) { this.buffer += block.closeStrip.open ? '{{~/' : '{{/'; this.Expression(block.path); this.buffer += block.closeStrip.close ? '~}}' : '}}'; } } BlockParams(blockParams) { this.buffer += ` as |${blockParams.join(' ')}|`; } ConcatStatement(concat) { if (this.handledByOverride(concat)) { return; } this.buffer += '"'; concat.parts.forEach(part => { if (part.type === 'TextNode') { this.TextNode(part, '"'); } else { this.Node(part); } }); this.buffer += '"'; } MustacheCommentStatement(comment) { if (this.handledByOverride(comment)) { return; } this.buffer += `{{!--${comment.value}--}}`; } ElementModifierStatement(mod) { if (this.handledByOverride(mod)) { return; } this.buffer += '{{'; this.Expression(mod.path); this.Params(mod.params); this.Hash(mod.hash); this.buffer += '}}'; } CommentStatement(comment) { if (this.handledByOverride(comment)) { return; } this.buffer += `<!--${comment.value}-->`; } PathExpression(path) { if (this.handledByOverride(path)) { return; } this.buffer += path.original; } SubExpression(sexp) { if (this.handledByOverride(sexp)) { return; } this.buffer += '('; this.Expression(sexp.path); this.Params(sexp.params); this.Hash(sexp.hash); this.buffer += ')'; } Params(params) { // TODO: implement a top level Params AST node (just like the Hash object) // so that this can also be overridden if (params.length) { params.forEach(param => { this.buffer += ' '; this.Expression(param); }); } } Hash(hash) { if (this.handledByOverride(hash, true)) { return; } hash.pairs.forEach(pair => { this.buffer += ' '; this.HashPair(pair); }); } HashPair(pair) { if (this.handledByOverride(pair)) { return; } this.buffer += pair.key; this.buffer += '='; this.Node(pair.value); } StringLiteral(str) { if (this.handledByOverride(str)) { return; } this.buffer += JSON.stringify(str.value); } BooleanLiteral(bool) { if (this.handledByOverride(bool)) { return; } this.buffer += String(bool.value); } NumberLiteral(number) { if (this.handledByOverride(number)) { return; } this.buffer += String(number.value); } UndefinedLiteral(node) { if (this.handledByOverride(node)) { return; } this.buffer += 'undefined'; } NullLiteral(node) { if (this.handledByOverride(node)) { return; } this.buffer += 'null'; } print(node) { let { options } = this; if (options.override) { let result = options.override(node, options); if (result !== undefined) { return result; } } this.buffer = ''; this.Node(node); return this.buffer; } } function build(ast, options = { entityEncoding: 'transformed' }) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JS users if (!ast) { return ''; } let printer = new Printer(options); return printer.print(ast); } const UNKNOWN_POSITION = Object.freeze({ line: 1, column: 0 }); const SYNTHETIC_LOCATION = Object.freeze({ source: '(synthetic)', start: UNKNOWN_POSITION, end: UNKNOWN_POSITION }); const NON_EXISTENT_LOCATION = Object.freeze({ source: '(nonexistent)', start: UNKNOWN_POSITION, end: UNKNOWN_POSITION }); const BROKEN_LOCATION = Object.freeze({ source: '(broken)', start: UNKNOWN_POSITION, end: UNKNOWN_POSITION }); /** * We have already computed the character position of this offset or span. */ const CHAR_OFFSET_KIND = 'CharPosition'; /** * This offset or span was instantiated with a Handlebars SourcePosition or SourceLocation. Its * character position will be computed on demand. */ const HBS_POSITION_KIND = 'HbsPosition'; /** * for (rare) situations where a node is created but there was no source location (e.g. the name * "default" in default blocks when the word "default" never appeared in source). This is used * by the internals when there is a legitimate reason for the internals to synthesize a node * with no location. */ const INTERNAL_SYNTHETIC_KIND = 'InternalsSynthetic'; /** * For situations where a node represents zero parts of the source (for example, empty arguments). * In general, we attempt to assign these nodes *some* position (empty arguments can be * positioned immediately after the callee), but it's not always possible */ const NON_EXISTENT_KIND = 'NonExistent'; /** * For situations where a source location was expected, but it didn't correspond to the node in * the source. This happens if a plugin creates broken locations. */ const BROKEN_KIND = 'Broken'; /** * These kinds describe spans that don't have a concrete location in the original source. */ function isInvisible(kind) { return kind !== CHAR_OFFSET_KIND && kind !== HBS_POSITION_KIND; } /** * This file implements the DSL used by span and offset in places where they need to exhaustively * consider all combinations of states (Handlebars offsets, character offsets and invisible/broken * offsets). * * It's probably overkill, but it makes the code that uses it clear. It could be refactored or * removed. */ const MatchAny = 'MATCH_ANY'; const IsInvisible = 'IS_INVISIBLE'; class WhenList { _whens; constructor(whens) { this._whens = whens; } first(kind) { for (const when of this._whens) { const value = when.match(kind); if (isPresentArray(value)) { return value[0]; } } return null; } } class When { _map = new Map(); get(pattern, or) { let value = this._map.get(pattern); if (value) { return value; } value = or(); this._map.set(pattern, value); return value; } add(pattern, out) { this._map.set(pattern, out); } match(kind) { const pattern = patternFor(kind); const out = []; const exact = this._map.get(pattern); const fallback = this._map.get(MatchAny); if (exact) { out.push(exact); } if (fallback) { out.push(fallback); } return out; } } function match(callback) { return callback(new Matcher()).validate(); } class Matcher { _whens = new When(); /** * You didn't exhaustively match all possibilities. */ validate() { return (left, right) => this.matchFor(left.kind, right.kind)(left, right); } matchFor(left, right) { const nesteds = this._whens.match(left); assert(isPresentArray(nesteds)); const callback = new WhenList(nesteds).first(right); return callback; } // This big block is the bulk of the heavy lifting in this file. It facilitates exhaustiveness // checking so that matchers can ensure they've actually covered all the cases (and TypeScript // will treat it as an exhaustive match). when(left, right, // eslint-disable-next-line @typescript-eslint/no-explicit-any callback) { this._whens.get(left, () => new When()).add(right, callback); return this; } } function patternFor(kind) { switch (kind) { case BROKEN_KIND: case INTERNAL_SYNTHETIC_KIND: case NON_EXISTENT_KIND: return IsInvisible; default: return kind; } } class SourceSlice { static synthetic(chars) { let offsets = SourceSpan.synthetic(chars); return new SourceSlice({ loc: offsets, chars: chars }); } static load(source, slice) { return new SourceSlice({ loc: SourceSpan.load(source, slice[1]), chars: slice[0] }); } chars; loc; constructor(options) { this.loc = options.loc; this.chars = options.chars; } getString() { return this.chars; } serialize() { return [this.chars, this.loc.serialize()]; } } /** * All spans have these details in common. */ /** * A `SourceSpan` object represents a span of characters inside of a template source. * * There are three kinds of `SourceSpan` objects: * * - `ConcreteSourceSpan`, which contains byte offsets * - `LazySourceSpan`, which contains `SourceLocation`s from the Handlebars AST, which can be * converted to byte offsets on demand. * - `InvisibleSourceSpan`, which represent source strings that aren't present in the source, * because: * - they were created synthetically * - their location is nonsensical (the span is broken) * - they represent nothing in the source (this currently happens only when a bug in the * upstream Handlebars parser fails to assign a location to empty blocks) * * At a high level, all `SourceSpan` objects provide: * * - byte offsets * - source in column and line format * * And you can do these operations on `SourceSpan`s: * * - collapse it to a `SourceSpan` representing its starting or ending position * - slice out some characters, optionally skipping some characters at the beginning or end * - create a new `SourceSpan` with a different starting or ending offset * * All SourceSpan objects implement `SourceLocation`, for compatibility. All SourceSpan * objects have a `toJSON` that emits `SourceLocation`, also for compatibility. * * For compatibility, subclasses of `AbstractSourceSpan` must implement `locDidUpdate`, which * happens when an AST plugin attempts to modify the `start` or `end` of a span directly. * * The goal is to avoid creating any problems for use-cases like AST Explorer. */ class SourceSpan { static get NON_EXISTENT() { return new InvisibleSpan(NON_EXISTENT_KIND, NON_EXISTENT_LOCATION).wrap(); } static load(source, serialized) { if (typeof serialized === 'number') { return SourceSpan.forCharPositions(source, serialized, serialized); } else if (typeof serialized === 'string') { return SourceSpan.synthetic(serialized); } else if (Array.isArray(serialized)) { return SourceSpan.forCharPositions(source, serialized[0], serialized[1]); } else if (serialized === NON_EXISTENT_KIND) { return SourceSpan.NON_EXISTENT; } else if (serialized === BROKEN_KIND) { return SourceSpan.broken(BROKEN_LOCATION); } assertNever(serialized); } static forHbsLoc(source, loc) { const start = new HbsPosition(source, loc.start); const end = new HbsPosition(source, loc.end); return new HbsSpan(source, { start, end }, loc).wrap(); } static forCharPositions(source, startPos, endPos) { const start = new CharPosition(source, startPos); const end = new CharPosition(source, endPos); return new CharPositionSpan(source, { start, end }).wrap(); } static synthetic(chars) { return new InvisibleSpan(INTERNAL_SYNTHETIC_KIND, NON_EXISTENT_LOCATION, chars).wrap(); } static broken(pos = BROKEN_LOCATION) { return new InvisibleSpan(BROKEN_KIND, pos).wrap(); } isInvisible; constructor(data) { this.data = data; this.isInvisible = isInvisible(data.kind); } getStart() { return this.data.getStart().wrap(); } getEnd() { return this.data.getEnd().wrap(); } get loc() { const span = this.data.toHbsSpan(); return span === null ? BROKEN_LOCATION : span.toHbsLoc(); } get module() { return this.data.getModule(); } /** * Get the starting `SourcePosition` for this `SourceSpan`, lazily computing it if needed. */ get startPosition() { return this.loc.start; } /** * Get the ending `SourcePosition` for this `SourceSpan`, lazily computing it if needed. */ get endPosition() { return this.loc.end; } /** * Support converting ASTv1 nodes into a serialized format using JSON.stringify. */ toJSON() { return this.loc; } /** * Create a new span with the current span's end and a new beginning. */ withStart(other) { return span(other.data, this.data.getEnd()); } /** * Create a new span with the current span's beginning and a new ending. */ withEnd(other) { return span(this.data.getStart(), other.data); } asString() { return this.data.asString(); } /** * Convert this `SourceSpan` into a `SourceSlice`. */ toSlice(expected) { const chars = this.data.asString(); assert(expected === undefined || expected === chars, `unexpectedly found ${JSON.stringify(chars)} when slicing source, ` + `but expected ${JSON.stringify(expected)}`); return new SourceSlice({ loc: this, chars: expected || chars }); } /** * For compatibility with SourceLocation in AST plugins * * @deprecated use startPosition instead */ get start() { return this.loc.start; } /** * For compatibility with SourceLocation in AST plugins * * @deprecated use withStart instead */ set start(position) { this.data.locDidUpdate({ start: position }); } /** * For compatibility with SourceLocation in AST plugins * * @deprecated use endPosition instead */ get end() { return this.loc.end; } /** * For compatibility with SourceLocation in AST plugins * * @deprecated use withEnd instead */ set end(position) { this.data.locDidUpdate({ end: position }); } /** * For compatibility with SourceLocation in AST plugins * * @deprecated use module instead */ get source() { return this.module; } collapse(where) { switch (where) { case 'start': return this.getStart().collapsed(); case 'end': return this.getEnd().collapsed(); } } extend(other) { return span(this.data.getStart(), other.data.getEnd()); } serialize() { return this.data.serialize(); } slice({ skipStart = 0, skipEnd = 0 }) { return span(this.getStart().move(skipStart).data, this.getEnd().move(-skipEnd).data); } sliceStartChars({ skipStart = 0, chars }) { return span(this.getStart().move(skipStart).data, this.getStart().move(skipStart + chars).data); } sliceEndChars({ skipEnd = 0, chars }) { return span(this.getEnd().move(skipEnd - chars).data, this.getStart().move(-skipEnd).data); } } class CharPositionSpan { kind = CHAR_OFFSET_KIND; #locPosSpan = null; constructor(source, charPositions) { this.source = source; this.charPositions = charPositions; } wrap() { return new SourceSpan(this); } asString() { return this.source.slice(this.charPositions.start.charPos, this.charPositions.end.charPos); } getModule() { return this.source.module; } getStart() { return this.charPositions.start; } getEnd() { return this.charPositions.end; } locDidUpdate() { } toHbsSpan() { let locPosSpan = this.#locPosSpan; if (locPosSpan === null) { const start = this.charPositions.start.toHbsPos(); const end = this.charPositions.end.toHbsPos(); if (start === null || end === null) { locPosSpan = this.#locPosSpan = BROKEN; } else { locPosSpan = this.#locPosSpan = new HbsSpan(this.source, { start, end }); } } return locPosSpan === BROKEN ? null : locPosSpan; } serialize() { const { start: { charPos: start }, end: { charPos: end } } = this.charPositions; if (start === end) { return start; } else { return [start, end]; } } toCharPosSpan() { return this; } } class HbsSpan { kind = HBS_POSITION_KIND; #charPosSpan = null; // the source location from Handlebars + AST Plugins -- could be wrong #providedHbsLoc; constructor(source, hbsPositions, providedHbsLoc = null) { this.source = source; this.hbsPositions = hbsPositions; this.#providedHbsLoc = providedHbsLoc; } serialize() { const charPos = this.toCharPosSpan(); return charPos === null ? BROKEN_KIND : charPos.wrap().serialize(); } wrap() { return new SourceSpan(this); } updateProvided(pos, edge) { if (this.#providedHbsLoc) { this.#providedHbsLoc[edge] = pos; } // invalidate computed character offsets this.#charPosSpan = null; this.#providedHbsLoc = { start: pos, end: pos }; } locDidUpdate({ start, end }) { if (start !== undefined) { this.updateProvided(start, 'start'); this.hbsPositions.start = new HbsPosition(this.source, start, null); } if (end !== undefined) { this.updateProvided(end, 'end'); this.hbsPositions.end = new HbsPosition(this.source, end, null); } } asString() { const span = this.toCharPosSpan(); return span === null ? '' : span.asString(); } getModule() { return this.source.module; } getStart() { return this.hbsPositions.start; } getEnd() { return this.hbsPositions.end; } toHbsLoc() { return { start: this.hbsPositions.start.hbsPos, end: this.hbsPositions.end.hbsPos }; } toHbsSpan() { return this; } toCharPosSpan() { let charPosSpan = this.#charPosSpan; if (charPosSpan === null) { const start = this.hbsPositions.start.toCharPos(); const end = this.hbsPositions.end.toCharPos(); if (start && end) { charPosSpan = this.#charPosSpan = new CharPositionSpan(this.source, { start, end }); } else { charPosSpan = this.#charPosSpan = BROKEN; return null; } } return charPosSpan === BROKEN ? null : charPosSpan; } } class InvisibleSpan { constructor(kind, // whatever was provided, possibly broken loc, // if the span represents a synthetic string string = null) { this.kind = kind; this.loc = loc; this.string = string; } serialize() { switch (this.kind) { case BROKEN_KIND: case NON_EXISTENT_KIND: return this.kind; case INTERNAL_SYNTHETIC_KIND: return this.string || ''; } } wrap() { return new SourceSpan(this); } asString() { return this.string || ''; } locDidUpdate({ start, end }) { if (start !== undefined) { this.loc.start = start; } if (end !== undefined) { this.loc.end = end; } } getModule() { // TODO: Make this reflect the actual module this span originated from return 'an unknown module'; } getStart() { return new InvisiblePosition(this.kind, this.loc.start); } getEnd() { return new InvisiblePosition(this.kind, this.loc.end); } toCharPosSpan() { return this; } toHbsSpan() { return null; } toHbsLoc() { return BROKEN_LOCATION; } } const span = match(m => m.when(HBS_POSITION_KIND, HBS_POSITION_KIND, (left, right) => new HbsSpan(left.source, { start: left, end: right }).wrap()).when(CHAR_OFFSET_KIND, CHAR_OFFSET_KIND, (left, right) => new CharPositionSpan(left.source, { start: left, end: right }).wrap()).when(CHAR_OFFSET_KIND, HBS_POSITION_KIND, (left, right) => { const rightCharPos = right.toCharPos(); if (rightCharPos === null) { return new InvisibleSpan(BROKEN_KIND, BROKEN_LOCATION).wrap(); } else { return span(left, rightCharPos); } }).when(HBS_POSITION_KIND, CHAR_OFFSET_KIND, (left, right) => { const leftCharPos = left.toCharPos(); if (leftCharPos === null) { return new InvisibleSpan(BROKEN_KIND, BROKEN_LOCATION).wrap(); } else { return span(leftCharPos, right); } }).when(IsInvisible, MatchAny, left => new InvisibleSpan(left.kind, BROKEN_LOCATION).wrap()).when(MatchAny, IsInvisible, (_, right) => new InvisibleSpan(right.kind, BROKEN_LOCATION).wrap())); // `string` includes NON_EXISTENT_KIND and BROKEN_KIND /** * All positions have these details in common. Most notably, all three kinds of positions can * must be able to attempt to convert themselves into {@see CharPosition}. */ /** * Used to indicate that an attempt to convert a `SourcePosition` to a character offset failed. It * is separate from `null` so that `null` can be used to indicate that the computation wasn't yet * attempted (and therefore to cache the failure) */ const BROKEN = 'BROKEN'; /** * A `SourceOffset` represents a single position in the source. * * There are three kinds of backing data for `SourceOffset` objects: * * - `CharPosition`, which contains a character offset into the raw source string * - `HbsPosition`, which contains a `SourcePosition` from the Handlebars AST, which can be * converted to a `CharPosition` on demand. * - `InvisiblePosition`, which represents a position not in source (@see {InvisiblePosition}) */ class SourceOffset { /** * Create a `SourceOffset` from a Handlebars `SourcePosition`. It's stored as-is, and converted * into a character offset on demand, which avoids unnecessarily computing the offset of every * `SourceLocation`, but also means that broken `SourcePosition`s are not always detected. */ static forHbsPos(source, pos) { return new HbsPosition(source, pos, null).wrap(); } /** * Create a `SourceOffset` that corresponds to a broken `SourcePosition`. This means that the * calling code determined (or knows) that the `SourceLocation` doesn't correspond correctly to * any part of the source. */ static broken(pos = UNKNOWN_POSITION) { return new InvisiblePosition(BROKEN_KIND, pos).wrap(); } constructor(data) { this.data = data; } /** * Get the character offset for this `SourceOffset`, if possible. */ get offset() { const charPos = this.data.toCharPos(); return charPos === null ? null : charPos.offset; } /** * Compare this offset with another one. * * If both offsets are `HbsPosition`s, they're equivalent as long as their lines and columns are * the same. This avoids computing offsets unnecessarily. * * Otherwise, two `SourceOffset`s are equivalent if their successfully computed character offsets * are the same. */ eql(right) { return eql(this.data, right.data); } /** * Create a span that starts from this source offset and ends with another source offset. Avoid * computing character offsets if both `SourceOffset`s are still lazy. */ until(other) { return span(this.data, other.data); } /** * Create a `SourceOffset` by moving the character position represented by this source offset * forward or backward (if `by` is negative), if possible. * * If this `SourceOffset` can't compute a valid character offset, `move` returns a broken offset. * * If the resulting character offset is less than 0 or greater than the size of the source, `move` * returns a broken offset. */ move(by) { const charPos = this.data.toCharPos(); if (charPos === null) { return SourceOffset.broken(); } else { const result = charPos.offset + by; if (charPos.source.validate(result)) { return new CharPosition(charPos.source, result).wrap(); } else { return SourceOffset.broken(); } } } /** * Create a new `SourceSpan` that represents a collapsed range at this source offset. Avoid * computing the character offset if it has not already been computed. */ collapsed() { return span(this.data, this.data); } /** * Convert this `SourceOffset` into a Handlebars {@see SourcePosition} for compatibility with * existing plugins. */ toJSON() { return this.data.toJSON(); } } class CharPosition { kind = CHAR_OFFSET_KIND; /** Computed from char offset */ _locPos = null; constructor(source, charPos) { this.source = source; this.charPos = charPos; } /** * This is already a `CharPosition`. * * {@see HbsPosition} for the alternative. */ toCharPos() { return this; } /** * Produce a Handlebars {@see SourcePosition} for this `CharPosition`. If this `CharPosition` was * computed using {@see SourceOffset#move}, this will compute the `SourcePosition` for the offset. */ toJSON() { const hbs = this.toHbsPos(); return hbs === null ? UNKNOWN_POSITION : hbs.toJSON(); } wrap() { return new SourceOffset(this); } /** * A `CharPosition` always has an offset it can produce without any additional computation. */ get offset() { return this.charPos; } /** * Convert the current character offset to an `HbsPosition`, if it was not already computed. Once * a `CharPosition` has computed its `HbsPosition`, it will not need to do compute it again, and * the same `CharPosition` is retained when used as one of the ends of a `SourceSpan`, so * computing the `HbsPosition` should be a one-time operation. */ toHbsPos() { let locPos = this._locPos; if (locPos === null) { const hbsPos = this.source.hbsPosFor(this.charPos); if (hbsPos === null) { this._locPos = locPos = BROKEN; } else { this._locPos = locPos = new HbsPosition(this.source, hbsPos, this.charPos); } } return locPos === BROKEN ? null : locPos; } } class HbsPosition { kind = HBS_POSITION_KIND; _charPos; constructor(source, hbsPos, charPos = null) { this.source = source; this.hbsPos = hbsPos; this._charPos = charPos === null ? null : new CharPosition(source, charPos); } /** * Lazily compute the character offset from the {@see SourcePosition}. Once an `HbsPosition` has * computed its `CharPosition`, it will not need to do compute it again, and the same * `HbsPosition` is retained when used as one of the ends of a `SourceSpan`, so computing the * `CharPosition` should be a one-time operation. */ toCharPos() { let charPos = this._charPos; if (charPos === null) { const charPosNumber = this.source.charPosFor(this.hbsPos); if (charPosNumber === null) { this._charPos = charPos = BROKEN; } else { this._charPos = charPos = new CharPosition(this.source, charPosNumber); } } return charPos === BROKEN ? null : charPos; } /** * Return the {@see SourcePosition} that this `HbsPosition` was instantiated with. This operation * does not need to compute anything. */ toJSON() { return this.hbsPos; } wrap() { return new SourceOffset(this); } /** * This is already an `HbsPosition`. * * {@see CharPosition} for the alternative. */ toHbsPos() { return this; } } class InvisiblePosition { constructor(kind, // whatever was provided, possibly broken pos) { this.kind = kind; this.pos = pos; } /** * A broken position cannot be turned into a {@see CharacterPosition}. */ toCharPos() { return null; } /** * The serialization of an `InvisiblePosition is whatever Handlebars {@see SourcePosition} was * originally identified as broken, non-existent or synthetic. * * If an `InvisiblePosition` never had an source offset at all, this method returns * {@see UNKNOWN_POSITION} for compatibility. */ toJSON() { return this.pos; } wrap() { return new SourceOffset(this); } get offset() { return null; } } /** * Compare two {@see AnyPosition} and determine whether they are equal. * * @see {SourceOffset#eql} */ const eql = match(m => m.when(HBS_POSITION_KIND, HBS_POSITION_KIND, ({ hbsPos: left }, { hbsPos: right }) => left.column === right.column && left.line === right.line).when(CHAR_OFFSET_KIND, CHAR_OFFSET_KIND, ({ charPos: left }, { charPos: right }) => left === right).when(CHAR_OFFSET_KIND, HBS_POSITION_KIND, ({ offset: left }, right) => left === right.toCharPos()?.offset).when(HBS_POSITION_KIND, CHAR_OFFSET_KIND, (left, { offset: right }) => left.toCharPos()?.offset === right).when(MatchAny, MatchAny, () => false)); class Source { static from(source, options = {}) { return new Source(source, options.meta?.moduleName); } /** Char offset of each `\n` in the source. */ #newlineOffsets; constructor(source, module = 'an unknown module') { this.source = source; this.module = module; setLocalDebugType('syntax:source', this); this.#newlineOffsets = computeNewlineOffsets(source); } /** * Validate that the character offset represents a position in the source string. */ validate(offset) { return offset >= 0 && offset <= this.source.length; } slice(start, end) { return this.source.slice(start, end); } offsetFor(line, column) { return SourceOffset.forHbsPos(this, { line, column }); } spanFor({ start, end }) { return SourceSpan.forHbsLoc(this, { start: { line: start.line, column: start.column }, end: { line: end.line, column: end.column } }); } hbsPosFor(offset) { if (offset < 0 || offset > this.source.length) return null; const lineIdx = lowerBound(this.#newlineOffsets, offset); return { line: lineIdx + 1, column: offset - this.#lineStartFor(lineIdx) }; } charPosFor({ line, column }) { const lineIdx = line - 1; // Valid lines are [0, newlineOffsets.length]. Anything else has no offset. if (lineIdx < 0 || lineIdx > this.#newlineOffsets.length || column < 0) return null; const lineStart = lineIdx === 0 ? 0 : this.#newlineOffsets[lineIdx - 1] + 1; const lineEnd = this.#newlineOffsets[lineIdx] ?? this.source.length; const target = lineStart + column; if (target <= lineEnd) { return target; } return lineEnd; } #lineStartFor(lineIdx) { if (lineIdx === 0) return 0; const prevNl = this.#newlineOffsets[lineIdx - 1]; return prevNl === undefined ? 0 : prevNl + 1; } } function computeNewlineOffsets(source) { const offsets = []; for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1)) { offsets.push(i); } return offsets; } /** Lower-bound binary search: smallest i with arr[i] >= target, else arr.length. */ function lowerBound(arr, target) { let lo = 0; let hi = arr.length; while (lo < hi) { const mid = lo + hi >>> 1; // mid is in [lo, hi) so always a valid index. if (arr[mid] < target) lo = mid + 1;else hi = mid; } return lo; } class SpanList { static range(span, fallback = SourceSpan.NON_EXISTENT) { return new SpanList(span.map(loc)).getRangeOffset(fallback); } _span; constructor(span = []) { this._span = span; } add(offset) { this._span.push(offset); } getRangeOffset(fallback) { if (isPresentArray(this._span)) { let first = getFirst(this._span); let last = getLast(this._span); return first.extend(last); } else { return fallback; } } } function loc(span) { if (Array.isArray(span)) { let first = getFirst(span); let last = getLast(span); return loc(first).extend(loc(last)); } else if (span instanceof SourceSpan) { return span; } else { return span.loc; } } function hasSpan(span) { if (Array.isArray(span) && span.length === 0) { return false; } return true; } function maybeLoc(location, fallback) { if (hasSpan(location)) { return loc(location); } else { return fallback; } } 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(); 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'); } 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 (