Skip to content

Commit ac57778

Browse files
committed
Second attempt at landing #1552 (part 1/4)
The original commit has a few issues: - Type safety - Added a new AST node, which is unnecessary since we already have `VarHead`, but is also a breaking change and is kind of breakage we have been careful to avoid in this area thus far - The parsing is robust against a few classes of edge cases This rework the same feature (on the `Block`/`BlockStatement` side) and adds more test coverage for the feature. Also reanmed the field to `block.params`.
1 parent 422e05d commit ac57778

10 files changed

+396
-112
lines changed

packages/@glimmer/syntax/lib/parser.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export abstract class Parser {
7676
// node.loc = node.loc.withEnd(end);
7777
}
7878

79+
abstract parse(node: HBS.Program, locals: string[]): ASTv1.Template;
80+
7981
abstract Program(node: HBS.Program): HBS.Output<'Program'>;
8082
abstract MustacheStatement(node: HBS.MustacheStatement): HBS.Output<'MustacheStatement'>;
8183
abstract Decorator(node: HBS.Decorator): HBS.Output<'Decorator'>;
@@ -149,14 +151,6 @@ export abstract class Parser {
149151
return node;
150152
}
151153

152-
acceptTemplate(node: HBS.Program): ASTv1.Template {
153-
let result = this.Program(node);
154-
assert(result.type === 'Template', 'expected a template');
155-
return result;
156-
}
157-
158-
acceptNode(node: HBS.Program): ASTv1.Block | ASTv1.Template;
159-
acceptNode<U extends HBS.Node | ASTv1.Node>(node: HBS.Node): U;
160154
acceptNode<T extends HBS.NodeType>(node: HBS.Node<T>): HBS.Output<T> {
161155
return (this[node.type as T] as (node: HBS.Node<T>) => HBS.Output<T>)(node);
162156
}

packages/@glimmer/syntax/lib/parser/handlebars-node-visitors.ts

+115-38
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { Nullable, Recast } from '@glimmer/interfaces';
22
import type { TokenizerState } from 'simple-html-tokenizer';
3-
import { getLast, isPresentArray, unwrap } from '@glimmer/util';
3+
import { assert, getLast, isPresentArray, unwrap } from '@glimmer/util';
44

55
import type { ParserNodeBuilder, Tag } from '../parser';
6+
import type { SourceSpan } from '../source/span';
67
import type * as ASTv1 from '../v1/api';
78
import type * as HBS from '../v1/handlebars-ast';
89

@@ -24,46 +25,62 @@ export abstract class HandlebarsNodeVisitors extends Parser {
2425
return this.elementStack.length === 0;
2526
}
2627

