diff --git a/packages/core/src/config/environment.ts b/packages/core/src/config/environment.ts index 653b6178c..f6d9923dc 100644 --- a/packages/core/src/config/environment.ts +++ b/packages/core/src/config/environment.ts @@ -94,11 +94,15 @@ export class GlintEnvironment { * they support. */ public getStandaloneTemplateConfig(): - | Pick + | Pick< + GlintTemplateConfig, + 'typesModule' | 'specialForms' | 'preprocess' | 'postprocessAst' | 'mapTemplateContent' + > | undefined { if (this.standaloneTemplateConfig) { - let { typesModule, specialForms } = this.standaloneTemplateConfig; - return { typesModule, specialForms }; + let { typesModule, specialForms, preprocess, postprocessAst, mapTemplateContent } = + this.standaloneTemplateConfig; + return { typesModule, specialForms, preprocess, postprocessAst, mapTemplateContent }; } } @@ -189,7 +193,6 @@ function loadMergedEnvironmentConfig( } let config = envFunction(envUserConfig ?? {}) as GlintEnvironmentConfig; - if (config.template) { if (template) { throw new SilentError( diff --git a/packages/core/src/config/types.cts b/packages/core/src/config/types.cts index a0beabe09..ff3a8c174 100644 --- a/packages/core/src/config/types.cts +++ b/packages/core/src/config/types.cts @@ -1,4 +1,5 @@ import type * as ts from 'typescript'; +import { AST } from '@glimmer/syntax'; // This file is explicitly `.cts` so that environment packages written // in CJS can import its types from `@glint/core/config-types`. @@ -96,6 +97,12 @@ export type PathCandidateWithDeferral = { }; export type GlintTemplateConfig = { + preprocess?: ( + templateInfo: { contents: string; filename: string }, + args: { globals?: string[]; preamble?: string[] } + ) => { globals: string[]; preamble: string[]; template: string }; + postprocessAst?: (ast: AST.Template) => AST.Template; + mapTemplateContent?: Record; typesModule: string; specialForms?: { [global: string]: GlintSpecialForm }; getPossibleTemplatePaths(scriptPath: string): Array; diff --git a/packages/core/src/transform/template/inlining/companion-file.ts b/packages/core/src/transform/template/inlining/companion-file.ts index e8946211e..9a4d95d7a 100644 --- a/packages/core/src/transform/template/inlining/companion-file.ts +++ b/packages/core/src/transform/template/inlining/companion-file.ts @@ -31,11 +31,15 @@ export function calculateCompanionTemplateSpans( return { errors, directives, partialSpans }; } - let { typesModule, specialForms } = templateConfig; + let { typesModule, specialForms, preprocess, postprocessAst, mapTemplateContent } = + templateConfig; let useJsDoc = environment.isUntypedScript(script.filename); let targetNode = findCompanionTemplateTarget(ts, ast); if (targetNode && ts.isClassLike(targetNode)) { - let rewriteResult = templateToTypescript(template.contents, { + let rewriteResult = templateToTypescript(template, { + preprocess, + postprocessAst, + mapTemplateContent, typesModule, specialForms, useJsDoc, @@ -56,7 +60,10 @@ export function calculateCompanionTemplateSpans( : `({} as unknown as typeof import('./${moduleName}').default)`; } - let rewriteResult = templateToTypescript(template.contents, { + let rewriteResult = templateToTypescript(template, { + preprocess, + postprocessAst, + mapTemplateContent, typesModule, backingValue, specialForms, diff --git a/packages/core/src/transform/template/map-template-contents.ts b/packages/core/src/transform/template/map-template-contents.ts index 619bde78b..5064dccac 100644 --- a/packages/core/src/transform/template/map-template-contents.ts +++ b/packages/core/src/transform/template/map-template-contents.ts @@ -118,6 +118,7 @@ export type EmbeddingSyntax = { export type MapTemplateContentsOptions = { embeddingSyntax: EmbeddingSyntax; + postprocessAst?: (ast: AST.Template) => AST.Template; }; /** @@ -128,7 +129,7 @@ export type MapTemplateContentsOptions = { */ export function mapTemplateContents( template: string, - { embeddingSyntax }: MapTemplateContentsOptions, + { embeddingSyntax, postprocessAst }: MapTemplateContentsOptions, callback: (ast: AST.Template | null, mapper: Mapper) => void ): RewriteResult { let ast: AST.Template | null = null; @@ -136,6 +137,7 @@ export function mapTemplateContents( let lineOffsets = calculateLineOffsets(template, embeddingSyntax.prefix.length); try { ast = preprocess(template); + ast = postprocessAst?.(ast) || ast; } catch (error) { let message = getErrorMessage(error); let location: Range | undefined; diff --git a/packages/core/src/transform/template/template-to-typescript.ts b/packages/core/src/transform/template/template-to-typescript.ts index 5157b4c51..9243e1562 100644 --- a/packages/core/src/transform/template/template-to-typescript.ts +++ b/packages/core/src/transform/template/template-to-typescript.ts @@ -4,10 +4,17 @@ import { EmbeddingSyntax, mapTemplateContents, RewriteResult } from './map-templ import ScopeStack from './scope-stack.js'; import { GlintEmitMetadata, GlintSpecialForm } from '@glint/core/config-types'; import { TextContent } from './mapping-tree.js'; +import { SourceFile } from './transformed-module.js'; const SPLATTRIBUTES = '...attributes'; export type TemplateToTypescriptOptions = { + preprocess?: ( + templateInfo: SourceFile, + args: Pick + ) => Pick & { template: string }; + postprocessAst?: (ast: AST.Template) => AST.Template; + mapTemplateContent?: Record; typesModule: string; meta?: GlintEmitMetadata | undefined; globals?: Array | undefined; @@ -24,8 +31,19 @@ export type TemplateToTypescriptOptions = { * the original and transformed contents. */ export function templateToTypescript( - originalTemplate: string, - { + templateInfo: SourceFile | string, + args: TemplateToTypescriptOptions +): RewriteResult { + let result = {}; + let preprocessingError = ''; + try { + if ((templateInfo as SourceFile).contents) { + result = args.preprocess?.(templateInfo as SourceFile, args) || {}; + } + } catch (e) { + preprocessingError = (e as Error).message; + } + let { typesModule, globals, meta, @@ -34,1266 +52,1341 @@ export function templateToTypescript( embeddingSyntax = { prefix: '', suffix: '' }, specialForms = {}, useJsDoc = false, - }: TemplateToTypescriptOptions -): RewriteResult { + } = Object.assign({}, args, result); + let originalTemplate = (templateInfo as SourceFile).contents ?? (templateInfo as string); let { prefix, suffix } = embeddingSyntax; let template = `${''.padEnd(prefix.length)}${originalTemplate}${''.padEnd(suffix.length)}`; - return mapTemplateContents(originalTemplate, { embeddingSyntax }, (ast, mapper) => { - let { emit, record, rangeForLine, rangeForNode } = mapper; - let scope = new ScopeStack([]); + return mapTemplateContents( + originalTemplate, + { embeddingSyntax, postprocessAst: args.postprocessAst }, + (ast, mapper) => { + let { emit, record, rangeForLine, rangeForNode } = mapper; + let scope = new ScopeStack([]); - emitTemplateBoilerplate(() => { - for (let statement of ast?.body ?? []) { - emitTopLevelStatement(statement); + if (preprocessingError) { + record.error(preprocessingError, { start: 0, end: 0 }); } - }); - return; + const emitters = { + emitTemplateBoilerplate, + emitTopLevelStatement, + emitTopLevelTextNode, + emitComment, + emitTopLevelMustacheStatement, + emitBlockStatement, + emitElementNode, + emitMustacheStatement, + emitYieldExpression, + emitIfNotExpression, + emitObjectExpression, + emitArrayExpression, + emitBindInvokableExpression, + emitIfExpression, + emitBinaryOperatorExpression, + emitLogicalExpression, + emitUnaryOperatorExpression, + emitHashKey, + emitExpression, + emitPath, + emitSubExpression, + emitLiteral, + emitComponent, + emitConcatStatement, + emitPlainElement, + emitBlockContents, + emitPathContents, + emitAttributesAndModifiers, + emitSplattributes, + emitPlainAttributes, + emitModifiers, + emitArgs, + emitSpecialFormExpression, + emitResolve, + emitIfStatement, + emitUnlessStatement, + emitSpecialFormStatement, + emitBlock, + emitPropertyAccesss, + emitIdentifierReference, + emitIdentifierString, + }; + + const originalEmitters = Object.assign({}, emitters); + + Object.keys(args.mapTemplateContent || {}).forEach((key) => { + const k: keyof typeof emitters = key as any; + if (args.mapTemplateContent![k]) { + emitters[k] = function (...emitterArgs: any) { + return args.mapTemplateContent![k]( + originalEmitters[k], + originalEmitters, + mapper, + ...emitterArgs + ); + } as any; + } + }); - function emitTopLevelStatement(node: AST.TopLevelStatement): void { - switch (node.type) { - case 'Block': - case 'PartialStatement': - throw new Error(`Internal error: unexpected top-level ${node.type}`); + emitters.emitTemplateBoilerplate(() => { + for (let statement of ast?.body ?? []) { + emitters.emitTopLevelStatement(statement); + } + }); - case 'TextNode': - return emitTopLevelTextNode(node); + return; - case 'CommentStatement': - case 'MustacheCommentStatement': - return emitComment(node); + function emitTopLevelStatement(node: AST.TopLevelStatement): void { + switch (node.type) { + case 'Block': + case 'PartialStatement': + throw new Error(`Internal error: unexpected top-level ${node.type}`); - case 'MustacheStatement': - return emitTopLevelMustacheStatement(node); + case 'TextNode': + return emitters.emitTopLevelTextNode(node); - case 'BlockStatement': - return emitBlockStatement(node); + case 'CommentStatement': + case 'MustacheCommentStatement': + return emitters.emitComment(node); - case 'ElementNode': - return emitElementNode(node); + case 'MustacheStatement': + return emitters.emitTopLevelMustacheStatement(node); - default: - unreachable(node); - } - } + case 'BlockStatement': + return emitters.emitBlockStatement(node); - function emitTemplateBoilerplate(emitBody: () => void): void { - if (meta?.prepend) { - emit.text(meta.prepend); - } + case 'ElementNode': + return emitters.emitElementNode(node); - if (useJsDoc) { - emit.text(`(/** @type {typeof import("${typesModule}")} */ ({}))`); - } else { - emit.text(`({} as typeof import("${typesModule}"))`); + default: + unreachable(node); + } } - if (backingValue) { - emit.text(`.templateForBackingValue(${backingValue}, function(𝚪`); - } else { - emit.text(`.templateExpression(function(𝚪`); - } + function emitTemplateBoilerplate(emitBody: () => void): void { + if (meta?.prepend) { + emit.text(meta.prepend); + } - if (useJsDoc) { - emit.text(`, /** @type {typeof import("${typesModule}")} */ χ) {`); - } else { - emit.text(`, χ: typeof import("${typesModule}")) {`); - } + if (useJsDoc) { + emit.text(`(/** @type {typeof import("${typesModule}")} */ ({}))`); + } else { + emit.text(`({} as typeof import("${typesModule}"))`); + } - emit.newline(); - emit.indent(); + if (backingValue) { + emit.text(`.templateForBackingValue(${backingValue}, function(𝚪`); + } else { + emit.text(`.templateExpression(function(𝚪`); + } - for (let line of preamble) { - emit.text(line); - emit.newline(); - } + if (useJsDoc) { + emit.text(`, /** @type {typeof import("${typesModule}")} */ χ) {`); + } else { + emit.text(`, χ: typeof import("${typesModule}")) {`); + } - if (ast) { - emit.forNode(ast, emitBody); - } + emit.newline(); + emit.indent(); - // Ensure the context and lib variables are always consumed to prevent - // an unused variable warning - emit.text('𝚪; χ;'); - emit.newline(); + for (let line of preamble) { + emit.text(line); + emit.newline(); + } - emit.dedent(); - emit.text('})'); + if (ast) { + emit.forNode(ast, emitBody); + } - if (meta?.append) { - emit.text(meta.append); - } - } + // Ensure the context and lib variables are always consumed to prevent + // an unused variable warning + emit.text('𝚪; χ;'); + emit.newline(); - function emitTopLevelTextNode(node: AST.TextNode): void { - // We don't need to emit any code for text nodes, but we want to track - // where they are so we know NOT to try and suggest global completions - // in "text space" where it wouldn't make sense. - emit.nothing(node, new TextContent()); - } + emit.dedent(); + emit.text('})'); - function emitComment(node: AST.MustacheCommentStatement | AST.CommentStatement): void { - let text = node.value.trim(); - let match = /^@glint-([a-z-]+)/i.exec(text); - if (!match) { - return emit.nothing(node); + if (meta?.append) { + emit.text(meta.append); + } } - let kind = match[1]; - let location = rangeForNode(node); - if (kind === 'ignore') { - record.directive(kind, location, rangeForLine(node.loc.end.line + 1)); - } else if (kind === 'expect-error') { - record.directive(kind, location, rangeForLine(node.loc.end.line + 1)); - } else if (kind === 'nocheck') { - record.directive('ignore', location, { start: 0, end: template.length - 1 }); - } else { - record.error(`Unknown directive @glint-${kind}`, location); + function emitTopLevelTextNode(node: AST.TextNode): void { + // We don't need to emit any code for text nodes, but we want to track + // where they are so we know NOT to try and suggest global completions + // in "text space" where it wouldn't make sense. + emit.nothing(node, new TextContent()); } - emit.forNode(node, () => { - emit.text(`// @glint-${kind}`); - emit.newline(); - }); - } - - // Captures the context in which a given invocation (i.e. a mustache or - // sexpr) is being performed. Certain keywords like `yield` are only - // valid in certain positions, and whether a param-less mustache implicitly - // evaluates a helper or returns it also depends on the location it's in. - type InvokePosition = 'top-level' | 'attr' | 'arg' | 'concat' | 'sexpr'; + function emitComment(node: AST.MustacheCommentStatement | AST.CommentStatement): void { + let text = node.value.trim(); + let match = /^@glint-([a-z-]+)/i.exec(text); + if (!match) { + return emit.nothing(node); + } - function emitTopLevelMustacheStatement(node: AST.MustacheStatement): void { - emitMustacheStatement(node, 'top-level'); - emit.text(';'); - emit.newline(); - } + let kind = match[1]; + let location = rangeForNode(node); + if (kind === 'ignore') { + record.directive(kind, location, rangeForLine(node.loc.end.line + 1)); + } else if (kind === 'expect-error') { + record.directive(kind, location, rangeForLine(node.loc.end.line + 1)); + } else if (kind === 'nocheck') { + record.directive('ignore', location, { start: 0, end: template.length - 1 }); + } else { + record.error(`Unknown directive @glint-${kind}`, location); + } - function emitSpecialFormExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression, - position: InvokePosition - ): void { - if (formInfo.requiresConsumption) { - emit.text('(χ.noop('); - emitExpression(node.path); - emit.text('), '); + emit.forNode(node, () => { + emit.text(`// @glint-${kind}`); + emit.newline(); + }); } - switch (formInfo.form) { - case 'yield': - emitYieldExpression(formInfo, node, position); - break; + // Captures the context in which a given invocation (i.e. a mustache or + // sexpr) is being performed. Certain keywords like `yield` are only + // valid in certain positions, and whether a param-less mustache implicitly + // evaluates a helper or returns it also depends on the location it's in. + type InvokePosition = 'top-level' | 'attr' | 'arg' | 'concat' | 'sexpr'; - case 'if': - emitIfExpression(formInfo, node); - break; + function emitTopLevelMustacheStatement(node: AST.MustacheStatement): void { + emitters.emitMustacheStatement(node, 'top-level'); + emit.text(';'); + emit.newline(); + } - case 'if-not': - emitIfNotExpression(formInfo, node); - break; + function emitSpecialFormExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression, + position: InvokePosition + ): void { + if (formInfo.requiresConsumption) { + emit.text('(χ.noop('); + emitExpression(node.path); + emit.text('), '); + } - case 'object-literal': - emitObjectExpression(formInfo, node); - break; + switch (formInfo.form) { + case 'yield': + emitters.emitYieldExpression(formInfo, node, position); + break; - case 'array-literal': - emitArrayExpression(formInfo, node); - break; + case 'if': + emitters.emitIfExpression(formInfo, node); + break; - case 'bind-invokable': - emitBindInvokableExpression(formInfo, node, position); - break; + case 'if-not': + emitters.emitIfNotExpression(formInfo, node); + break; - case '===': - case '!==': - emitBinaryOperatorExpression(formInfo, node); - break; + case 'object-literal': + emitters.emitObjectExpression(formInfo, node); + break; - case '&&': - case '||': - emitLogicalExpression(formInfo, node); - break; + case 'array-literal': + emitters.emitArrayExpression(formInfo, node); + break; - case '!': - emitUnaryOperatorExpression(formInfo, node); - break; + case 'bind-invokable': + emitters.emitBindInvokableExpression(formInfo, node, position); + break; - default: - record.error(`${formInfo.name} is not valid in inline form`, rangeForNode(node)); - emit.text('undefined'); - } + case '===': + case '!==': + emitters.emitBinaryOperatorExpression(formInfo, node); + break; - if (formInfo.requiresConsumption) { - emit.text(')'); - } - } + case '&&': + case '||': + emitters.emitLogicalExpression(formInfo, node); + break; - function emitBindInvokableExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression, - position: InvokePosition - ): void { - emit.forNode(node, () => { - assert( - node.params.length >= 1, - () => `{{${formInfo.name}}} requires at least one positional argument` - ); + case '!': + emitters.emitUnaryOperatorExpression(formInfo, node); + break; - assert( - node.params.length === 1 || node.hash.pairs.length === 0, - () => - `Due to TypeScript inference limitations, {{${formInfo.name}}} can only pre-bind ` + - `either named or positional arguments in a single pass. You can instead break the ` + - `binding into two parts, e.g. ` + - `{{${formInfo.name} (${formInfo.name} ... posA posB) namedA=true namedB=true}}` - ); + default: + record.error(`${formInfo.name} is not valid in inline form`, rangeForNode(node)); + emit.text('undefined'); + } - if (position === 'top-level') { - emit.text('χ.emitContent('); + if (formInfo.requiresConsumption) { + emit.text(')'); } + } - // Treat the first argument to a bind-invokable expression (`{{component}}`, - // `{{helper}}`, etc) as special: we wrap it in a `resolve` call so that the - // type machinery for those helpers can always operate against the resolved value. - // We wrap the `resolveForBind` call in an IIFE to prevent "backpressure" in - // type inference from the subsequent arguments that are being passed: the bound - // invokable is the source of record for its own type and we don't want inference - // from the `resolveForBind` call to be affected by other (potentially incorrect) - // parameter types. - emit.text('χ.resolve('); - emitExpression(node.path); - emit.text(')((() => χ.resolveForBind('); - emitExpression(node.params[0]); - emit.text('))(), '); - emitArgs(node.params.slice(1), node.hash); - emit.text(')'); + function emitBindInvokableExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression, + position: InvokePosition + ): void { + emit.forNode(node, () => { + assert( + node.params.length >= 1, + () => `{{${formInfo.name}}} requires at least one positional argument` + ); - if (position === 'top-level') { + assert( + node.params.length === 1 || node.hash.pairs.length === 0, + () => + `Due to TypeScript inference limitations, {{${formInfo.name}}} can only pre-bind ` + + `either named or positional arguments in a single pass. You can instead break the ` + + `binding into two parts, e.g. ` + + `{{${formInfo.name} (${formInfo.name} ... posA posB) namedA=true namedB=true}}` + ); + + if (position === 'top-level') { + emit.text('χ.emitContent('); + } + + // Treat the first argument to a bind-invokable expression (`{{component}}`, + // `{{helper}}`, etc) as special: we wrap it in a `resolve` call so that the + // type machinery for those helpers can always operate against the resolved value. + // We wrap the `resolveForBind` call in an IIFE to prevent "backpressure" in + // type inference from the subsequent arguments that are being passed: the bound + // invokable is the source of record for its own type and we don't want inference + // from the `resolveForBind` call to be affected by other (potentially incorrect) + // parameter types. + emit.text('χ.resolve('); + emitExpression(node.path); + emit.text(')((() => χ.resolveForBind('); + emitExpression(node.params[0]); + emit.text('))(), '); + emitArgs(node.params.slice(1), node.hash); emit.text(')'); - } - }); - } - function emitObjectExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression - ): void { - emit.forNode(node, () => { - assert( - node.params.length === 0, - () => `{{${formInfo.name}}} only accepts named parameters` - ); + if (position === 'top-level') { + emit.text(')'); + } + }); + } - if (!node.hash.pairs.length) { - emit.text('{}'); - return; - } + function emitObjectExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression + ): void { + emit.forNode(node, () => { + assert( + node.params.length === 0, + () => `{{${formInfo.name}}} only accepts named parameters` + ); - emit.text('({'); - emit.indent(); - emit.newline(); + if (!node.hash.pairs.length) { + emit.text('{}'); + return; + } - let start = template.indexOf('hash', rangeForNode(node).start) + 4; - for (let pair of node.hash.pairs) { - start = template.indexOf(pair.key, start); - emitHashKey(pair.key, start); - emit.text(': '); - emitExpression(pair.value); - emit.text(','); + emit.text('({'); + emit.indent(); emit.newline(); - } - emit.dedent(); - emit.text('})'); - }); - } + let start = template.indexOf('hash', rangeForNode(node).start) + 4; + for (let pair of node.hash.pairs) { + start = template.indexOf(pair.key, start); + emitters.emitHashKey(pair.key, start); + emit.text(': '); + emitters.emitExpression(pair.value); + emit.text(','); + emit.newline(); + } - function emitArrayExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression - ): void { - emit.forNode(node, () => { - assert( - node.hash.pairs.length === 0, - () => `{{${formInfo.name}}} only accepts positional parameters` - ); + emit.dedent(); + emit.text('})'); + }); + } - emit.text('['); + function emitArrayExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression + ): void { + emit.forNode(node, () => { + assert( + node.hash.pairs.length === 0, + () => `{{${formInfo.name}}} only accepts positional parameters` + ); - for (let [index, param] of node.params.entries()) { - emitExpression(param); + emit.text('['); - if (index < node.params.length - 1) { - emit.text(', '); + for (let [index, param] of node.params.entries()) { + emitExpression(param); + + if (index < node.params.length - 1) { + emit.text(', '); + } } - } - emit.text(']'); - }); - } + emit.text(']'); + }); + } - function emitIfExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression - ): void { - emit.forNode(node, () => { - assert( - node.params.length >= 2, - () => `{{${formInfo.name}}} requires at least two parameters` - ); + function emitIfExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression + ): void { + emit.forNode(node, () => { + assert( + node.params.length >= 2, + () => `{{${formInfo.name}}} requires at least two parameters` + ); - emit.text('('); - emitExpression(node.params[0]); - emit.text(') ? ('); - emitExpression(node.params[1]); - emit.text(') : ('); + emit.text('('); + emitters.emitExpression(node.params[0]); + emit.text(') ? ('); + emitters.emitExpression(node.params[1]); + emit.text(') : ('); - if (node.params[2]) { - emitExpression(node.params[2]); - } else { - emit.text('undefined'); - } + if (node.params[2]) { + emitters.emitExpression(node.params[2]); + } else { + emit.text('undefined'); + } - emit.text(')'); - }); - } + emit.text(')'); + }); + } - function emitIfNotExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression - ): void { - emit.forNode(node, () => { - assert( - node.params.length >= 2, - () => `{{${formInfo.name}}} requires at least two parameters` - ); + function emitIfNotExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression + ): void { + emit.forNode(node, () => { + assert( + node.params.length >= 2, + () => `{{${formInfo.name}}} requires at least two parameters` + ); - emit.text('!('); - emitExpression(node.params[0]); - emit.text(') ? ('); - emitExpression(node.params[1]); - emit.text(') : ('); + emit.text('!('); + emitters.emitExpression(node.params[0]); + emit.text(') ? ('); + emitters.emitExpression(node.params[1]); + emit.text(') : ('); - if (node.params[2]) { - emitExpression(node.params[2]); - } else { - emit.text('undefined'); - } + if (node.params[2]) { + emitters.emitExpression(node.params[2]); + } else { + emit.text('undefined'); + } - emit.text(')'); - }); - } + emit.text(')'); + }); + } - function emitBinaryOperatorExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression - ): void { - emit.forNode(node, () => { - assert( - node.hash.pairs.length === 0, - () => `{{${formInfo.name}}} only accepts positional parameters` - ); - assert( - node.params.length === 2, - () => `{{${formInfo.name}}} requires exactly two parameters` - ); + function emitBinaryOperatorExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression + ): void { + emit.forNode(node, () => { + assert( + node.hash.pairs.length === 0, + () => `{{${formInfo.name}}} only accepts positional parameters` + ); + assert( + node.params.length === 2, + () => `{{${formInfo.name}}} requires exactly two parameters` + ); - const [left, right] = node.params; + const [left, right] = node.params; - emit.text('('); - emitExpression(left); - emit.text(` ${formInfo.form} `); - emitExpression(right); - emit.text(')'); - }); - } + emit.text('('); + emitters.emitExpression(left); + emit.text(` ${formInfo.form} `); + emitters.emitExpression(right); + emit.text(')'); + }); + } - function emitLogicalExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression - ): void { - emit.forNode(node, () => { - assert( - node.hash.pairs.length === 0, - () => `{{${formInfo.name}}} only accepts positional parameters` - ); - assert( - node.params.length >= 2, - () => `{{${formInfo.name}}} requires at least two parameters` - ); + function emitLogicalExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression + ): void { + emit.forNode(node, () => { + assert( + node.hash.pairs.length === 0, + () => `{{${formInfo.name}}} only accepts positional parameters` + ); + assert( + node.params.length >= 2, + () => `{{${formInfo.name}}} requires at least two parameters` + ); - emit.text('('); - for (const [index, param] of node.params.entries()) { - emitExpression(param); + emit.text('('); + for (const [index, param] of node.params.entries()) { + emitters.emitExpression(param); - if (index < node.params.length - 1) { - emit.text(` ${formInfo.form} `); + if (index < node.params.length - 1) { + emit.text(` ${formInfo.form} `); + } } - } - emit.text(')'); - }); - } + emit.text(')'); + }); + } - function emitUnaryOperatorExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression - ): void { - emit.forNode(node, () => { - assert( - node.hash.pairs.length === 0, - () => `{{${formInfo.name}}} only accepts positional parameters` - ); - assert( - node.params.length === 1, - () => `{{${formInfo.name}}} requires exactly one parameter` - ); + function emitUnaryOperatorExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression + ): void { + emit.forNode(node, () => { + assert( + node.hash.pairs.length === 0, + () => `{{${formInfo.name}}} only accepts positional parameters` + ); + assert( + node.params.length === 1, + () => `{{${formInfo.name}}} requires exactly one parameter` + ); - const [param] = node.params; + const [param] = node.params; - emit.text(formInfo.form); - emitExpression(param); - }); - } + emit.text(formInfo.form); + emitExpression(param); + }); + } - type SpecialFormInfo = { - form: GlintSpecialForm; - name: string; - requiresConsumption: boolean; - }; - - function checkSpecialForm(node: AST.CallNode): SpecialFormInfo | null { - if ( - node.path.type === 'PathExpression' && - node.path.head.type === 'VarHead' && - !node.path.tail.length - ) { - let name = node.path.head.name; - if (typeof specialForms[name] === 'string' && !scope.hasBinding(name)) { - let isGlobal = globals ? globals.includes(name) : true; - let form = specialForms[name]; - - return { name, form, requiresConsumption: !isGlobal }; + type SpecialFormInfo = { + form: GlintSpecialForm; + name: string; + requiresConsumption: boolean; + }; + + function checkSpecialForm(node: AST.CallNode): SpecialFormInfo | null { + if ( + node.path.type === 'PathExpression' && + node.path.head.type === 'VarHead' && + !node.path.tail.length + ) { + let name = node.path.head.name; + if (typeof specialForms[name] === 'string' && !scope.hasBinding(name)) { + let isGlobal = globals ? globals.includes(name) : true; + let form = specialForms[name]; + + return { name, form, requiresConsumption: !isGlobal }; + } } - } - return null; - } + return null; + } - function emitExpression(node: AST.Expression): void { - switch (node.type) { - case 'PathExpression': - return emitPath(node); + function emitExpression(node: AST.Expression): void { + switch (node.type) { + case 'PathExpression': + return emitters.emitPath(node); - case 'SubExpression': - return emitSubExpression(node); + case 'SubExpression': + return emitters.emitSubExpression(node); - case 'BooleanLiteral': - case 'NullLiteral': - case 'NumberLiteral': - case 'StringLiteral': - case 'UndefinedLiteral': - return emitLiteral(node); + case 'BooleanLiteral': + case 'NullLiteral': + case 'NumberLiteral': + case 'StringLiteral': + case 'UndefinedLiteral': + return emitters.emitLiteral(node); - default: - unreachable(node); + default: + unreachable(node); + } } - } - function emitElementNode(node: AST.ElementNode): void { - let firstCharacter = node.tag.charAt(0); - if ( - firstCharacter.toUpperCase() === firstCharacter || - node.tag.includes('.') || - scope.hasBinding(node.tag) - ) { - emitComponent(node); - } else { - emitPlainElement(node); + function emitElementNode(node: AST.ElementNode): void { + let firstCharacter = node.tag.charAt(0); + if ( + firstCharacter.toUpperCase() === firstCharacter || + node.tag.includes('.') || + scope.hasBinding(node.tag) + ) { + emitters.emitComponent(node); + } else { + emitters.emitPlainElement(node); + } } - } - function emitConcatStatement(node: AST.ConcatStatement): void { - emit.forNode(node, () => { - emit.text('`'); - for (let part of node.parts) { - if (part.type === 'MustacheStatement') { - emit.text('$' + '{'); - emitMustacheStatement(part, 'concat'); - emit.text('}'); + function emitConcatStatement(node: AST.ConcatStatement): void { + emit.forNode(node, () => { + emit.text('`'); + for (let part of node.parts) { + if (part.type === 'MustacheStatement') { + emit.text('$' + '{'); + emitters.emitMustacheStatement(part, 'concat'); + emit.text('}'); + } } - } - emit.text('`'); - }); - } + emit.text('`'); + }); + } - function emitIdentifierReference(name: string, hbsOffset: number): void { - if (treatAsGlobal(name)) { - emit.text('χ.Globals["'); - emit.identifier(JSON.stringify(name).slice(1, -1), hbsOffset, name.length); - emit.text('"]'); - } else { - emit.identifier(makeJSSafe(name), hbsOffset, name.length); + function emitIdentifierReference(name: string, hbsOffset: number): void { + if (treatAsGlobal(name)) { + emit.text('χ.Globals["'); + emit.identifier(JSON.stringify(name).slice(1, -1), hbsOffset, name.length); + emit.text('"]'); + } else { + emit.identifier(makeJSSafe(name), hbsOffset, name.length); + } } - } - function treatAsGlobal(name: string): boolean { - if (globals) { - // If we have a known set of global identifiers, we should only treat - // members of that set as global and assume everything else is local. - // This is typically true in environments that capture scope, like - // GlimmerX or strict-mode Ember. - return globals.includes(name); - } else { - // Otherwise, we assume everything is global unless we can see it - // in scope as a block variable. This is the case in resolver-based - // environments like loose-mode Ember. - return !scope.hasBinding(name); + function treatAsGlobal(name: string): boolean { + if (globals) { + // If we have a known set of global identifiers, we should only treat + // members of that set as global and assume everything else is local. + // This is typically true in environments that capture scope, like + // GlimmerX or strict-mode Ember. + return globals.includes(name); + } else { + // Otherwise, we assume everything is global unless we can see it + // in scope as a block variable. This is the case in resolver-based + // environments like loose-mode Ember. + return !scope.hasBinding(name); + } } - } - function tagNameToPathContents(node: AST.ElementNode): { - start: number; - kind: PathKind; - path: Array; - } { - let tagName = node.tag; - let start = template.indexOf(tagName, rangeForNode(node).start); - - if (tagName.startsWith('@')) { - return { - start, - kind: 'arg', - path: tagName.slice(1).split('.'), - }; - } else if (tagName.startsWith('this.')) { - return { - start, - kind: 'this', - path: tagName.slice('this.'.length).split('.'), - }; - } else { - return { - start, - kind: 'free', - path: tagName.split('.'), - }; + function tagNameToPathContents(node: AST.ElementNode): { + start: number; + kind: PathKind; + path: Array; + } { + let tagName = node.tag; + let start = template.indexOf(tagName, rangeForNode(node).start); + + if (tagName.startsWith('@')) { + return { + start, + kind: 'arg', + path: tagName.slice(1).split('.'), + }; + } else if (tagName.startsWith('this.')) { + return { + start, + kind: 'this', + path: tagName.slice('this.'.length).split('.'), + }; + } else { + return { + start, + kind: 'free', + path: tagName.split('.'), + }; + } } - } - function emitComponent(node: AST.ElementNode): void { - emit.forNode(node, () => { - let { start, path, kind } = tagNameToPathContents(node); + function emitComponent(node: AST.ElementNode): void { + emit.forNode(node, () => { + let { start, path, kind } = tagNameToPathContents(node); - for (let comment of node.comments) { - emitComment(comment); - } + for (let comment of node.comments) { + emitters.emitComment(comment); + } - emit.text('{'); - emit.newline(); - emit.indent(); + emit.text('{'); + emit.newline(); + emit.indent(); - emit.text('const 𝛄 = χ.emitComponent(χ.resolve('); - emitPathContents(path, start, kind); - emit.text(')('); + emit.text('const 𝛄 = χ.emitComponent(χ.resolve('); + emitPathContents(path, start, kind); + emit.text(')('); + + let dataAttrs = node.attributes.filter(({ name }) => name.startsWith('@')); + if (dataAttrs.length) { + emit.text('{ '); + + for (let attr of dataAttrs) { + emit.forNode(attr, () => { + start = template.indexOf(attr.name, start + 1); + emitHashKey(attr.name.slice(1), start + 1); + emit.text(': '); + + switch (attr.value.type) { + case 'TextNode': + emit.text(JSON.stringify(attr.value.chars)); + break; + case 'ConcatStatement': + emitters.emitConcatStatement(attr.value); + break; + case 'MustacheStatement': + emitters.emitMustacheStatement(attr.value, 'arg'); + break; + default: + unreachable(attr.value); + } + }); + + start = rangeForNode(attr.value).end; + emit.text(', '); + } + + emit.text('...χ.NamedArgsMarker }'); + } + + emit.text('));'); + emit.newline(); - let dataAttrs = node.attributes.filter(({ name }) => name.startsWith('@')); - if (dataAttrs.length) { - emit.text('{ '); - - for (let attr of dataAttrs) { - emit.forNode(attr, () => { - start = template.indexOf(attr.name, start + 1); - emitHashKey(attr.name.slice(1), start + 1); - emit.text(': '); - - switch (attr.value.type) { - case 'TextNode': - emit.text(JSON.stringify(attr.value.chars)); - break; - case 'ConcatStatement': - emitConcatStatement(attr.value); - break; - case 'MustacheStatement': - emitMustacheStatement(attr.value, 'arg'); - break; - default: - unreachable(attr.value); + emitters.emitAttributesAndModifiers(node); + + if (!node.selfClosing) { + let blocks = determineBlockChildren(node); + if (blocks.type === 'named') { + for (const child of blocks.children) { + if ( + child.type === 'CommentStatement' || + child.type === 'MustacheCommentStatement' + ) { + emitters.emitComment(child); + continue; + } + + let childStart = rangeForNode(child).start; + let nameStart = template.indexOf(child.tag, childStart) + ':'.length; + let blockParamsStart = template.indexOf('|', childStart); + let name = child.tag.slice(1); + + emit.forNode(child, () => + emitters.emitBlockContents( + name, + nameStart, + child.blockParams, + blockParamsStart, + child.children + ) + ); } - }); + } else { + let blockParamsStart = template.indexOf('|', rangeForNode(node).start); + emitters.emitBlockContents( + 'default', + undefined, + node.blockParams, + blockParamsStart, + blocks.children + ); + } - start = rangeForNode(attr.value).end; - emit.text(', '); + // Emit `ComponentName;` to represent the closing tag, so we have + // an anchor for things like symbol renames. + emitters.emitPathContents( + path, + template.lastIndexOf(node.tag, rangeForNode(node).end), + kind + ); + emit.text(';'); + emit.newline(); } - emit.text('...χ.NamedArgsMarker }'); - } + emit.dedent(); + emit.text('}'); + emit.newline(); + }); + } - emit.text('));'); - emit.newline(); + function isAllowedAmongNamedBlocks(node: AST.Node): boolean { + return ( + (node.type === 'TextNode' && node.chars.trim() === '') || + node.type === 'CommentStatement' || + node.type === 'MustacheCommentStatement' + ); + } - emitAttributesAndModifiers(node); + function isNamedBlock(node: AST.Node): boolean { + return node.type === 'ElementNode' && node.tag.startsWith(':'); + } - if (!node.selfClosing) { - let blocks = determineBlockChildren(node); - if (blocks.type === 'named') { - for (const child of blocks.children) { - if (child.type === 'CommentStatement' || child.type === 'MustacheCommentStatement') { - emitComment(child); - continue; - } + type NamedBlockChild = AST.ElementNode | AST.CommentStatement | AST.MustacheCommentStatement; + type BlockChildren = + | { type: 'named'; children: NamedBlockChild[] } + | { type: 'default'; children: AST.TopLevelStatement[] }; - let childStart = rangeForNode(child).start; - let nameStart = template.indexOf(child.tag, childStart) + ':'.length; - let blockParamsStart = template.indexOf('|', childStart); - let name = child.tag.slice(1); + function determineBlockChildren(node: AST.ElementNode): BlockChildren { + let named = 0; + let other = 0; + for (let child of node.children) { + if (isAllowedAmongNamedBlocks(child)) { + continue; + } + + if (isNamedBlock(child)) { + named += 1; + } else { + other += 1; + } + } + + if (named === 0) { + return { type: 'default', children: node.children }; + } else if (other === 0) { + return { + type: 'named', + children: node.children.filter( + // Filter out ignorable content between named blocks + (child): child is NamedBlockChild => + child.type === 'ElementNode' || + child.type === 'CommentStatement' || + child.type === 'MustacheCommentStatement' + ), + }; + } else { + // If we get here, meaningful content was mixed with named blocks, + // so it's worth doing the additional work to produce errors for + // those nodes + for (let child of node.children) { + if (!isNamedBlock(child)) { emit.forNode(child, () => - emitBlockContents( - name, - nameStart, - child.blockParams, - blockParamsStart, - child.children + assert( + isAllowedAmongNamedBlocks(child), + 'Named blocks may not be mixed with other content' ) ); } - } else { - let blockParamsStart = template.indexOf('|', rangeForNode(node).start); - emitBlockContents( - 'default', - undefined, - node.blockParams, - blockParamsStart, - blocks.children - ); } - // Emit `ComponentName;` to represent the closing tag, so we have - // an anchor for things like symbol renames. - emitPathContents(path, template.lastIndexOf(node.tag, rangeForNode(node).end), kind); - emit.text(';'); - emit.newline(); + return { type: 'named', children: [] }; } + } - emit.dedent(); - emit.text('}'); - emit.newline(); - }); - } + function emitPlainElement(node: AST.ElementNode): void { + emit.forNode(node, () => { + for (let comment of node.comments) { + emitters.emitComment(comment); + } - function isAllowedAmongNamedBlocks(node: AST.Node): boolean { - return ( - (node.type === 'TextNode' && node.chars.trim() === '') || - node.type === 'CommentStatement' || - node.type === 'MustacheCommentStatement' - ); - } + emit.text('{'); + emit.newline(); + emit.indent(); - function isNamedBlock(node: AST.Node): boolean { - return node.type === 'ElementNode' && node.tag.startsWith(':'); - } + emit.text('const 𝛄 = χ.emitElement('); + emit.text(JSON.stringify(node.tag)); + emit.text(');'); + emit.newline(); - type NamedBlockChild = AST.ElementNode | AST.CommentStatement | AST.MustacheCommentStatement; - type BlockChildren = - | { type: 'named'; children: NamedBlockChild[] } - | { type: 'default'; children: AST.TopLevelStatement[] }; + emitters.emitAttributesAndModifiers(node); - function determineBlockChildren(node: AST.ElementNode): BlockChildren { - let named = 0; - let other = 0; + for (let child of node.children) { + emitters.emitTopLevelStatement(child); + } - for (let child of node.children) { - if (isAllowedAmongNamedBlocks(child)) { - continue; - } + emit.dedent(); + emit.text('}'); + emit.newline(); + }); + } - if (isNamedBlock(child)) { - named += 1; + function emitAttributesAndModifiers(node: AST.ElementNode): void { + let nonArgAttributes = node.attributes.filter((attr) => !attr.name.startsWith('@')); + if (!nonArgAttributes.length && !node.modifiers.length) { + // Avoid unused-symbol diagnostics + emit.text('𝛄;'); + emit.newline(); } else { - other += 1; + emitters.emitSplattributes(node); + emitters.emitPlainAttributes(node); + emitters.emitModifiers(node); } } - if (named === 0) { - return { type: 'default', children: node.children }; - } else if (other === 0) { - return { - type: 'named', - children: node.children.filter( - // Filter out ignorable content between named blocks - (child): child is NamedBlockChild => - child.type === 'ElementNode' || - child.type === 'CommentStatement' || - child.type === 'MustacheCommentStatement' - ), - }; - } else { - // If we get here, meaningful content was mixed with named blocks, - // so it's worth doing the additional work to produce errors for - // those nodes - for (let child of node.children) { - if (!isNamedBlock(child)) { - emit.forNode(child, () => - assert( - isAllowedAmongNamedBlocks(child), - 'Named blocks may not be mixed with other content' - ) - ); - } - } - - return { type: 'named', children: [] }; - } - } + function emitPlainAttributes(node: AST.ElementNode): void { + let attributes = node.attributes.filter( + (attr) => !attr.name.startsWith('@') && attr.name !== SPLATTRIBUTES + ); - function emitPlainElement(node: AST.ElementNode): void { - emit.forNode(node, () => { - for (let comment of node.comments) { - emitComment(comment); - } + if (!attributes.length) return; - emit.text('{'); + emit.text('χ.applyAttributes(𝛄.element, {'); emit.newline(); emit.indent(); - emit.text('const 𝛄 = χ.emitElement('); - emit.text(JSON.stringify(node.tag)); - emit.text(');'); - emit.newline(); + let start = template.indexOf(node.tag, rangeForNode(node).start) + node.tag.length; - emitAttributesAndModifiers(node); + for (let attr of attributes) { + emit.forNode(attr, () => { + start = template.indexOf(attr.name, start + 1); - for (let child of node.children) { - emitTopLevelStatement(child); + emitters.emitHashKey(attr.name, start); + emit.text(': '); + + if (attr.value.type === 'MustacheStatement') { + emitters.emitMustacheStatement(attr.value, 'attr'); + } else if (attr.value.type === 'ConcatStatement') { + emitters.emitConcatStatement(attr.value); + } else { + emit.text(JSON.stringify(attr.value.chars)); + } + + emit.text(','); + emit.newline(); + }); } emit.dedent(); - emit.text('}'); - emit.newline(); - }); - } - - function emitAttributesAndModifiers(node: AST.ElementNode): void { - let nonArgAttributes = node.attributes.filter((attr) => !attr.name.startsWith('@')); - if (!nonArgAttributes.length && !node.modifiers.length) { - // Avoid unused-symbol diagnostics - emit.text('𝛄;'); + emit.text('});'); emit.newline(); - } else { - emitSplattributes(node); - emitPlainAttributes(node); - emitModifiers(node); } - } - function emitPlainAttributes(node: AST.ElementNode): void { - let attributes = node.attributes.filter( - (attr) => !attr.name.startsWith('@') && attr.name !== SPLATTRIBUTES - ); + function emitSplattributes(node: AST.ElementNode): void { + let splattributes = node.attributes.find((attr) => attr.name === SPLATTRIBUTES); + if (!splattributes) return; - if (!attributes.length) return; + assert( + splattributes.value.type === 'TextNode' && splattributes.value.chars === '', + '`...attributes` cannot accept a value' + ); - emit.text('χ.applyAttributes(𝛄.element, {'); - emit.newline(); - emit.indent(); + emit.forNode(splattributes, () => { + emit.text('χ.applySplattributes(𝚪.element, 𝛄.element);'); + }); - let start = template.indexOf(node.tag, rangeForNode(node).start) + node.tag.length; + emit.newline(); + } + + function emitModifiers(node: AST.ElementNode): void { + for (let modifier of node.modifiers) { + emit.forNode(modifier, () => { + emit.text('χ.applyModifier(χ.resolve('); + emitters.emitExpression(modifier.path); + emit.text(')(𝛄.element, '); + emitters.emitArgs(modifier.params, modifier.hash); + emit.text('));'); + emit.newline(); + }); + } + } - for (let attr of attributes) { - emit.forNode(attr, () => { - start = template.indexOf(attr.name, start + 1); + function emitMustacheStatement(node: AST.MustacheStatement, position: InvokePosition): void { + let specialFormInfo = checkSpecialForm(node); + if (specialFormInfo) { + emitters.emitSpecialFormExpression(specialFormInfo, node, position); + return; + } else if (node.path.type !== 'PathExpression' && node.path.type !== 'SubExpression') { + // This assertion is currently meaningless, as @glimmer/syntax silently drops + // any named or positional parameters passed in a literal mustache + assert( + node.params.length === 0 && node.hash.pairs.length === 0, + 'Literals do not accept params' + ); - emitHashKey(attr.name, start); - emit.text(': '); + emitLiteral(node.path); + return; + } - if (attr.value.type === 'MustacheStatement') { - emitMustacheStatement(attr.value, 'attr'); - } else if (attr.value.type === 'ConcatStatement') { - emitConcatStatement(attr.value); + emit.forNode(node, () => { + // If a mustache has parameters, we know it must be an invocation; if + // not, it depends on where it appears. In arg position, it's always + // passed directly as a value; otherwise it's invoked if it's a + // component/helper, and returned as a value otherwise. + let hasParams = Boolean(node.hash.pairs.length || node.params.length); + if (!hasParams && position === 'arg' && !isGlobal(node.path)) { + emitters.emitExpression(node.path); + } else if (position === 'top-level') { + emit.text('χ.emitContent('); + emitters.emitResolve(node, hasParams ? 'resolve' : 'resolveOrReturn'); + emit.text(')'); } else { - emit.text(JSON.stringify(attr.value.chars)); + emitters.emitResolve(node, hasParams ? 'resolve' : 'resolveOrReturn'); } - - emit.text(','); - emit.newline(); }); } - emit.dedent(); - emit.text('});'); - emit.newline(); - } + function isGlobal(path: AST.Expression): boolean { + return Boolean( + path.type === 'PathExpression' && + path.head.type === 'VarHead' && + globals?.includes(path.head.name) && + !scope.hasBinding(path.head.name) + ); + } - function emitSplattributes(node: AST.ElementNode): void { - let splattributes = node.attributes.find((attr) => attr.name === SPLATTRIBUTES); - if (!splattributes) return; + function emitYieldExpression( + formInfo: SpecialFormInfo, + node: AST.MustacheStatement | AST.SubExpression, + position: InvokePosition + ): void { + emit.forNode(node, () => { + assert( + position === 'top-level', + () => `{{${formInfo.name}}} may only appear as a top-level statement` + ); - assert( - splattributes.value.type === 'TextNode' && splattributes.value.chars === '', - '`...attributes` cannot accept a value' - ); + let to = 'default'; + let toPair = node.hash.pairs.find((pair) => pair.key === 'to'); + if (toPair) { + assert( + toPair.value.type === 'StringLiteral', + () => `Named block {{${formInfo.name}}}s must have a literal block name` + ); + to = toPair.value.value; + } - emit.forNode(splattributes, () => { - emit.text('χ.applySplattributes(𝚪.element, 𝛄.element);'); - }); + if (to === 'inverse') { + to = 'else'; + } - emit.newline(); - } + emit.text('χ.yieldToBlock(𝚪, '); + emit.text(JSON.stringify(to)); + emit.text(')('); - function emitModifiers(node: AST.ElementNode): void { - for (let modifier of node.modifiers) { - emit.forNode(modifier, () => { - emit.text('χ.applyModifier(χ.resolve('); - emitExpression(modifier.path); - emit.text(')(𝛄.element, '); - emitArgs(modifier.params, modifier.hash); - emit.text('));'); - emit.newline(); - }); - } - } + for (let [index, param] of node.params.entries()) { + if (index) { + emit.text(', '); + } - function emitMustacheStatement(node: AST.MustacheStatement, position: InvokePosition): void { - let specialFormInfo = checkSpecialForm(node); - if (specialFormInfo) { - emitSpecialFormExpression(specialFormInfo, node, position); - return; - } else if (node.path.type !== 'PathExpression' && node.path.type !== 'SubExpression') { - // This assertion is currently meaningless, as @glimmer/syntax silently drops - // any named or positional parameters passed in a literal mustache - assert( - node.params.length === 0 && node.hash.pairs.length === 0, - 'Literals do not accept params' - ); + emitExpression(param); + } - emitLiteral(node.path); - return; + emit.text(')'); + }); } - emit.forNode(node, () => { - // If a mustache has parameters, we know it must be an invocation; if - // not, it depends on where it appears. In arg position, it's always - // passed directly as a value; otherwise it's invoked if it's a - // component/helper, and returned as a value otherwise. - let hasParams = Boolean(node.hash.pairs.length || node.params.length); - if (!hasParams && position === 'arg' && !isGlobal(node.path)) { - emitExpression(node.path); - } else if (position === 'top-level') { - emit.text('χ.emitContent('); - emitResolve(node, hasParams ? 'resolve' : 'resolveOrReturn'); - emit.text(')'); - } else { - emitResolve(node, hasParams ? 'resolve' : 'resolveOrReturn'); + function emitSpecialFormStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void { + if (formInfo.requiresConsumption) { + emitters.emitExpression(node.path); + emit.text(';'); + emit.newline(); } - }); - } - function isGlobal(path: AST.Expression): boolean { - return Boolean( - path.type === 'PathExpression' && - path.head.type === 'VarHead' && - globals?.includes(path.head.name) && - !scope.hasBinding(path.head.name) - ); - } + switch (formInfo.form) { + case 'if': + emitters.emitIfStatement(formInfo, node); + break; + + case 'if-not': + emitters.emitUnlessStatement(formInfo, node); + break; + + case 'bind-invokable': + record.error( + `The {{${formInfo.name}}} helper can't be used directly in block form under Glint. ` + + `Consider first binding the result to a variable, e.g. '{{#let (${formInfo.name} ...) as |...|}}' ` + + `and then using the bound value.`, + rangeForNode(node.path) + ); + break; - function emitYieldExpression( - formInfo: SpecialFormInfo, - node: AST.MustacheStatement | AST.SubExpression, - position: InvokePosition - ): void { - emit.forNode(node, () => { - assert( - position === 'top-level', - () => `{{${formInfo.name}}} may only appear as a top-level statement` - ); + default: + record.error(`${formInfo.name} is not valid in block form`, rangeForNode(node.path)); + } + } - let to = 'default'; - let toPair = node.hash.pairs.find((pair) => pair.key === 'to'); - if (toPair) { + function emitIfStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void { + emit.forNode(node, () => { assert( - toPair.value.type === 'StringLiteral', - () => `Named block {{${formInfo.name}}}s must have a literal block name` + node.params.length === 1, + () => `{{#${formInfo.name}}} requires exactly one condition` ); - to = toPair.value.value; - } - if (to === 'inverse') { - to = 'else'; - } - - emit.text('χ.yieldToBlock(𝚪, '); - emit.text(JSON.stringify(to)); - emit.text(')('); + emit.text('if ('); + emitExpression(node.params[0]); + emit.text(') {'); + emit.newline(); + emit.indent(); - for (let [index, param] of node.params.entries()) { - if (index) { - emit.text(', '); + for (let statement of node.program.body) { + emitters.emitTopLevelStatement(statement); } - emitExpression(param); - } + if (node.inverse) { + emit.dedent(); + emit.text('} else {'); + emit.indent(); + emit.newline(); - emit.text(')'); - }); - } + for (let statement of node.inverse.body) { + emitters.emitTopLevelStatement(statement); + } + } - function emitSpecialFormStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void { - if (formInfo.requiresConsumption) { - emitExpression(node.path); - emit.text(';'); - emit.newline(); + emit.dedent(); + emit.text('}'); + emit.newline(); + }); } - switch (formInfo.form) { - case 'if': - emitIfStatement(formInfo, node); - break; - - case 'if-not': - emitUnlessStatement(formInfo, node); - break; - - case 'bind-invokable': - record.error( - `The {{${formInfo.name}}} helper can't be used directly in block form under Glint. ` + - `Consider first binding the result to a variable, e.g. '{{#let (${formInfo.name} ...) as |...|}}' ` + - `and then using the bound value.`, - rangeForNode(node.path) + function emitUnlessStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void { + emit.forNode(node, () => { + assert( + node.params.length === 1, + () => `{{#${formInfo.name}}} requires exactly one condition` ); - break; - default: - record.error(`${formInfo.name} is not valid in block form`, rangeForNode(node.path)); - } - } + emit.text('if (!('); + emitters.emitExpression(node.params[0]); + emit.text(')) {'); + emit.newline(); + emit.indent(); - function emitIfStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void { - emit.forNode(node, () => { - assert( - node.params.length === 1, - () => `{{#${formInfo.name}}} requires exactly one condition` - ); + for (let statement of node.program.body) { + emitters.emitTopLevelStatement(statement); + } - emit.text('if ('); - emitExpression(node.params[0]); - emit.text(') {'); - emit.newline(); - emit.indent(); + if (node.inverse) { + emit.dedent(); + emit.text('} else {'); + emit.indent(); + emit.newline(); - for (let statement of node.program.body) { - emitTopLevelStatement(statement); - } + for (let statement of node.inverse.body) { + emitters.emitTopLevelStatement(statement); + } + } - if (node.inverse) { emit.dedent(); - emit.text('} else {'); - emit.indent(); + emit.text('}'); emit.newline(); + }); + } - for (let statement of node.inverse.body) { - emitTopLevelStatement(statement); - } + function emitBlockStatement(node: AST.BlockStatement): void { + let specialFormInfo = checkSpecialForm(node); + if (specialFormInfo) { + emitters.emitSpecialFormStatement(specialFormInfo, node); + return; } - emit.dedent(); - emit.text('}'); - emit.newline(); - }); - } - - function emitUnlessStatement(formInfo: SpecialFormInfo, node: AST.BlockStatement): void { - emit.forNode(node, () => { - assert( - node.params.length === 1, - () => `{{#${formInfo.name}}} requires exactly one condition` - ); + emit.forNode(node, () => { + emit.text('{'); + emit.newline(); + emit.indent(); - emit.text('if (!('); - emitExpression(node.params[0]); - emit.text(')) {'); - emit.newline(); - emit.indent(); + emit.text('const 𝛄 = χ.emitComponent('); + emitters.emitResolve(node, 'resolve'); + emit.text(');'); + emit.newline(); - for (let statement of node.program.body) { - emitTopLevelStatement(statement); - } + emitters.emitBlock('default', node.program); - if (node.inverse) { - emit.dedent(); - emit.text('} else {'); - emit.indent(); - emit.newline(); + if (node.inverse) { + emitters.emitBlock('else', node.inverse); + } - for (let statement of node.inverse.body) { - emitTopLevelStatement(statement); + // TODO: emit something corresponding to `{{/foo}}` like we do + // for angle bracket components, so that symbol renames propagate? + // A little hairier (ha) for mustaches, since they + if (node.path.type === 'PathExpression') { + let start = template.lastIndexOf(node.path.original, rangeForNode(node).end); + emitters.emitPathContents(node.path.parts, start, determinePathKind(node.path)); + emit.text(';'); + emit.newline(); } - } - emit.dedent(); - emit.text('}'); + emit.dedent(); + emit.text('}'); + }); + emit.newline(); - }); - } + } + + function emitBlock(name: string, node: AST.Block): void { + let paramsStart = template.lastIndexOf( + '|', + template.lastIndexOf('|', rangeForNode(node).start) - 1 + ); - function emitBlockStatement(node: AST.BlockStatement): void { - let specialFormInfo = checkSpecialForm(node); - if (specialFormInfo) { - emitSpecialFormStatement(specialFormInfo, node); - return; + emitters.emitBlockContents(name, undefined, node.blockParams, paramsStart, node.body); } - emit.forNode(node, () => { + function emitBlockContents( + name: string, + nameOffset: number | undefined, + blockParams: string[], + blockParamsOffset: number, + children: AST.TopLevelStatement[] + ): void { + assert( + blockParams.every((name) => !name.includes('-')), + 'Block params must be valid TypeScript identifiers' + ); + + scope.push(blockParams); + emit.text('{'); emit.newline(); emit.indent(); - emit.text('const 𝛄 = χ.emitComponent('); - emitResolve(node, 'resolve'); - emit.text(');'); - emit.newline(); + emit.text('const ['); - emitBlock('default', node.program); + let start = blockParamsOffset; + for (let [index, param] of blockParams.entries()) { + if (index) emit.text(', '); - if (node.inverse) { - emitBlock('else', node.inverse); + start = template.indexOf(param, start); + emit.identifier(makeJSSafe(param), start, param.length); } - // TODO: emit something corresponding to `{{/foo}}` like we do - // for angle bracket components, so that symbol renames propagate? - // A little hairier (ha) for mustaches, since they - if (node.path.type === 'PathExpression') { - let start = template.lastIndexOf(node.path.original, rangeForNode(node).end); - emitPathContents(node.path.parts, start, determinePathKind(node.path)); - emit.text(';'); - emit.newline(); + emit.text('] = 𝛄.blockParams'); + emitters.emitPropertyAccesss(name, { offset: nameOffset, synthetic: true }); + emit.text(';'); + emit.newline(); + + for (let statement of children) { + emitters.emitTopLevelStatement(statement); } emit.dedent(); emit.text('}'); - }); - - emit.newline(); - } - - function emitBlock(name: string, node: AST.Block): void { - let paramsStart = template.lastIndexOf( - '|', - template.lastIndexOf('|', rangeForNode(node).start) - 1 - ); - - emitBlockContents(name, undefined, node.blockParams, paramsStart, node.body); - } - - function emitBlockContents( - name: string, - nameOffset: number | undefined, - blockParams: string[], - blockParamsOffset: number, - children: AST.TopLevelStatement[] - ): void { - assert( - blockParams.every((name) => !name.includes('-')), - 'Block params must be valid TypeScript identifiers' - ); - - scope.push(blockParams); - - emit.text('{'); - emit.newline(); - emit.indent(); - - emit.text('const ['); - - let start = blockParamsOffset; - for (let [index, param] of blockParams.entries()) { - if (index) emit.text(', '); - - start = template.indexOf(param, start); - emit.identifier(makeJSSafe(param), start, param.length); + emit.newline(); + scope.pop(); } - emit.text('] = 𝛄.blockParams'); - emitPropertyAccesss(name, { offset: nameOffset, synthetic: true }); - emit.text(';'); - emit.newline(); + function emitSubExpression(node: AST.SubExpression): void { + let specialFormInfo = checkSpecialForm(node); + if (specialFormInfo) { + emitters.emitSpecialFormExpression(specialFormInfo, node, 'sexpr'); + return; + } - for (let statement of children) { - emitTopLevelStatement(statement); + emit.forNode(node, () => { + emitters.emitResolve(node, 'resolve'); + }); } - emit.dedent(); - emit.text('}'); - emit.newline(); - scope.pop(); - } + /** An AST node that represents an invocation of some template entity in curlies */ + type CurlyInvocationNode = + | AST.MustacheStatement + | AST.SubExpression + | AST.BlockStatement + | AST.ElementModifierStatement; - function emitSubExpression(node: AST.SubExpression): void { - let specialFormInfo = checkSpecialForm(node); - if (specialFormInfo) { - emitSpecialFormExpression(specialFormInfo, node, 'sexpr'); - return; + function emitResolve(node: CurlyInvocationNode, resolveType: string): void { + emit.text('χ.'); + emit.text(resolveType); + emit.text('('); + emitExpression(node.path); + emit.text(')('); + emitArgs(node.params, node.hash); + emit.text(')'); } - emit.forNode(node, () => { - emitResolve(node, 'resolve'); - }); - } - - /** An AST node that represents an invocation of some template entity in curlies */ - type CurlyInvocationNode = - | AST.MustacheStatement - | AST.SubExpression - | AST.BlockStatement - | AST.ElementModifierStatement; - - function emitResolve(node: CurlyInvocationNode, resolveType: string): void { - emit.text('χ.'); - emit.text(resolveType); - emit.text('('); - emitExpression(node.path); - emit.text(')('); - emitArgs(node.params, node.hash); - emit.text(')'); - } + function emitArgs(positional: Array, named: AST.Hash): void { + // Emit positional args + for (let [index, param] of positional.entries()) { + if (index) { + emit.text(', '); + } - function emitArgs(positional: Array, named: AST.Hash): void { - // Emit positional args - for (let [index, param] of positional.entries()) { - if (index) { - emit.text(', '); + emitters.emitExpression(param); } - emitExpression(param); - } + // Emit named args + if (named.pairs.length) { + emit.text(positional.length ? ', { ' : '{ '); - // Emit named args - if (named.pairs.length) { - emit.text(positional.length ? ', { ' : '{ '); + let { start } = rangeForNode(named); + for (let [index, pair] of named.pairs.entries()) { + start = template.indexOf(pair.key, start); + emitters.emitHashKey(pair.key, start); + emit.text(': '); + emitters.emitExpression(pair.value); - let { start } = rangeForNode(named); - for (let [index, pair] of named.pairs.entries()) { - start = template.indexOf(pair.key, start); - emitHashKey(pair.key, start); - emit.text(': '); - emitExpression(pair.value); + if (index === named.pairs.length - 1) { + emit.text(' '); + } - if (index === named.pairs.length - 1) { - emit.text(' '); + start = rangeForNode(pair.value).end; + emit.text(', '); } - start = rangeForNode(pair.value).end; - emit.text(', '); + emit.text('...χ.NamedArgsMarker }'); } - - emit.text('...χ.NamedArgsMarker }'); } - } - - type PathKind = 'this' | 'arg' | 'free'; - function emitPath(node: AST.PathExpression): void { - emit.forNode(node, () => { - let { start } = rangeForNode(node); - emitPathContents(node.parts, start, determinePathKind(node)); - }); - } + type PathKind = 'this' | 'arg' | 'free'; - function determinePathKind(node: AST.PathExpression): PathKind { - return node.this ? 'this' : node.data ? 'arg' : 'free'; - } + function emitPath(node: AST.PathExpression): void { + emit.forNode(node, () => { + let { start } = rangeForNode(node); + emitters.emitPathContents(node.parts, start, determinePathKind(node)); + }); + } - function emitPathContents(parts: string[], start: number, kind: PathKind): void { - if (kind === 'this') { - let thisStart = template.indexOf('this', start); - emit.text('𝚪.'); - emit.identifier('this', thisStart); - start = template.indexOf('.', thisStart) + 1; - } else if (kind === 'arg') { - emit.text('𝚪.args'); - start = template.indexOf('@', start) + 1; + function determinePathKind(node: AST.PathExpression): PathKind { + return node.this ? 'this' : node.data ? 'arg' : 'free'; } - let head = parts[0]; - if (!head) return; + function emitPathContents(parts: string[], start: number, kind: PathKind): void { + if (kind === 'this') { + let thisStart = template.indexOf('this', start); + emit.text('𝚪.'); + emit.identifier('this', thisStart); + start = template.indexOf('.', thisStart) + 1; + } else if (kind === 'arg') { + emit.text('𝚪.args'); + start = template.indexOf('@', start) + 1; + } - start = template.indexOf(head, start); + let head = parts[0]; + if (!head) return; - // The first segment of a non-this, non-arg path must resolve - // to some in-scope identifier. - if (kind === 'free') { - emitIdentifierReference(head, start); - } else { - emitPropertyAccesss(head, { offset: start, optional: false }); - } + start = template.indexOf(head, start); - start += head.length; + // The first segment of a non-this, non-arg path must resolve + // to some in-scope identifier. + if (kind === 'free') { + emitters.emitIdentifierReference(head, start); + } else { + emitters.emitPropertyAccesss(head, { offset: start, optional: false }); + } + + start += head.length; - for (let i = 1; i < parts.length; i++) { - let part = parts[i]; - start = template.indexOf(part, start); - emitPropertyAccesss(part, { offset: start, optional: true }); - start += part.length; + for (let i = 1; i < parts.length; i++) { + let part = parts[i]; + start = template.indexOf(part, start); + emitters.emitPropertyAccesss(part, { offset: start, optional: true }); + start += part.length; + } } - } - type PropertyAccessOptions = { - offset?: number; - optional?: boolean; - synthetic?: boolean; - }; - - function emitPropertyAccesss( - name: string, - { offset, optional, synthetic }: PropertyAccessOptions = {} - ): void { - // Synthetic accesses should always use `[]` notation to avoid incidentally triggering - // `noPropertyAccessFromIndexSignature`. Emitting `{{foo.bar}}` property accesses, however, - // should use `.` notation for exactly the same reason. - if (!synthetic && isSafeKey(name)) { - emit.text(optional ? '?.' : '.'); - if (offset) { - emit.identifier(name, offset); + type PropertyAccessOptions = { + offset?: number; + optional?: boolean; + synthetic?: boolean; + }; + + function emitPropertyAccesss( + name: string, + { offset, optional, synthetic }: PropertyAccessOptions = {} + ): void { + // Synthetic accesses should always use `[]` notation to avoid incidentally triggering + // `noPropertyAccessFromIndexSignature`. Emitting `{{foo.bar}}` property accesses, however, + // should use `.` notation for exactly the same reason. + if (!synthetic && isSafeKey(name)) { + emit.text(optional ? '?.' : '.'); + if (offset) { + emit.identifier(name, offset); + } else { + emit.text(name); + } } else { - emit.text(name); + emit.text(optional ? '?.[' : '['); + if (offset) { + emitters.emitIdentifierString(name, offset); + } else { + emit.text(JSON.stringify(name)); + } + emit.text(']'); } - } else { - emit.text(optional ? '?.[' : '['); - if (offset) { - emitIdentifierString(name, offset); + } + + function emitHashKey(name: string, start: number): void { + if (isSafeKey(name)) { + emit.identifier(name, start); } else { - emit.text(JSON.stringify(name)); + emitters.emitIdentifierString(name, start); } - emit.text(']'); } - } - function emitHashKey(name: string, start: number): void { - if (isSafeKey(name)) { - emit.identifier(name, start); - } else { - emitIdentifierString(name, start); + function emitIdentifierString(name: string, start: number): void { + emit.text('"'); + emit.identifier(JSON.stringify(name).slice(1, -1), start, name.length); + emit.text('"'); } - } - - function emitIdentifierString(name: string, start: number): void { - emit.text('"'); - emit.identifier(JSON.stringify(name).slice(1, -1), start, name.length); - emit.text('"'); - } - function emitLiteral(node: AST.Literal): void { - emit.forNode(node, () => - emit.text(node.value === undefined ? 'undefined' : JSON.stringify(node.value)) - ); - } + function emitLiteral(node: AST.Literal): void { + emit.forNode(node, () => + emit.text(node.value === undefined ? 'undefined' : JSON.stringify(node.value)) + ); + } - function isSafeKey(key: string): boolean { - return /^[a-z_$][a-z0-9_$]*$/i.test(key); + function isSafeKey(key: string): boolean { + return /^[a-z_$][a-z0-9_$]*$/i.test(key); + } } - }); + ); } const JSKeywords = new Set([