From 491fc76b38c040433615b15c3863084a9ac7984d Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Mon, 1 Apr 2024 22:32:47 -0700 Subject: [PATCH 1/2] Remove invalid callee nodes from AST v2 `{{("foo")}}` etc is illegal and already covered by existing tests. Currently this is checked by the parser (handlebars-node-visitors) but AST plugins can violate this constraint in theory. --- .../lib/passes/1-normalization/keywords/impl.ts | 6 ------ packages/@glimmer/syntax/lib/v1/nodes-v1.ts | 1 + packages/@glimmer/syntax/lib/v2/builders.ts | 2 +- packages/@glimmer/syntax/lib/v2/normalize.ts | 15 ++++++++++++++- packages/@glimmer/syntax/lib/v2/objects/base.ts | 6 ++++-- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/@glimmer/compiler/lib/passes/1-normalization/keywords/impl.ts b/packages/@glimmer/compiler/lib/passes/1-normalization/keywords/impl.ts index ead8ab356b..88e4f456b9 100644 --- a/packages/@glimmer/compiler/lib/passes/1-normalization/keywords/impl.ts +++ b/packages/@glimmer/compiler/lib/passes/1-normalization/keywords/impl.ts @@ -78,12 +78,6 @@ class KeywordImpl< } } -export type PossibleNode = - | ASTv2.PathExpression - | ASTv2.AppendContent - | ASTv2.CallExpression - | ASTv2.InvokeBlock; - export const KEYWORD_NODES = { Call: ['Call'], Block: ['InvokeBlock'], diff --git a/packages/@glimmer/syntax/lib/v1/nodes-v1.ts b/packages/@glimmer/syntax/lib/v1/nodes-v1.ts index 9c34badb89..baf49864b9 100644 --- a/packages/@glimmer/syntax/lib/v1/nodes-v1.ts +++ b/packages/@glimmer/syntax/lib/v1/nodes-v1.ts @@ -43,6 +43,7 @@ export interface CallParts { path: CallableExpression; params: Expression[]; hash: Hash; + loc: src.SourceSpan; } export type CallNode = diff --git a/packages/@glimmer/syntax/lib/v2/builders.ts b/packages/@glimmer/syntax/lib/v2/builders.ts index 3f109ac995..84aaf215e5 100644 --- a/packages/@glimmer/syntax/lib/v2/builders.ts +++ b/packages/@glimmer/syntax/lib/v2/builders.ts @@ -9,7 +9,7 @@ import { SpanList } from '../source/span-list'; import * as ASTv2 from './api'; export interface CallParts { - callee: ASTv2.ExpressionNode; + callee: ASTv2.CalleeNode; args: ASTv2.Args; } diff --git a/packages/@glimmer/syntax/lib/v2/normalize.ts b/packages/@glimmer/syntax/lib/v2/normalize.ts index 9d8e38c4f5..f226a3d31b 100644 --- a/packages/@glimmer/syntax/lib/v2/normalize.ts +++ b/packages/@glimmer/syntax/lib/v2/normalize.ts @@ -243,7 +243,7 @@ class ExpressionNormalizer { * it to an ASTv2 CallParts. */ callParts(parts: ASTv1.CallParts, context: ASTv2.FreeVarResolution): CallParts { - let { path, params, hash } = parts; + let { path, params, hash, loc } = parts; let callee = this.normalize(path, context); let paramList = params.map((p) => this.normalize(p, ASTv2.STRICT_RESOLUTION)); @@ -261,6 +261,18 @@ class ExpressionNormalizer { this.block.loc(hash.loc) ); + switch (callee.type) { + case 'Literal': + throw generateSyntaxError( + `Invalid invocation of a literal value (\`${callee.value}\`)`, + loc + ); + + // This really shouldn't be possible, something has gone pretty wrong + case 'Interpolate': + throw generateSyntaxError(`Invalid invocation of a interpolated string`, loc); + } + return { callee, args: this.block.builder.args(positional, named, argsLoc), @@ -402,6 +414,7 @@ class StatementNormalizer { path, params, hash, + loc, }, resolution.result ); diff --git a/packages/@glimmer/syntax/lib/v2/objects/base.ts b/packages/@glimmer/syntax/lib/v2/objects/base.ts index 439a027242..59a26c8a97 100644 --- a/packages/@glimmer/syntax/lib/v2/objects/base.ts +++ b/packages/@glimmer/syntax/lib/v2/objects/base.ts @@ -2,7 +2,7 @@ import type { SerializedSourceSpan } from '../../source/span'; import type { Args } from './args'; import type { ElementModifier } from './attr-block'; import type { AppendContent, ContentNode, InvokeBlock, InvokeComponent } from './content'; -import type { CallExpression, ExpressionNode } from './expr'; +import type { CallExpression, PathExpression } from './expr'; import type { BaseNodeFields } from './node'; export interface SerializedBaseNode { @@ -14,10 +14,12 @@ export interface GlimmerParentNodeOptions extends BaseNodeFields { } export interface CallFields extends BaseNodeFields { - callee: ExpressionNode; + callee: CalleeNode; args: Args; } +export type CalleeNode = PathExpression | CallExpression; + export type CallNode = | CallExpression | InvokeBlock From 025e7429bceb7c0a97f0fc4bf0b18b5015f4e331 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Thu, 4 Apr 2024 00:23:37 -0700 Subject: [PATCH 2/2] Introduce `keywords` option for `precompile` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recently in #1557 we started emitting build time error for strict mode templates containing undefined references. Previously these were not handled until runtime and so we can only emit runtime errors. However, the previous setup allowed for the host environment to implement additional keywords – these are resolved at runtime with the `lookupBuildInHelper` and `lookupBuiltInModifier` hooks, and the error is only emitted if these hooks returned `null`. To allow for this possibility, `precompile` now accept an option for additional strict mode keywords that the host environment is expected to provide. --- .../lib/modes/jit/compilation-context.ts | 4 +- .../lib/test-helpers/define.ts | 7 +- .../test/strict-mode-test.ts | 30 ++++- .../@glimmer/compiler/lib/builder/builder.ts | 3 +- .../passes/1-normalization/utils/is-node.ts | 109 +----------------- .../visitors/element/classified.ts | 5 - .../1-normalization/visitors/expressions.ts | 11 +- .../1-normalization/visitors/strict-mode.ts | 1 + .../lib/passes/2-encoding/expressions.ts | 10 +- .../compiler/lib/passes/2-encoding/mir.ts | 3 +- .../compiler/lib/wire-format-debug.ts | 2 +- .../lib/compile/wire-format/api.d.ts | 4 +- .../lib/parser/tokenizer-event-handlers.ts | 16 +++ packages/@glimmer/syntax/lib/symbol-table.ts | 35 ++++-- packages/@glimmer/syntax/lib/v2/builders.ts | 8 ++ packages/@glimmer/syntax/lib/v2/normalize.ts | 35 ++++-- .../@glimmer/syntax/lib/v2/objects/base.ts | 4 +- .../@glimmer/syntax/lib/v2/objects/expr.ts | 12 ++ .../syntax/lib/v2/serialize/serialize.ts | 20 ++++ 19 files changed, 168 insertions(+), 151 deletions(-) diff --git a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/compilation-context.ts b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/compilation-context.ts index c567671294..d9b4059427 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/modes/jit/compilation-context.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/modes/jit/compilation-context.ts @@ -23,8 +23,8 @@ export default class JitCompileTimeLookup implements CompileTimeResolver { return this.resolver.lookupComponent(name, owner); } - lookupBuiltInHelper(_name: string): Nullable { - return null; + lookupBuiltInHelper(name: string): Nullable { + return this.resolver.lookupHelper(`$keyword.${name}`); } lookupBuiltInModifier(_name: string): Nullable { diff --git a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts index 9194c5cf4e..74e7ac7ee9 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts @@ -96,6 +96,9 @@ export interface DefineComponentOptions { // defaults to true when some scopeValues are passed and false otherwise strictMode?: boolean; + + // additional strict-mode keywords + keywords?: string[]; } export function defineComponent( @@ -110,8 +113,10 @@ export function defineComponent( strictMode = scopeValues !== null; } + let keywords = options.keywords ?? []; + let definition = options.definition ?? templateOnlyComponent(); - let templateFactory = createTemplate(templateSource, { strictMode }, scopeValues ?? {}); + let templateFactory = createTemplate(templateSource, { strictMode, keywords }, scopeValues ?? {}); setComponentTemplate(templateFactory, definition); return definition; } diff --git a/packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts b/packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts index 00e14ee486..940428bbdc 100644 --- a/packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts +++ b/packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts @@ -72,12 +72,16 @@ class GeneralStrictModeTest extends RenderTest { } @test - 'Implicit this lookup does not work'() { + 'Undefined references is an error'() { + this.registerHelper('bar', () => 'should not resolve this helper'); + this.assert.throws( () => { defineComponent({}, '{{bar}}', { definition: class extends GlimmerishComponent { - bar = 'Hello, world!'; + get bar() { + throw new Error('should not fallback to this.bar'); + } }, }); }, @@ -91,6 +95,28 @@ class GeneralStrictModeTest extends RenderTest { ); } + @test + 'Non-native keyword'() { + this.registerHelper('bar', () => { + throw new Error('should not resolve this helper'); + }); + + this.registerHelper('$keyword.bar', () => 'bar keyword'); + + const Foo = defineComponent({}, '{{bar}}', { + keywords: ['bar'], + definition: class extends GlimmerishComponent { + get bar() { + throw new Error('should not fallback to this.bar'); + } + }, + }); + + this.renderComponent(Foo); + this.assertHTML('bar keyword'); + this.assertStableRerender(); + } + @test '{{component}} throws an error if a string is used in strict (append position)'() { this.assert.throws(() => { diff --git a/packages/@glimmer/compiler/lib/builder/builder.ts b/packages/@glimmer/compiler/lib/builder/builder.ts index 5acfd89388..80cdb6e2b3 100644 --- a/packages/@glimmer/compiler/lib/builder/builder.ts +++ b/packages/@glimmer/compiler/lib/builder/builder.ts @@ -632,7 +632,7 @@ export function buildVar( symbols: Symbols, path?: PresentArray ): Expressions.GetPath | Expressions.GetVar { - let op: Expressions.GetVar[0] = Op.GetSymbol; + let op: Expressions.GetPath[0] | Expressions.GetVar[0] = Op.GetSymbol; let sym: number; switch (head.kind) { case VariableKind.Free: @@ -665,6 +665,7 @@ export function buildVar( if (path === undefined || path.length === 0) { return [op, sym]; } else { + assert(op !== Op.GetStrictKeyword, '[BUG] keyword with a path'); return [op, sym, path]; } } diff --git a/packages/@glimmer/compiler/lib/passes/1-normalization/utils/is-node.ts b/packages/@glimmer/compiler/lib/passes/1-normalization/utils/is-node.ts index 3f8963d38c..b0131a8efe 100644 --- a/packages/@glimmer/compiler/lib/passes/1-normalization/utils/is-node.ts +++ b/packages/@glimmer/compiler/lib/passes/1-normalization/utils/is-node.ts @@ -1,111 +1,4 @@ -import type { PresentArray } from '@glimmer/interfaces'; -import type { SourceSlice } from '@glimmer/syntax'; -import { ASTv2, generateSyntaxError } from '@glimmer/syntax'; -import { unreachable } from '@glimmer/util'; - -export type HasPath = Node & { - head: ASTv2.PathExpression; -}; - -export type HasArguments = - | { - params: PresentArray; - } - | { - hash: { - pairs: PresentArray; - }; - }; - -export type HelperInvocation = HasPath & - HasArguments; - -export function hasPath(node: N): node is HasPath { - return node.callee.type === 'Path'; -} - -export function isHelperInvocation( - node: ASTv2.CallNode -): node is HelperInvocation { - if (!hasPath(node)) { - return false; - } - - return !node.args.isEmpty(); -} - -export interface SimplePath extends ASTv2.PathExpression { - tail: [SourceSlice]; - data: false; - this: false; -} - -export type SimpleHelper = N & { - path: SimplePath; -}; - -export function isSimplePath(path: ASTv2.ExpressionNode): path is SimplePath { - if (path.type === 'Path') { - let { ref: head, tail: parts } = path; - - return head.type === 'Free' && !ASTv2.isStrictResolution(head.resolution) && parts.length === 0; - } else { - return false; - } -} - -export function isStrictHelper(expr: HasPath): boolean { - if (expr.callee.type !== 'Path') { - return true; - } - - if (expr.callee.ref.type !== 'Free') { - return true; - } - - return ASTv2.isStrictResolution(expr.callee.ref.resolution); -} - -export function assertIsValidModifier( - helper: N -): asserts helper is SimpleHelper { - if (isStrictHelper(helper) || isSimplePath(helper.callee)) { - return; - } - - throw generateSyntaxError( - `\`${printPath(helper.callee)}\` is not a valid name for a modifier`, - helper.loc - ); -} - -function printPath(path: ASTv2.ExpressionNode): string { - switch (path.type) { - case 'Literal': - return JSON.stringify(path.value); - case 'Path': { - let printedPath = [printPathHead(path.ref)]; - printedPath.push(...path.tail.map((t) => t.chars)); - return printedPath.join('.'); - } - case 'Call': - return `(${printPath(path.callee)} ...)`; - case 'Interpolate': - throw unreachable('a concat statement cannot appear as the head of an expression'); - } -} - -function printPathHead(head: ASTv2.VariableReference): string { - switch (head.type) { - case 'Arg': - return head.name.chars; - case 'Free': - case 'Local': - return head.name; - case 'This': - return 'this'; - } -} +import type { ASTv2 } from '@glimmer/syntax'; /** * This function is checking whether an AST node is a triple-curly, which means that it's diff --git a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/element/classified.ts b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/element/classified.ts index ec3317b73a..9baa4b9113 100644 --- a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/element/classified.ts +++ b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/element/classified.ts @@ -7,7 +7,6 @@ import { Ok, Result, ResultArray } from '../../../../shared/result'; import { getAttrNamespace } from '../../../../utils'; import * as mir from '../../../2-encoding/mir'; import { MODIFIER_KEYWORDS } from '../../keywords'; -import { assertIsValidModifier, isHelperInvocation } from '../../utils/is-node'; import { convertPathToCallIfKeyword, VISIT_EXPRS } from '../expressions'; export type ValidAttr = mir.StaticAttr | mir.DynamicAttr | mir.SplatAttr; @@ -75,10 +74,6 @@ export class ClassifiedElement { } private modifier(modifier: ASTv2.ElementModifier): Result { - if (isHelperInvocation(modifier)) { - assertIsValidModifier(modifier); - } - let translated = MODIFIER_KEYWORDS.translate(modifier, this.state); if (translated !== null) { diff --git a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/expressions.ts b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/expressions.ts index d4f07b4c14..0daae08685 100644 --- a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/expressions.ts +++ b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/expressions.ts @@ -8,13 +8,14 @@ import type { NormalizationState } from '../context'; import { Ok, Result, ResultArray } from '../../../shared/result'; import * as mir from '../../2-encoding/mir'; import { CALL_KEYWORDS } from '../keywords'; -import { hasPath } from '../utils/is-node'; export class NormalizeExpressions { visit(node: ASTv2.ExpressionNode, state: NormalizationState): Result { switch (node.type) { case 'Literal': return Ok(this.Literal(node)); + case 'Keyword': + return Ok(this.Keyword(node)); case 'Interpolate': return this.Interpolate(node, state); case 'Path': @@ -78,6 +79,10 @@ export class NormalizeExpressions { return literal; } + Keyword(keyword: ASTv2.KeywordExpression): ASTv2.KeywordExpression { + return keyword; + } + Interpolate( expr: ASTv2.InterpolateExpression, state: NormalizationState @@ -93,8 +98,8 @@ export class NormalizeExpressions { expr: ASTv2.CallExpression, state: NormalizationState ): Result { - if (!hasPath(expr)) { - throw new Error(`unimplemented subexpression at the head of a subexpression`); + if (expr.callee.type === 'Call') { + throw new Error(`unimplemented: subexpression at the head of a subexpression`); } else { return Result.all( VISIT_EXPRS.visit(expr.callee, state), diff --git a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts index 787139a8d5..433e905711 100644 --- a/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts +++ b/packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts @@ -120,6 +120,7 @@ export default class StrictModeValidationPass { ): Result { switch (expression.type) { case 'Literal': + case 'Keyword': case 'Missing': case 'This': case 'Arg': diff --git a/packages/@glimmer/compiler/lib/passes/2-encoding/expressions.ts b/packages/@glimmer/compiler/lib/passes/2-encoding/expressions.ts index 9c36287707..f9aeda9257 100644 --- a/packages/@glimmer/compiler/lib/passes/2-encoding/expressions.ts +++ b/packages/@glimmer/compiler/lib/passes/2-encoding/expressions.ts @@ -1,6 +1,6 @@ import type { PresentArray, WireFormat } from '@glimmer/interfaces'; import type { ASTv2 } from '@glimmer/syntax'; -import { assertPresentArray, isPresentArray, mapPresentArray } from '@glimmer/util'; +import { assert, assertPresentArray, isPresentArray, mapPresentArray } from '@glimmer/util'; import { SexpOpcodes } from '@glimmer/wire-format'; import type * as mir from './mir'; @@ -14,6 +14,8 @@ export class ExpressionEncoder { return undefined; case 'Literal': return this.Literal(expr); + case 'Keyword': + return this.Keyword(expr); case 'CallExpression': return this.CallExpression(expr); case 'PathExpression': @@ -86,9 +88,13 @@ export class ExpressionEncoder { return [isTemplateLocal ? SexpOpcodes.GetLexicalSymbol : SexpOpcodes.GetSymbol, symbol]; } + Keyword({ symbol }: ASTv2.KeywordExpression): WireFormat.Expressions.GetStrictFree { + return [SexpOpcodes.GetStrictKeyword, symbol]; + } + PathExpression({ head, tail }: mir.PathExpression): WireFormat.Expressions.GetPath { let getOp = EXPR.expr(head) as WireFormat.Expressions.GetVar; - + assert(getOp[0] !== SexpOpcodes.GetStrictKeyword, '[BUG] keyword in a PathExpression'); return [...getOp, EXPR.Tail(tail)]; } diff --git a/packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts b/packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts index 8eb1f49187..cb10e013e8 100644 --- a/packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts +++ b/packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts @@ -180,9 +180,10 @@ export class Tail extends node('Tail').fields<{ members: PresentArray string) | undefined; } diff --git a/packages/@glimmer/syntax/lib/symbol-table.ts b/packages/@glimmer/syntax/lib/symbol-table.ts index b6ee4e0432..a9edccb8f1 100644 --- a/packages/@glimmer/syntax/lib/symbol-table.ts +++ b/packages/@glimmer/syntax/lib/symbol-table.ts @@ -15,15 +15,21 @@ interface SymbolTableOptions { } export abstract class SymbolTable { - static top(locals: string[], options: SymbolTableOptions): ProgramSymbolTable { - return new ProgramSymbolTable(locals, options); + static top( + locals: readonly string[], + keywords: readonly string[], + options: SymbolTableOptions + ): ProgramSymbolTable { + return new ProgramSymbolTable(locals, keywords, options); } abstract has(name: string): boolean; abstract get(name: string): [symbol: number, isRoot: boolean]; + abstract hasKeyword(name: string): boolean; + abstract getKeyword(name: string): number; + abstract hasLexical(name: string): boolean; - abstract getLexical(name: string): number; abstract getLocalsMap(): Dict; abstract getDebugInfo(): Core.DebugInfo; @@ -42,7 +48,8 @@ export abstract class SymbolTable { export class ProgramSymbolTable extends SymbolTable { constructor( - private templateLocals: string[], + private templateLocals: readonly string[], + private keywords: readonly string[], private options: SymbolTableOptions ) { super(); @@ -62,8 +69,12 @@ export class ProgramSymbolTable extends SymbolTable { return this.options.lexicalScope(name); } - getLexical(name: string): number { - return this.allocateFree(name, ASTv2.HTML_RESOLUTION); + hasKeyword(name: string): boolean { + return this.keywords.includes(name); + } + + getKeyword(name: string): number { + return this.allocateFree(name, ASTv2.STRICT_RESOLUTION); } getUsedTemplateLocals(): string[] { @@ -166,14 +177,18 @@ export class BlockSymbolTable extends SymbolTable { return this.symbols; } - getLexical(name: string): number { - return this.parent.getLexical(name); - } - hasLexical(name: string): boolean { return this.parent.hasLexical(name); } + getKeyword(name: string): number { + return this.parent.getKeyword(name); + } + + hasKeyword(name: string): boolean { + return this.parent.hasKeyword(name); + } + has(name: string): boolean { return this.symbols.indexOf(name) !== -1 || this.parent.has(name); } diff --git a/packages/@glimmer/syntax/lib/v2/builders.ts b/packages/@glimmer/syntax/lib/v2/builders.ts index 84aaf215e5..fd13939783 100644 --- a/packages/@glimmer/syntax/lib/v2/builders.ts +++ b/packages/@glimmer/syntax/lib/v2/builders.ts @@ -148,6 +148,14 @@ export class Builder { }); } + keyword(name: string, symbol: number, loc: SourceSpan): ASTv2.KeywordExpression { + return new ASTv2.KeywordExpression({ + loc, + name, + symbol, + }); + } + self(loc: SourceSpan): ASTv2.VariableReference { return new ASTv2.ThisReference({ loc, diff --git a/packages/@glimmer/syntax/lib/v2/normalize.ts b/packages/@glimmer/syntax/lib/v2/normalize.ts index f226a3d31b..e9b19e70a5 100644 --- a/packages/@glimmer/syntax/lib/v2/normalize.ts +++ b/packages/@glimmer/syntax/lib/v2/normalize.ts @@ -42,16 +42,13 @@ export function normalize( strictMode: false, ...options, locals: ast.blockParams, + keywords: options.keywords ?? [], }; - let top = SymbolTable.top( - normalizeOptions.locals, - - { - customizeComponentName: options.customizeComponentName ?? ((name) => name), - lexicalScope: options.lexicalScope, - } - ); + let top = SymbolTable.top(normalizeOptions.locals, normalizeOptions.keywords, { + customizeComponentName: options.customizeComponentName ?? ((name) => name), + lexicalScope: options.lexicalScope, + }); let block = new BlockContext(source, normalizeOptions, top); let normalizer = new StatementNormalizer(block); @@ -125,6 +122,10 @@ export class BlockContext { return this.table.hasLexical(variable); } + isKeyword(name: string): boolean { + return this.strict && !this.table.hasLexical(name) && this.table.hasKeyword(name); + } + private isFreeVar(callee: ASTv1.CallNode | ASTv1.PathExpression): boolean { if (callee.type === 'PathExpression') { if (callee.head.type !== 'VarHead') { @@ -217,7 +218,21 @@ class ExpressionNormalizer { private path( expr: ASTv1.MinimalPathExpression, resolution: ASTv2.FreeVarResolution - ): ASTv2.PathExpression { + ): ASTv2.KeywordExpression | ASTv2.PathExpression { + let loc = this.block.loc(expr.loc); + + if ( + expr.head.type === 'VarHead' && + expr.tail.length === 0 && + this.block.isKeyword(expr.head.name) + ) { + return this.block.builder.keyword( + expr.head.name, + this.block.table.getKeyword(expr.head.name), + loc + ); + } + let headOffsets = this.block.loc(expr.head.loc); let tail = []; @@ -235,7 +250,7 @@ class ExpressionNormalizer { ); } - return this.block.builder.path(this.ref(expr.head, resolution), tail, this.block.loc(expr.loc)); + return this.block.builder.path(this.ref(expr.head, resolution), tail, loc); } /** diff --git a/packages/@glimmer/syntax/lib/v2/objects/base.ts b/packages/@glimmer/syntax/lib/v2/objects/base.ts index 59a26c8a97..5568a077cc 100644 --- a/packages/@glimmer/syntax/lib/v2/objects/base.ts +++ b/packages/@glimmer/syntax/lib/v2/objects/base.ts @@ -2,7 +2,7 @@ import type { SerializedSourceSpan } from '../../source/span'; import type { Args } from './args'; import type { ElementModifier } from './attr-block'; import type { AppendContent, ContentNode, InvokeBlock, InvokeComponent } from './content'; -import type { CallExpression, PathExpression } from './expr'; +import type { CallExpression, KeywordExpression, PathExpression } from './expr'; import type { BaseNodeFields } from './node'; export interface SerializedBaseNode { @@ -18,7 +18,7 @@ export interface CallFields extends BaseNodeFields { args: Args; } -export type CalleeNode = PathExpression | CallExpression; +export type CalleeNode = KeywordExpression | PathExpression | CallExpression; export type CallNode = | CallExpression diff --git a/packages/@glimmer/syntax/lib/v2/objects/expr.ts b/packages/@glimmer/syntax/lib/v2/objects/expr.ts index 70f91d06c6..0da46ff03a 100644 --- a/packages/@glimmer/syntax/lib/v2/objects/expr.ts +++ b/packages/@glimmer/syntax/lib/v2/objects/expr.ts @@ -71,6 +71,17 @@ export class PathExpression extends node('Path').fields<{ tail: readonly SourceSlice[]; }>() {} +/** + * Corresponds to a known strict-mode keyword. It behaves similarly to a + * PathExpression with a FreeVarReference, but implies StrictResolution and + * is guaranteed to not have a tail, since `{{outlet.foo}}` would have been + * illegal. + */ +export class KeywordExpression extends node('Keyword').fields<{ + name: string; + symbol: number; +}>() {} + /** * Corresponds to a parenthesized call expression. * @@ -97,5 +108,6 @@ export class InterpolateExpression extends node('Interpolate').fields<{ export type ExpressionNode = | LiteralExpression | PathExpression + | KeywordExpression | CallExpression | InterpolateExpression; diff --git a/packages/@glimmer/syntax/lib/v2/serialize/serialize.ts b/packages/@glimmer/syntax/lib/v2/serialize/serialize.ts index 6c6435814a..068993697d 100644 --- a/packages/@glimmer/syntax/lib/v2/serialize/serialize.ts +++ b/packages/@glimmer/syntax/lib/v2/serialize/serialize.ts @@ -35,6 +35,15 @@ import type { import { SourceSlice } from '../../source/slice'; export class RefSerializer { + keyword(keyword: ASTv2.KeywordExpression): SerializedFreeVarReference { + return { + type: 'Free', + loc: keyword.loc.serialize(), + resolution: 'Strict', + name: keyword.name, + }; + } + arg(ref: ASTv2.ArgReference): SerializedArgReference { return { type: 'Arg', @@ -79,6 +88,15 @@ export class ExprSerializer { }; } + keyword(keyword: ASTv2.KeywordExpression): SerializedPathExpression { + return { + type: 'Path', + loc: keyword.loc.serialize(), + ref: REF.keyword(keyword), + tail: [], + }; + } + path(path: ASTv2.PathExpression): SerializedPathExpression { return { type: 'Path', @@ -268,6 +286,8 @@ const visit = { switch (expr.type) { case 'Literal': return EXPR.literal(expr); + case 'Keyword': + return EXPR.keyword(expr); case 'Path': return EXPR.path(expr); case 'Call':