Skip to content

Commit 025e742

Browse files
committed
Introduce keywords option for precompile
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.
1 parent 491fc76 commit 025e742

File tree

19 files changed

+168
-151
lines changed

19 files changed

+168
-151
lines changed

packages/@glimmer-workspace/integration-tests/lib/modes/jit/compilation-context.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ export default class JitCompileTimeLookup implements CompileTimeResolver {
2323
return this.resolver.lookupComponent(name, owner);
2424
}
2525

26-
lookupBuiltInHelper(_name: string): Nullable<HelperDefinitionState> {
27-
return null;
26+
lookupBuiltInHelper(name: string): Nullable<HelperDefinitionState> {
27+
return this.resolver.lookupHelper(`$keyword.${name}`);
2828
}
2929

3030
lookupBuiltInModifier(_name: string): Nullable<ModifierDefinitionState> {

packages/@glimmer-workspace/integration-tests/lib/test-helpers/define.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ export interface DefineComponentOptions {
9696

9797
// defaults to true when some scopeValues are passed and false otherwise
9898
strictMode?: boolean;
99+
100+
// additional strict-mode keywords
101+
keywords?: string[];
99102
}
100103

101104
export function defineComponent(
@@ -110,8 +113,10 @@ export function defineComponent(
110113
strictMode = scopeValues !== null;
111114
}
112115

116+
let keywords = options.keywords ?? [];
117+
113118
let definition = options.definition ?? templateOnlyComponent();
114-
let templateFactory = createTemplate(templateSource, { strictMode }, scopeValues ?? {});
119+
let templateFactory = createTemplate(templateSource, { strictMode, keywords }, scopeValues ?? {});
115120
setComponentTemplate(templateFactory, definition);
116121
return definition;
117122
}

packages/@glimmer-workspace/integration-tests/test/strict-mode-test.ts

+28-2
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,16 @@ class GeneralStrictModeTest extends RenderTest {
7272
}
7373

7474
@test
75-
'Implicit this lookup does not work'() {
75+
'Undefined references is an error'() {
76+
this.registerHelper('bar', () => 'should not resolve this helper');
77+
7678
this.assert.throws(
7779
() => {
7880
defineComponent({}, '{{bar}}', {
7981
definition: class extends GlimmerishComponent {
80-
bar = 'Hello, world!';
82+
get bar() {
83+
throw new Error('should not fallback to this.bar');
84+
}
8185
},
8286
});
8387
},
@@ -91,6 +95,28 @@ class GeneralStrictModeTest extends RenderTest {
9195
);
9296
}
9397

98+
@test
99+
'Non-native keyword'() {
100+
this.registerHelper('bar', () => {
101+
throw new Error('should not resolve this helper');
102+
});
103+
104+
this.registerHelper('$keyword.bar', () => 'bar keyword');
105+
106+
const Foo = defineComponent({}, '{{bar}}', {
107+
keywords: ['bar'],
108+
definition: class extends GlimmerishComponent {
109+
get bar() {
110+
throw new Error('should not fallback to this.bar');
111+
}
112+
},
113+
});
114+
115+
this.renderComponent(Foo);
116+
this.assertHTML('bar keyword');
117+
this.assertStableRerender();
118+
}
119+
94120
@test
95121
'{{component}} throws an error if a string is used in strict (append position)'() {
96122
this.assert.throws(() => {

packages/@glimmer/compiler/lib/builder/builder.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,7 @@ export function buildVar(
632632
symbols: Symbols,
633633
path?: PresentArray<string>
634634
): Expressions.GetPath | Expressions.GetVar {
635-
let op: Expressions.GetVar[0] = Op.GetSymbol;
635+
let op: Expressions.GetPath[0] | Expressions.GetVar[0] = Op.GetSymbol;
636636
let sym: number;
637637
switch (head.kind) {
638638
case VariableKind.Free:
@@ -665,6 +665,7 @@ export function buildVar(
665665
if (path === undefined || path.length === 0) {
666666
return [op, sym];
667667
} else {
668+
assert(op !== Op.GetStrictKeyword, '[BUG] keyword with a path');
668669
return [op, sym, path];
669670
}
670671
}

packages/@glimmer/compiler/lib/passes/1-normalization/utils/is-node.ts

+1-108
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,4 @@
1-
import type { PresentArray } from '@glimmer/interfaces';
2-
import type { SourceSlice } from '@glimmer/syntax';
3-
import { ASTv2, generateSyntaxError } from '@glimmer/syntax';
4-
import { unreachable } from '@glimmer/util';
5-
6-
export type HasPath<Node extends ASTv2.CallNode = ASTv2.CallNode> = Node & {
7-
head: ASTv2.PathExpression;
8-
};
9-
10-
export type HasArguments =
11-
| {
12-
params: PresentArray<ASTv2.ExpressionNode>;
13-
}
14-
| {
15-
hash: {
16-
pairs: PresentArray<ASTv2.NamedArgument>;
17-
};
18-
};
19-
20-
export type HelperInvocation<Node extends ASTv2.CallNode = ASTv2.CallNode> = HasPath<Node> &
21-
HasArguments;
22-
23-
export function hasPath<N extends ASTv2.CallNode>(node: N): node is HasPath<N> {
24-
return node.callee.type === 'Path';
25-
}
26-
27-
export function isHelperInvocation<N extends ASTv2.CallNode>(
28-
node: ASTv2.CallNode
29-
): node is HelperInvocation<N> {
30-
if (!hasPath(node)) {
31-
return false;
32-
}
33-
34-
return !node.args.isEmpty();
35-
}
36-
37-
export interface SimplePath extends ASTv2.PathExpression {
38-
tail: [SourceSlice];
39-
data: false;
40-
this: false;
41-
}
42-
43-
export type SimpleHelper<N extends HasPath> = N & {
44-
path: SimplePath;
45-
};
46-
47-
export function isSimplePath(path: ASTv2.ExpressionNode): path is SimplePath {
48-
if (path.type === 'Path') {
49-
let { ref: head, tail: parts } = path;
50-
51-
return head.type === 'Free' && !ASTv2.isStrictResolution(head.resolution) && parts.length === 0;
52-
} else {
53-
return false;
54-
}
55-
}
56-
57-
export function isStrictHelper(expr: HasPath): boolean {
58-
if (expr.callee.type !== 'Path') {
59-
return true;
60-
}
61-
62-
if (expr.callee.ref.type !== 'Free') {
63-
return true;
64-
}
65-
66-
return ASTv2.isStrictResolution(expr.callee.ref.resolution);
67-
}
68-
69-
export function assertIsValidModifier<N extends HasPath>(
70-
helper: N
71-
): asserts helper is SimpleHelper<N> {
72-
if (isStrictHelper(helper) || isSimplePath(helper.callee)) {
73-
return;
74-
}
75-
76-
throw generateSyntaxError(
77-
`\`${printPath(helper.callee)}\` is not a valid name for a modifier`,
78-
helper.loc
79-
);
80-
}
81-
82-
function printPath(path: ASTv2.ExpressionNode): string {
83-
switch (path.type) {
84-
case 'Literal':
85-
return JSON.stringify(path.value);
86-
case 'Path': {
87-
let printedPath = [printPathHead(path.ref)];
88-
printedPath.push(...path.tail.map((t) => t.chars));
89-
return printedPath.join('.');
90-
}
91-
case 'Call':
92-
return `(${printPath(path.callee)} ...)`;
93-
case 'Interpolate':
94-
throw unreachable('a concat statement cannot appear as the head of an expression');
95-
}
96-
}
97-
98-
function printPathHead(head: ASTv2.VariableReference): string {
99-
switch (head.type) {
100-
case 'Arg':
101-
return head.name.chars;
102-
case 'Free':
103-
case 'Local':
104-
return head.name;
105-
case 'This':
106-
return 'this';
107-
}
108-
}
1+
import type { ASTv2 } from '@glimmer/syntax';
1092

1103
/**
1114
* This function is checking whether an AST node is a triple-curly, which means that it's

packages/@glimmer/compiler/lib/passes/1-normalization/visitors/element/classified.ts

-5
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { Ok, Result, ResultArray } from '../../../../shared/result';
77
import { getAttrNamespace } from '../../../../utils';
88
import * as mir from '../../../2-encoding/mir';
99
import { MODIFIER_KEYWORDS } from '../../keywords';
10-
import { assertIsValidModifier, isHelperInvocation } from '../../utils/is-node';
1110
import { convertPathToCallIfKeyword, VISIT_EXPRS } from '../expressions';
1211

1312
export type ValidAttr = mir.StaticAttr | mir.DynamicAttr | mir.SplatAttr;
@@ -75,10 +74,6 @@ export class ClassifiedElement {
7574
}
7675

7776
private modifier(modifier: ASTv2.ElementModifier): Result<mir.Modifier> {
78-
if (isHelperInvocation(modifier)) {
79-
assertIsValidModifier(modifier);
80-
}
81-
8277
let translated = MODIFIER_KEYWORDS.translate(modifier, this.state);
8378

8479
if (translated !== null) {

packages/@glimmer/compiler/lib/passes/1-normalization/visitors/expressions.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import type { NormalizationState } from '../context';
88
import { Ok, Result, ResultArray } from '../../../shared/result';
99
import * as mir from '../../2-encoding/mir';
1010
import { CALL_KEYWORDS } from '../keywords';
11-
import { hasPath } from '../utils/is-node';
1211

1312
export class NormalizeExpressions {
1413
visit(node: ASTv2.ExpressionNode, state: NormalizationState): Result<mir.ExpressionNode> {
1514
switch (node.type) {
1615
case 'Literal':
1716
return Ok(this.Literal(node));
17+
case 'Keyword':
18+
return Ok(this.Keyword(node));
1819
case 'Interpolate':
1920
return this.Interpolate(node, state);
2021
case 'Path':
@@ -78,6 +79,10 @@ export class NormalizeExpressions {
7879
return literal;
7980
}
8081

82+
Keyword(keyword: ASTv2.KeywordExpression): ASTv2.KeywordExpression {
83+
return keyword;
84+
}
85+
8186
Interpolate(
8287
expr: ASTv2.InterpolateExpression,
8388
state: NormalizationState
@@ -93,8 +98,8 @@ export class NormalizeExpressions {
9398
expr: ASTv2.CallExpression,
9499
state: NormalizationState
95100
): Result<mir.ExpressionNode> {
96-
if (!hasPath(expr)) {
97-
throw new Error(`unimplemented subexpression at the head of a subexpression`);
101+
if (expr.callee.type === 'Call') {
102+
throw new Error(`unimplemented: subexpression at the head of a subexpression`);
98103
} else {
99104
return Result.all(
100105
VISIT_EXPRS.visit(expr.callee, state),

packages/@glimmer/compiler/lib/passes/1-normalization/visitors/strict-mode.ts

+1
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export default class StrictModeValidationPass {
120120
): Result<null> {
121121
switch (expression.type) {
122122
case 'Literal':
123+
case 'Keyword':
123124
case 'Missing':
124125
case 'This':
125126
case 'Arg':

packages/@glimmer/compiler/lib/passes/2-encoding/expressions.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { PresentArray, WireFormat } from '@glimmer/interfaces';
22
import type { ASTv2 } from '@glimmer/syntax';
3-
import { assertPresentArray, isPresentArray, mapPresentArray } from '@glimmer/util';
3+
import { assert, assertPresentArray, isPresentArray, mapPresentArray } from '@glimmer/util';
44
import { SexpOpcodes } from '@glimmer/wire-format';
55

66
import type * as mir from './mir';
@@ -14,6 +14,8 @@ export class ExpressionEncoder {
1414
return undefined;
1515
case 'Literal':
1616
return this.Literal(expr);
17+
case 'Keyword':
18+
return this.Keyword(expr);
1719
case 'CallExpression':
1820
return this.CallExpression(expr);
1921
case 'PathExpression':
@@ -86,9 +88,13 @@ export class ExpressionEncoder {
8688
return [isTemplateLocal ? SexpOpcodes.GetLexicalSymbol : SexpOpcodes.GetSymbol, symbol];
8789
}
8890

91+
Keyword({ symbol }: ASTv2.KeywordExpression): WireFormat.Expressions.GetStrictFree {
92+
return [SexpOpcodes.GetStrictKeyword, symbol];
93+
}
94+
8995
PathExpression({ head, tail }: mir.PathExpression): WireFormat.Expressions.GetPath {
9096
let getOp = EXPR.expr(head) as WireFormat.Expressions.GetVar;
91-
97+
assert(getOp[0] !== SexpOpcodes.GetStrictKeyword, '[BUG] keyword in a PathExpression');
9298
return [...getOp, EXPR.Tail(tail)];
9399
}
94100

packages/@glimmer/compiler/lib/passes/2-encoding/mir.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,10 @@ export class Tail extends node('Tail').fields<{ members: PresentArray<SourceSlic
180180

181181
export type ExpressionNode =
182182
| ASTv2.LiteralExpression
183+
| ASTv2.KeywordExpression
184+
| ASTv2.VariableReference
183185
| Missing
184186
| PathExpression
185-
| ASTv2.VariableReference
186187
| InterpolateExpression
187188
| CallExpression
188189
| Not

packages/@glimmer/compiler/lib/wire-format-debug.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export default class WireFormatDebugger {
170170
return ['concat', this.formatParams(opcode[1] as WireFormat.Core.Params)];
171171

172172
case Op.GetStrictKeyword:
173-
return ['get-strict-free', this.upvars[opcode[1]], opcode[2]];
173+
return ['get-strict-free', this.upvars[opcode[1]]];
174174

175175
case Op.GetFreeAsComponentOrHelperHead:
176176
return ['GetFreeAsComponentOrHelperHead', this.upvars[opcode[1]], opcode[2]];

packages/@glimmer/interfaces/lib/compile/wire-format/api.d.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ export namespace Expressions {
111111

112112
export type GetPathSymbol = [GetSymbolOpcode, number, Path];
113113
export type GetPathTemplateSymbol = [GetLexicalSymbolOpcode, number, Path];
114-
export type GetPathStrictFree = [GetStrictKeywordOpcode, number, Path];
115114
export type GetPathFreeAsComponentOrHelperHead = [
116115
GetFreeAsComponentOrHelperHeadOpcode,
117116
number,
@@ -126,8 +125,7 @@ export namespace Expressions {
126125
| GetPathFreeAsHelperHead
127126
| GetPathFreeAsModifierHead
128127
| GetPathFreeAsComponentHead;
129-
export type GetPathFree = GetPathStrictFree | GetPathContextualFree;
130-
export type GetPath = GetPathSymbol | GetPathTemplateSymbol | GetPathFree;
128+
export type GetPath = GetPathSymbol | GetPathTemplateSymbol | GetPathContextualFree;
131129

132130
export type Get = GetVar | GetPath;
133131

packages/@glimmer/syntax/lib/parser/tokenizer-event-handlers.ts

+16
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,22 @@ export interface TemplateIdFn {
661661

662662
export interface PrecompileOptions extends PreprocessOptions {
663663
id?: TemplateIdFn;
664+
665+
/**
666+
* Additional non-native keywords.
667+
*
668+
* Local variables (block params or lexical scope) always takes precedence,
669+
* but otherwise, suitable free variable candidates (e.g. those are not part
670+
* of a path) are matched against this list and turned into keywords.
671+
*
672+
* In strict mode compilation, keywords suppresses the undefined reference
673+
* error and will be resolved by the runtime environment.
674+
*
675+
* In loose mode, keywords are currently ignored and since all free variables
676+
* are already resolved by the runtime environment.
677+
*/
678+
keywords?: readonly string[];
679+
664680
customizeComponentName?: ((input: string) => string) | undefined;
665681
}
666682

0 commit comments

Comments
 (0)