27-
Program(program: HBS.Program): ASTv1.Block;
28-
Program(program: HBS.Program): ASTv1.Template;
29-
Program(program: HBS.Program): ASTv1.Template | ASTv1.Block;
30-
Program(program: HBS.Program): ASTv1.Block | ASTv1.Template {
31-
const body: ASTv1.Statement[] = [];
32-
let node;
33-
34-
if (this.isTopLevel) {
35-
node = b.template({
36-
body,
37-
loc: this.source.spanFor(program.loc),
38-
});
39-
} else {
40-
node = b.blockItself({
41-
body,
42-
blockParams: program.blockParams,
43-
chained: program.chained,
44-
loc: this.source.spanFor(program.loc),
45-
});
46-
}
28+
parse(program: HBS.Program, locals: string[]): ASTv1.Template {
29+
let node = b.template({
30+
body: [],
31+
locals,
32+
loc: this.source.spanFor(program.loc),
33+
});
4734

48-
let i,
49-
l = program.body.length;
35+
return this.parseProgram(node, program);
36+
}
5037

51-
this.elementStack.push(node);
38+
Program(program: HBS.Program, blockParams?: ASTv1.VarHead[]): ASTv1.Block {
39+
// The abstract signature doesn't have the blockParams argument, but in
40+
// practice we can only come from this.BlockStatement() which adds the
41+
// extra argument for us
42+
assert(
43+
Array.isArray(blockParams),
44+
'[BUG] Program in parser unexpectedly called without block params'
45+
);
5246

53-
if (l === 0) {
54-
return this.elementStack.pop() as ASTv1.Block | ASTv1.Template;
47+
let node = b.blockItself({
48+
body: [],
49+
params: blockParams,
50+
chained: program.chained,
51+
loc: this.source.spanFor(program.loc),
52+
});
53+
54+
return this.parseProgram(node, program);
55+
}
56+
57+
private parseProgram<T extends ASTv1.ParentNode>(node: T, program: HBS.Program): T {
58+
if (program.body.length === 0) {
59+
return node;
5560
}
5661

57-
for (i = 0; i < l; i++) {
58-
this.acceptNode(unwrap(program.body[i]));
62+
let poppedNode;
63+
64+
try {
65+
this.elementStack.push(node);
66+
67+
for (let child of program.body) {
68+
this.acceptNode(child);
69+
}
70+
} finally {
71+
poppedNode = this.elementStack.pop();
5972
}
6073

6174
// Ensure that that the element stack is balanced properly.
62-
const poppedNode = this.elementStack.pop();
63-
if (poppedNode !== node) {
64-
const elementNode = poppedNode as ASTv1.ElementNode;
65-
66-
throw generateSyntaxError(`Unclosed element \`${elementNode.tag}\``, elementNode.loc);
75+
if (node !== poppedNode) {
76+
if (poppedNode?.type === 'ElementNode') {
77+
throw generateSyntaxError(`Unclosed element \`${poppedNode.tag}\``, poppedNode.loc);
78+
} else {
79+
// If the stack is not balanced, then it is likely our own bug, because
80+
// any unclosed Handlebars blocks should already been caught by now
81+
assert(poppedNode !== undefined, '[BUG] empty parser elementStack');
82+
assert(false, `[BUG] mismatched parser elementStack node: ${node.type}`);
83+
}
6784
}
6885

6986
return node;
@@ -83,6 +100,66 @@ export abstract class HandlebarsNodeVisitors extends Parser {
83100
}
84101

85102
const { path, params, hash } = acceptCallNodes(this, block);
103+
const loc = this.source.spanFor(block.loc);
104+
105+
// Backfill block params loc for the default block
106+
let blockParams: ASTv1.VarHead[] = [];
107+
108+
if (block.program.blockParams?.length) {
109+
// Start from right after the hash
110+
let span = hash.loc.collapse('end');
111+
112+
// Extend till the beginning of the block
113+
if (block.program.loc) {
114+
span = span.withEnd(this.source.spanFor(block.program.loc).getStart());
115+
}
116+
if (block.program.body[0]) {
117+
span = span.withEnd(this.source.spanFor(block.program.body[0].loc).getStart());
118+
} else {
119+
// ...or if all else fail, use the end of the block statement
120+
// this can only happen if the block statement is empty anyway
121+
span = span.withEnd(loc.getEnd());
122+
}
123+
124+
// Now we have a span for something like this:
125+
//
126+
// {{#foo bar baz=bat as |wow wat|}}
127+
// ~~~~~~~~~~~~~~~
128+
//
129+
// Or, if we are unlucky:
130+
//
131+
// {{#foo bar baz=bat as |wow wat|}}{{/foo}}
132+
// ~~~~~~~~~~~~~~~~~~~~~~~
133+
//
134+
// Either way, within this span, there should be exactly two pipes
135+
// fencing our block params, neatly whitespace separated and with
136+
// legal identifiers only
137+
const content = span.asString();
138+
let skipStart = content.indexOf('|') + 1;
139+
const limit = content.indexOf('|', skipStart);
140+
141+
for (const name of block.program.blockParams) {
142+
let nameStart: number;
143+
let loc: SourceSpan;
144+
145+
if (skipStart >= limit) {
146+
nameStart = -1;
147+
} else {
148+
nameStart = content.indexOf(name, skipStart);
149+
}
150+
151+
if (nameStart === -1 || nameStart + name.length > limit) {
152+
skipStart = limit;
153+
loc = this.source.spanFor(NON_EXISTENT_LOCATION);
154+
} else {
155+
skipStart = nameStart;
156+
loc = span.sliceStartChars({ skipStart, chars: name.length });
157+
skipStart += name.length;
158+
}
159+
160+
blockParams.push(b.var({ name, loc }));
161+
}
162+
}
86163

87164
// These are bugs in Handlebars upstream
88165
if (!block.program.loc) {
@@ -93,8 +170,8 @@ export abstract class HandlebarsNodeVisitors extends Parser {
93170
block.inverse.loc = NON_EXISTENT_LOCATION;
94171
}
95172

96-
const program = this.Program(block.program);
97-
const inverse = block.inverse ? this.Program(block.inverse) : null;
173+
const program = this.Program(block.program, blockParams);
174+
const inverse = block.inverse ? this.Program(block.inverse, []) : null;
98175

99176
const node = b.block({
100177
path,
@@ -126,7 +203,7 @@ export abstract class HandlebarsNodeVisitors extends Parser {
126203

127204
if (isHBSLiteral(rawMustache.path)) {
128205
mustache = b.mustache({
129-
path: this.acceptNode<ASTv1.Literal>(rawMustache.path),
206+
path: this.acceptNode<(typeof rawMustache.path)['type']>(rawMustache.path),
130207
params: [],
131208
hash: b.hash({ pairs: [], loc: this.source.spanFor(rawMustache.path.loc).collapse('end') }),
132209
trusting: !escaped,
@@ -388,7 +465,7 @@ export abstract class HandlebarsNodeVisitors extends Parser {
388465
const pairs = hash.pairs.map((pair) =>
389466
b.pair({
390467
key: pair.key,
391-
value: this.acceptNode(pair.value),
468+
value: this.acceptNode<HBS.Expression['type']>(pair.value),
392469
loc: this.source.spanFor(pair.loc),
393470
})
394471
);
@@ -536,7 +613,7 @@ function acceptCallNodes(
536613
}
537614

538615
const params = node.params
539-
? node.params.map((e) => compiler.acceptNode<ASTv1.Expression>(e))
616+
? node.params.map((e) => compiler.acceptNode<HBS.Expression['type']>(e))
540617
: [];
541618

542619
// if there is no hash, position it as a collapsed node immediately after the last param (or the

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

+4-1
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,10 @@ export function preprocess(
441441
end: offsets.endPosition,
442442
};
443443

444-
let template = new TokenizerEventHandlers(source, entityParser, mode).acceptTemplate(ast);
444+
let template = new TokenizerEventHandlers(source, entityParser, mode).parse(
445+
ast,
446+
options.locals ?? []
447+
);
445448

446449
if (options.strictMode && options.locals?.length) {
447450
template = b.template({ ...template, locals: options.locals });

packages/@glimmer/syntax/lib/source/loc/span.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ export class SourceSpan implements SourceLocation {
190190
/**
191191
* Create a new span with the current span's beginning and a new ending.
192192
*/
193-
withEnd(this: SourceSpan, other: SourceOffset): SourceSpan {
193+
withEnd(other: SourceOffset): SourceSpan {
194194
return span(this.data.getStart(), other.data);
195195
}
196196

packages/@glimmer/syntax/lib/v1/handlebars-ast.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export interface CommonNode {
1313
}
1414

1515
export interface NodeMap {
16-
Program: { input: Program; output: ASTv1.Template | ASTv1.Block };
16+
Program: { input: Program; output: ASTv1.Block };
1717
MustacheStatement: { input: MustacheStatement; output: ASTv1.MustacheStatement | void };
1818
Decorator: { input: Decorator; output: never };
1919
BlockStatement: { input: BlockStatement; output: ASTv1.BlockStatement | void };
@@ -50,7 +50,7 @@ export interface Position {
5050
export interface Program extends CommonNode {
5151
type: 'Program';
5252
body: Statement[];
53-
blockParams: string[];
53+
blockParams?: string[];
5454
chained?: boolean;
5555
}
5656

packages/@glimmer/syntax/lib/v1/nodes-v1.ts

+34-1
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@ export interface CommonProgram extends BaseNode {
1616

1717
export interface Block extends CommonProgram {
1818
type: 'Block';
19-
blockParams: string[];
19+
params: VarHead[];
2020
chained?: boolean;
21+
22+
/**
23+
* string accessor for params.name
24+
*/
25+
blockParams: string[];
2126
}
2227

2328
export type EntityEncodingState = 'transformed' | 'raw';
@@ -302,6 +307,34 @@ export type Nodes = {
302307
export type NodeType = keyof Nodes;
303308
export type Node = Nodes[NodeType];
304309

310+
// These "sub-node" cannot appear standalone, they are only used inside another
311+
// "real" AST node to provide richer information. The distinction mostly exists
312+
// for backwards compatibility reason. These nodes are not traversed and do not
313+
// have visitor keys for them, so it won't break existing AST consumers (e.g.
314+
// those that implemented an `All` visitor may not be expecting these new types
315+
// of nodes).
316+
//
317+
// Conceptually, the idea of "sub-node" does make sense, and you can say source
318+
// locations are another kind of these things. However, in these cases, they
319+
// actually fully implement the `BaseNode` interface, and only not extending
320+
// `BaseNode` because the `type` field is not `keyof Nodes` (which is circular
321+
// reasoning). If these are not "real" nodes because they can only appear in
322+
// very limited context, then the same reasoning probably applies for, say,
323+
// HashPair.
324+
//
325+
// If we do eventually make some kind of breaking change here, perhaps with
326+
// some kind of opt-in, then we can consider upgrading these into "real" nodes,
327+
// but for now, this is where they go, and it isn't a huge problem in practice
328+
// because there are little utility in traversing these kind of nodes anyway.
329+
export type SubNodes = {
330+
ThisHead: ThisHead;
331+
AtHead: AtHead;
332+
VarHead: VarHead;
333+
};
334+
335+
export type SubNodeType = keyof SubNodes;
336+
export type SubNode = SubNodes[SubNodeType];
337+
305338
export type Statement = Nodes[StatementName];
306339
export type Statements = Pick<Nodes, StatementName>;
307340
export type Literal = Nodes[LiteralName];

0 commit comments

Comments
 (0)