/
type_check_block.ts
1325 lines (1196 loc) Β· 55.2 KB
/
type_check_block.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AST, BindingPipe, BindingType, BoundTarget, DYNAMIC_TYPE, ImplicitReceiver, MethodCall, ParseSourceSpan, ParseSpan, ParsedEventType, PropertyRead, SchemaMetadata, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
import * as ts from 'typescript';
import {Reference} from '../../imports';
import {ClassDeclaration} from '../../reflection';
import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta} from './api';
import {addParseSpanInfo, addSourceId, toAbsoluteSpan, wrapForDiagnostics} from './diagnostics';
import {DomSchemaChecker} from './dom';
import {Environment} from './environment';
import {NULL_AS_ANY, astToTypescript} from './expression';
import {OutOfBandDiagnosticRecorder} from './oob';
import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateVariable, tsDeclareVariable} from './ts_util';
/**
* Given a `ts.ClassDeclaration` for a component, and metadata regarding that component, compose a
* "type check block" function.
*
* When passed through TypeScript's TypeChecker, type errors that arise within the type check block
* function indicate issues in the template itself.
*
* As a side effect of generating a TCB for the component, `ts.Diagnostic`s may also be produced
* directly for issues within the template which are identified during generation. These issues are
* recorded in either the `domSchemaChecker` (which checks usage of DOM elements and bindings) as
* well as the `oobRecorder` (which records errors when the type-checking code generator is unable
* to sufficiently understand a template).
*
* @param env an `Environment` into which type-checking code will be generated.
* @param ref a `Reference` to the component class which should be type-checked.
* @param name a `ts.Identifier` to use for the generated `ts.FunctionDeclaration`.
* @param meta metadata about the component's template and the function being generated.
* @param domSchemaChecker used to check and record errors regarding improper usage of DOM elements
* and bindings.
* @param oobRecorder used to record errors regarding template elements which could not be correctly
* translated into types during TCB generation.
*/
export function generateTypeCheckBlock(
env: Environment, ref: Reference<ClassDeclaration<ts.ClassDeclaration>>, name: ts.Identifier,
meta: TypeCheckBlockMetadata, domSchemaChecker: DomSchemaChecker,
oobRecorder: OutOfBandDiagnosticRecorder): ts.FunctionDeclaration {
const tcb = new Context(
env, domSchemaChecker, oobRecorder, meta.id, meta.boundTarget, meta.pipes, meta.schemas);
const scope = Scope.forNodes(tcb, null, tcb.boundTarget.target.template !);
const ctxRawType = env.referenceType(ref);
if (!ts.isTypeReferenceNode(ctxRawType)) {
throw new Error(
`Expected TypeReferenceNode when referencing the ctx param for ${ref.debugName}`);
}
const paramList = [tcbCtxParam(ref.node, ctxRawType.typeName)];
const scopeStatements = scope.render();
const innerBody = ts.createBlock([
...env.getPreludeStatements(),
...scopeStatements,
]);
// Wrap the body in an "if (true)" expression. This is unnecessary but has the effect of causing
// the `ts.Printer` to format the type-check block nicely.
const body = ts.createBlock([ts.createIf(ts.createTrue(), innerBody, undefined)]);
const fnDecl = ts.createFunctionDeclaration(
/* decorators */ undefined,
/* modifiers */ undefined,
/* asteriskToken */ undefined,
/* name */ name,
/* typeParameters */ ref.node.typeParameters,
/* parameters */ paramList,
/* type */ undefined,
/* body */ body);
addSourceId(fnDecl, meta.id);
return fnDecl;
}
/**
* A code generation operation that's involved in the construction of a Type Check Block.
*
* The generation of a TCB is non-linear. Bindings within a template may result in the need to
* construct certain types earlier than they otherwise would be constructed. That is, if the
* generation of a TCB for a template is broken down into specific operations (constructing a
* directive, extracting a variable from a let- operation, etc), then it's possible for operations
* earlier in the sequence to depend on operations which occur later in the sequence.
*
* `TcbOp` abstracts the different types of operations which are required to convert a template into
* a TCB. This allows for two phases of processing for the template, where 1) a linear sequence of
* `TcbOp`s is generated, and then 2) these operations are executed, not necessarily in linear
* order.
*
* Each `TcbOp` may insert statements into the body of the TCB, and also optionally return a
* `ts.Expression` which can be used to reference the operation's result.
*/
abstract class TcbOp { abstract execute(): ts.Expression|null; }
/**
* A `TcbOp` which creates an expression for a native DOM element (or web component) from a
* `TmplAstElement`.
*
* Executing this operation returns a reference to the element variable.
*/
class TcbElementOp extends TcbOp {
constructor(private tcb: Context, private scope: Scope, private element: TmplAstElement) {
super();
}
execute(): ts.Identifier {
const id = this.tcb.allocateId();
// Add the declaration of the element using document.createElement.
const initializer = tsCreateElement(this.element.name);
addParseSpanInfo(initializer, this.element.startSourceSpan || this.element.sourceSpan);
this.scope.addStatement(tsCreateVariable(id, initializer));
return id;
}
}
/**
* A `TcbOp` which creates an expression for particular let- `TmplAstVariable` on a
* `TmplAstTemplate`'s context.
*
* Executing this operation returns a reference to the variable variable (lol).
*/
class TcbVariableOp extends TcbOp {
constructor(
private tcb: Context, private scope: Scope, private template: TmplAstTemplate,
private variable: TmplAstVariable) {
super();
}
execute(): ts.Identifier {
// Look for a context variable for the template.
const ctx = this.scope.resolve(this.template);
// Allocate an identifier for the TmplAstVariable, and initialize it to a read of the variable
// on the template context.
const id = this.tcb.allocateId();
const initializer = ts.createPropertyAccess(
/* expression */ ctx,
/* name */ this.variable.value || '$implicit');
addParseSpanInfo(initializer, this.variable.sourceSpan);
// Declare the variable, and return its identifier.
this.scope.addStatement(tsCreateVariable(id, initializer));
return id;
}
}
/**
* A `TcbOp` which generates a variable for a `TmplAstTemplate`'s context.
*
* Executing this operation returns a reference to the template's context variable.
*/
class TcbTemplateContextOp extends TcbOp {
constructor(private tcb: Context, private scope: Scope) { super(); }
execute(): ts.Identifier {
// Allocate a template ctx variable and declare it with an 'any' type. The type of this variable
// may be narrowed as a result of template guard conditions.
const ctx = this.tcb.allocateId();
const type = ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword);
this.scope.addStatement(tsDeclareVariable(ctx, type));
return ctx;
}
}
/**
* A `TcbOp` which descends into a `TmplAstTemplate`'s children and generates type-checking code for
* them.
*
* This operation wraps the children's type-checking code in an `if` block, which may include one
* or more type guard conditions that narrow types within the template body.
*/
class TcbTemplateBodyOp extends TcbOp {
constructor(private tcb: Context, private scope: Scope, private template: TmplAstTemplate) {
super();
}
execute(): null {
// Create a new Scope for the template. This constructs the list of operations for the template
// children, as well as tracks bindings within the template.
const tmplScope = Scope.forNodes(this.tcb, this.scope, this.template);
// An `if` will be constructed, within which the template's children will be type checked. The
// `if` is used for two reasons: it creates a new syntactic scope, isolating variables declared
// in the template's TCB from the outer context, and it allows any directives on the templates
// to perform type narrowing of either expressions or the template's context.
//
// The guard is the `if` block's condition. It's usually set to `true` but directives that exist
// on the template can trigger extra guard expressions that serve to narrow types within the
// `if`. `guard` is calculated by starting with `true` and adding other conditions as needed.
// Collect these into `guards` by processing the directives.
const directiveGuards: ts.Expression[] = [];
const directives = this.tcb.boundTarget.getDirectivesOfNode(this.template);
if (directives !== null) {
for (const dir of directives) {
const dirInstId = this.scope.resolve(this.template, dir);
const dirId =
this.tcb.env.reference(dir.ref as Reference<ClassDeclaration<ts.ClassDeclaration>>);
// There are two kinds of guards. Template guards (ngTemplateGuards) allow type narrowing of
// the expression passed to an @Input of the directive. Scan the directive to see if it has
// any template guards, and generate them if needed.
dir.ngTemplateGuards.forEach(guard => {
// For each template guard function on the directive, look for a binding to that input.
const boundInput = this.template.inputs.find(i => i.name === guard.inputName) ||
this.template.templateAttrs.find(
(i: TmplAstTextAttribute | TmplAstBoundAttribute): i is TmplAstBoundAttribute =>
i instanceof TmplAstBoundAttribute && i.name === guard.inputName);
if (boundInput !== undefined) {
// If there is such a binding, generate an expression for it.
const expr = tcbExpression(
boundInput.value, this.tcb, this.scope,
boundInput.valueSpan || boundInput.sourceSpan);
if (guard.type === 'binding') {
// Use the binding expression itself as guard.
directiveGuards.push(expr);
} else {
// Call the guard function on the directive with the directive instance and that
// expression.
const guardInvoke = tsCallMethod(dirId, `ngTemplateGuard_${guard.inputName}`, [
dirInstId,
expr,
]);
addParseSpanInfo(
guardInvoke, toAbsoluteSpan(boundInput.value.span, boundInput.sourceSpan));
directiveGuards.push(guardInvoke);
}
}
});
// The second kind of guard is a template context guard. This guard narrows the template
// rendering context variable `ctx`.
if (dir.hasNgTemplateContextGuard && this.tcb.env.config.applyTemplateContextGuards) {
const ctx = this.scope.resolve(this.template);
const guardInvoke = tsCallMethod(dirId, 'ngTemplateContextGuard', [dirInstId, ctx]);
addParseSpanInfo(guardInvoke, this.template.sourceSpan);
directiveGuards.push(guardInvoke);
}
}
}
// By default the guard is simply `true`.
let guard: ts.Expression = ts.createTrue();
// If there are any guards from directives, use them instead.
if (directiveGuards.length > 0) {
// Pop the first value and use it as the initializer to reduce(). This way, a single guard
// will be used on its own, but two or more will be combined into binary AND expressions.
guard = directiveGuards.reduce(
(expr, dirGuard) =>
ts.createBinary(expr, ts.SyntaxKind.AmpersandAmpersandToken, dirGuard),
directiveGuards.pop() !);
}
// Construct the `if` block for the template with the generated guard expression. The body of
// the `if` block is created by rendering the template's `Scope.
const tmplIf = ts.createIf(
/* expression */ guard,
/* thenStatement */ ts.createBlock(tmplScope.render()));
this.scope.addStatement(tmplIf);
return null;
}
}
/**
* A `TcbOp` which renders a text binding (interpolation) into the TCB.
*
* Executing this operation returns nothing.
*/
class TcbTextInterpolationOp extends TcbOp {
constructor(private tcb: Context, private scope: Scope, private binding: TmplAstBoundText) {
super();
}
execute(): null {
const expr = tcbExpression(this.binding.value, this.tcb, this.scope, this.binding.sourceSpan);
this.scope.addStatement(ts.createExpressionStatement(expr));
return null;
}
}
/**
* A `TcbOp` which constructs an instance of a directive with types inferred from its inputs, which
* also checks the bindings to the directive in the process.
*
* Executing this operation returns a reference to the directive instance variable with its inferred
* type.
*/
class TcbDirectiveOp extends TcbOp {
constructor(
private tcb: Context, private scope: Scope, private node: TmplAstTemplate|TmplAstElement,
private dir: TypeCheckableDirectiveMeta) {
super();
}
execute(): ts.Identifier {
const id = this.tcb.allocateId();
// Process the directive and construct expressions for each of its bindings.
const inputs = tcbGetDirectiveInputs(this.node, this.dir, this.tcb, this.scope);
// Call the type constructor of the directive to infer a type, and assign the directive
// instance.
const typeCtor = tcbCallTypeCtor(this.dir, this.tcb, inputs);
addParseSpanInfo(typeCtor, this.node.sourceSpan);
this.scope.addStatement(tsCreateVariable(id, typeCtor));
return id;
}
}
/**
* A `TcbOp` which feeds elements and unclaimed properties to the `DomSchemaChecker`.
*
* The DOM schema is not checked via TCB code generation. Instead, the `DomSchemaChecker` ingests
* elements and property bindings and accumulates synthetic `ts.Diagnostic`s out-of-band. These are
* later merged with the diagnostics generated from the TCB.
*
* For convenience, the TCB iteration of the template is used to drive the `DomSchemaChecker` via
* the `TcbDomSchemaCheckerOp`.
*/
class TcbDomSchemaCheckerOp extends TcbOp {
constructor(
private tcb: Context, private element: TmplAstElement, private checkElement: boolean,
private claimedInputs: Set<string>) {
super();
}
execute(): ts.Expression|null {
if (this.checkElement) {
this.tcb.domSchemaChecker.checkElement(this.tcb.id, this.element, this.tcb.schemas);
}
// TODO(alxhub): this could be more efficient.
for (const binding of this.element.inputs) {
if (binding.type === BindingType.Property && this.claimedInputs.has(binding.name)) {
// Skip this binding as it was claimed by a directive.
continue;
}
if (binding.type === BindingType.Property) {
if (binding.name !== 'style' && binding.name !== 'class') {
// A direct binding to a property.
const propertyName = ATTR_TO_PROP[binding.name] || binding.name;
this.tcb.domSchemaChecker.checkProperty(
this.tcb.id, this.element, propertyName, binding.sourceSpan, this.tcb.schemas);
}
}
}
return null;
}
}
/**
* Mapping between attributes names that don't correspond to their element property names.
* Note: this mapping has to be kept in sync with the equally named mapping in the runtime.
*/
const ATTR_TO_PROP: {[name: string]: string} = {
'class': 'className',
'for': 'htmlFor',
'formaction': 'formAction',
'innerHtml': 'innerHTML',
'readonly': 'readOnly',
'tabindex': 'tabIndex',
};
/**
* A `TcbOp` which generates code to check "unclaimed inputs" - bindings on an element which were
* not attributed to any directive or component, and are instead processed against the HTML element
* itself.
*
* Currently, only the expressions of these bindings are checked. The targets of the bindings are
* checked against the DOM schema via a `TcbDomSchemaCheckerOp`.
*
* Executing this operation returns nothing.
*/
class TcbUnclaimedInputsOp extends TcbOp {
constructor(
private tcb: Context, private scope: Scope, private element: TmplAstElement,
private claimedInputs: Set<string>) {
super();
}
execute(): null {
// `this.inputs` contains only those bindings not matched by any directive. These bindings go to
// the element itself.
const elId = this.scope.resolve(this.element);
// TODO(alxhub): this could be more efficient.
for (const binding of this.element.inputs) {
if (binding.type === BindingType.Property && this.claimedInputs.has(binding.name)) {
// Skip this binding as it was claimed by a directive.
continue;
}
let expr = tcbExpression(
binding.value, this.tcb, this.scope, binding.valueSpan || binding.sourceSpan);
if (!this.tcb.env.config.checkTypeOfInputBindings) {
// If checking the type of bindings is disabled, cast the resulting expression to 'any'
// before the assignment.
expr = tsCastToAny(expr);
} else if (!this.tcb.env.config.strictNullInputBindings) {
// If strict null checks are disabled, erase `null` and `undefined` from the type by
// wrapping the expression in a non-null assertion.
expr = ts.createNonNullExpression(expr);
}
if (this.tcb.env.config.checkTypeOfDomBindings && binding.type === BindingType.Property) {
if (binding.name !== 'style' && binding.name !== 'class') {
// A direct binding to a property.
const propertyName = ATTR_TO_PROP[binding.name] || binding.name;
const prop = ts.createPropertyAccess(elId, propertyName);
const stmt = ts.createBinary(prop, ts.SyntaxKind.EqualsToken, wrapForDiagnostics(expr));
addParseSpanInfo(stmt, binding.sourceSpan);
this.scope.addStatement(ts.createExpressionStatement(stmt));
} else {
this.scope.addStatement(ts.createExpressionStatement(expr));
}
} else {
// A binding to an animation, attribute, class or style. For now, only validate the right-
// hand side of the expression.
// TODO: properly check class and style bindings.
this.scope.addStatement(ts.createExpressionStatement(expr));
}
}
return null;
}
}
/**
* A `TcbOp` which generates code to check event bindings on an element that correspond with the
* outputs of a directive.
*
* Executing this operation returns nothing.
*/
class TcbDirectiveOutputsOp extends TcbOp {
constructor(
private tcb: Context, private scope: Scope, private node: TmplAstTemplate|TmplAstElement,
private dir: TypeCheckableDirectiveMeta) {
super();
}
execute(): null {
const dirId = this.scope.resolve(this.node, this.dir);
// `dir.outputs` is an object map of field names on the directive class to event names.
// This is backwards from what's needed to match event handlers - a map of event names to field
// names is desired. Invert `dir.outputs` into `fieldByEventName` to create this map.
const fieldByEventName = new Map<string, string>();
const outputs = this.dir.outputs;
for (const key of Object.keys(outputs)) {
fieldByEventName.set(outputs[key], key);
}
for (const output of this.node.outputs) {
if (output.type !== ParsedEventType.Regular || !fieldByEventName.has(output.name)) {
continue;
}
const field = fieldByEventName.get(output.name) !;
if (this.tcb.env.config.checkTypeOfOutputEvents) {
// For strict checking of directive events, generate a call to the `subscribe` method
// on the directive's output field to let type information flow into the handler function's
// `$event` parameter.
//
// Note that the `EventEmitter<T>` type from '@angular/core' that is typically used for
// outputs has a typings deficiency in its `subscribe` method. The generic type `T` is not
// carried into the handler function, which is vital for inference of the type of `$event`.
// As a workaround, the directive's field is passed into a helper function that has a
// specially crafted set of signatures, to effectively cast `EventEmitter<T>` to something
// that has a `subscribe` method that properly carries the `T` into the handler function.
const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Infer);
const outputField = ts.createPropertyAccess(dirId, field);
const outputHelper =
ts.createCall(this.tcb.env.declareOutputHelper(), undefined, [outputField]);
const subscribeFn = ts.createPropertyAccess(outputHelper, 'subscribe');
const call = ts.createCall(subscribeFn, /* typeArguments */ undefined, [handler]);
addParseSpanInfo(call, output.sourceSpan);
this.scope.addStatement(ts.createExpressionStatement(call));
} else {
// If strict checking of directive events is disabled, emit a handler function where the
// `$event` parameter has an explicit `any` type.
const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any);
this.scope.addStatement(ts.createExpressionStatement(handler));
}
}
return null;
}
}
/**
* A `TcbOp` which generates code to check "unclaimed outputs" - event bindings on an element which
* were not attributed to any directive or component, and are instead processed against the HTML
* element itself.
*
* Executing this operation returns nothing.
*/
class TcbUnclaimedOutputsOp extends TcbOp {
constructor(
private tcb: Context, private scope: Scope, private element: TmplAstElement,
private claimedOutputs: Set<string>) {
super();
}
execute(): null {
const elId = this.scope.resolve(this.element);
// TODO(alxhub): this could be more efficient.
for (const output of this.element.outputs) {
if (this.claimedOutputs.has(output.name)) {
// Skip this event handler as it was claimed by a directive.
continue;
}
if (output.type === ParsedEventType.Animation) {
// Animation output bindings always have an `$event` parameter of type `AnimationEvent`.
const eventType = this.tcb.env.config.checkTypeOfAnimationEvents ?
this.tcb.env.referenceExternalType('@angular/animations', 'AnimationEvent') :
EventParamType.Any;
const handler = tcbCreateEventHandler(output, this.tcb, this.scope, eventType);
this.scope.addStatement(ts.createExpressionStatement(handler));
} else if (this.tcb.env.config.checkTypeOfDomEvents) {
// If strict checking of DOM events is enabled, generate a call to `addEventListener` on
// the element instance so that TypeScript's type inference for
// `HTMLElement.addEventListener` using `HTMLElementEventMap` to infer an accurate type for
// `$event` depending on the event name. For unknown event names, TypeScript resorts to the
// base `Event` type.
const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Infer);
const call = ts.createCall(
/* expression */ ts.createPropertyAccess(elId, 'addEventListener'),
/* typeArguments */ undefined,
/* arguments */[ts.createStringLiteral(output.name), handler]);
addParseSpanInfo(call, output.sourceSpan);
this.scope.addStatement(ts.createExpressionStatement(call));
} else {
// If strict checking of DOM inputs is disabled, emit a handler function where the `$event`
// parameter has an explicit `any` type.
const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any);
this.scope.addStatement(ts.createExpressionStatement(handler));
}
}
return null;
}
}
/**
* Value used to break a circular reference between `TcbOp`s.
*
* This value is returned whenever `TcbOp`s have a circular dependency. The expression is a non-null
* assertion of the null value (in TypeScript, the expression `null!`). This construction will infer
* the least narrow type for whatever it's assigned to.
*/
const INFER_TYPE_FOR_CIRCULAR_OP_EXPR = ts.createNonNullExpression(ts.createNull());
/**
* Overall generation context for the type check block.
*
* `Context` handles operations during code generation which are global with respect to the whole
* block. It's responsible for variable name allocation and management of any imports needed. It
* also contains the template metadata itself.
*/
export class Context {
private nextId = 1;
constructor(
readonly env: Environment, readonly domSchemaChecker: DomSchemaChecker,
readonly oobRecorder: OutOfBandDiagnosticRecorder, readonly id: string,
readonly boundTarget: BoundTarget<TypeCheckableDirectiveMeta>,
private pipes: Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>,
readonly schemas: SchemaMetadata[]) {}
/**
* Allocate a new variable name for use within the `Context`.
*
* Currently this uses a monotonically increasing counter, but in the future the variable name
* might change depending on the type of data being stored.
*/
allocateId(): ts.Identifier { return ts.createIdentifier(`_t${this.nextId++}`); }
getPipeByName(name: string): ts.Expression|null {
if (!this.pipes.has(name)) {
return null;
}
return this.env.pipeInst(this.pipes.get(name) !);
}
}
/**
* Local scope within the type check block for a particular template.
*
* The top-level template and each nested `<ng-template>` have their own `Scope`, which exist in a
* hierarchy. The structure of this hierarchy mirrors the syntactic scopes in the generated type
* check block, where each nested template is encased in an `if` structure.
*
* As a template's `TcbOp`s are executed in a given `Scope`, statements are added via
* `addStatement()`. When this processing is complete, the `Scope` can be turned into a `ts.Block`
* via `renderToBlock()`.
*
* If a `TcbOp` requires the output of another, it can call `resolve()`.
*/
class Scope {
/**
* A queue of operations which need to be performed to generate the TCB code for this scope.
*
* This array can contain either a `TcbOp` which has yet to be executed, or a `ts.Expression|null`
* representing the memoized result of executing the operation. As operations are executed, their
* results are written into the `opQueue`, overwriting the original operation.
*
* If an operation is in the process of being executed, it is temporarily overwritten here with
* `INFER_TYPE_FOR_CIRCULAR_OP_EXPR`. This way, if a cycle is encountered where an operation
* depends transitively on its own result, the inner operation will infer the least narrow type
* that fits instead. This has the same semantics as TypeScript itself when types are referenced
* circularly.
*/
private opQueue: (TcbOp|ts.Expression|null)[] = [];
/**
* A map of `TmplAstElement`s to the index of their `TcbElementOp` in the `opQueue`
*/
private elementOpMap = new Map<TmplAstElement, number>();
/**
* A map of maps which tracks the index of `TcbDirectiveOp`s in the `opQueue` for each directive
* on a `TmplAstElement` or `TmplAstTemplate` node.
*/
private directiveOpMap =
new Map<TmplAstElement|TmplAstTemplate, Map<TypeCheckableDirectiveMeta, number>>();
/**
* Map of immediately nested <ng-template>s (within this `Scope`) represented by `TmplAstTemplate`
* nodes to the index of their `TcbTemplateContextOp`s in the `opQueue`.
*/
private templateCtxOpMap = new Map<TmplAstTemplate, number>();
/**
* Map of variables declared on the template that created this `Scope` (represented by
* `TmplAstVariable` nodes) to the index of their `TcbVariableOp`s in the `opQueue`.
*/
private varMap = new Map<TmplAstVariable, number>();
/**
* Statements for this template.
*
* Executing the `TcbOp`s in the `opQueue` populates this array.
*/
private statements: ts.Statement[] = [];
private constructor(private tcb: Context, private parent: Scope|null = null) {}
/**
* Constructs a `Scope` given either a `TmplAstTemplate` or a list of `TmplAstNode`s.
*
* @param tcb the overall context of TCB generation.
* @param parent the `Scope` of the parent template (if any) or `null` if this is the root
* `Scope`.
* @param templateOrNodes either a `TmplAstTemplate` representing the template for which to
* calculate the `Scope`, or a list of nodes if no outer template object is available.
*/
static forNodes(
tcb: Context, parent: Scope|null, templateOrNodes: TmplAstTemplate|(TmplAstNode[])): Scope {
const scope = new Scope(tcb, parent);
let children: TmplAstNode[];
// If given an actual `TmplAstTemplate` instance, then process any additional information it
// has.
if (templateOrNodes instanceof TmplAstTemplate) {
// The template's variable declarations need to be added as `TcbVariableOp`s.
for (const v of templateOrNodes.variables) {
const opIndex = scope.opQueue.push(new TcbVariableOp(tcb, scope, templateOrNodes, v)) - 1;
scope.varMap.set(v, opIndex);
}
children = templateOrNodes.children;
} else {
children = templateOrNodes;
}
for (const node of children) {
scope.appendNode(node);
}
return scope;
}
/**
* Look up a `ts.Expression` representing the value of some operation in the current `Scope`,
* including any parent scope(s).
*
* @param node a `TmplAstNode` of the operation in question. The lookup performed will depend on
* the type of this node:
*
* Assuming `directive` is not present, then `resolve` will return:
*
* * `TmplAstElement` - retrieve the expression for the element DOM node
* * `TmplAstTemplate` - retrieve the template context variable
* * `TmplAstVariable` - retrieve a template let- variable
*
* @param directive if present, a directive type on a `TmplAstElement` or `TmplAstTemplate` to
* look up instead of the default for an element or template node.
*/
resolve(
node: TmplAstElement|TmplAstTemplate|TmplAstVariable,
directive?: TypeCheckableDirectiveMeta): ts.Expression {
// Attempt to resolve the operation locally.
const res = this.resolveLocal(node, directive);
if (res !== null) {
return res;
} else if (this.parent !== null) {
// Check with the parent.
return this.parent.resolve(node, directive);
} else {
throw new Error(`Could not resolve ${node} / ${directive}`);
}
}
/**
* Add a statement to this scope.
*/
addStatement(stmt: ts.Statement): void { this.statements.push(stmt); }
/**
* Get the statements.
*/
render(): ts.Statement[] {
for (let i = 0; i < this.opQueue.length; i++) {
this.executeOp(i);
}
return this.statements;
}
private resolveLocal(
ref: TmplAstElement|TmplAstTemplate|TmplAstVariable,
directive?: TypeCheckableDirectiveMeta): ts.Expression|null {
if (ref instanceof TmplAstVariable && this.varMap.has(ref)) {
// Resolving a context variable for this template.
// Execute the `TcbVariableOp` associated with the `TmplAstVariable`.
return this.resolveOp(this.varMap.get(ref) !);
} else if (
ref instanceof TmplAstTemplate && directive === undefined &&
this.templateCtxOpMap.has(ref)) {
// Resolving the context of the given sub-template.
// Execute the `TcbTemplateContextOp` for the template.
return this.resolveOp(this.templateCtxOpMap.get(ref) !);
} else if (
(ref instanceof TmplAstElement || ref instanceof TmplAstTemplate) &&
directive !== undefined && this.directiveOpMap.has(ref)) {
// Resolving a directive on an element or sub-template.
const dirMap = this.directiveOpMap.get(ref) !;
if (dirMap.has(directive)) {
return this.resolveOp(dirMap.get(directive) !);
} else {
return null;
}
} else if (ref instanceof TmplAstElement && this.elementOpMap.has(ref)) {
// Resolving the DOM node of an element in this template.
return this.resolveOp(this.elementOpMap.get(ref) !);
} else {
return null;
}
}
/**
* Like `executeOp`, but assert that the operation actually returned `ts.Expression`.
*/
private resolveOp(opIndex: number): ts.Expression {
const res = this.executeOp(opIndex);
if (res === null) {
throw new Error(`Error resolving operation, got null`);
}
return res;
}
/**
* Execute a particular `TcbOp` in the `opQueue`.
*
* This method replaces the operation in the `opQueue` with the result of execution (once done)
* and also protects against a circular dependency from the operation to itself by temporarily
* setting the operation's result to a special expression.
*/
private executeOp(opIndex: number): ts.Expression|null {
const op = this.opQueue[opIndex];
if (!(op instanceof TcbOp)) {
return op;
}
// Set the result of the operation in the queue to a special expression. If executing this
// operation results in a circular dependency, this will break the cycle and infer the least
// narrow type where needed (which is how TypeScript deals with circular dependencies in types).
this.opQueue[opIndex] = INFER_TYPE_FOR_CIRCULAR_OP_EXPR;
const res = op.execute();
// Once the operation has finished executing, it's safe to cache the real result.
this.opQueue[opIndex] = res;
return res;
}
private appendNode(node: TmplAstNode): void {
if (node instanceof TmplAstElement) {
const opIndex = this.opQueue.push(new TcbElementOp(this.tcb, this, node)) - 1;
this.elementOpMap.set(node, opIndex);
this.appendDirectivesAndInputsOfNode(node);
this.appendOutputsOfNode(node);
for (const child of node.children) {
this.appendNode(child);
}
this.checkReferencesOfNode(node);
} else if (node instanceof TmplAstTemplate) {
// Template children are rendered in a child scope.
this.appendDirectivesAndInputsOfNode(node);
this.appendOutputsOfNode(node);
if (this.tcb.env.config.checkTemplateBodies) {
const ctxIndex = this.opQueue.push(new TcbTemplateContextOp(this.tcb, this)) - 1;
this.templateCtxOpMap.set(node, ctxIndex);
this.opQueue.push(new TcbTemplateBodyOp(this.tcb, this, node));
}
this.checkReferencesOfNode(node);
} else if (node instanceof TmplAstBoundText) {
this.opQueue.push(new TcbTextInterpolationOp(this.tcb, this, node));
}
}
private checkReferencesOfNode(node: TmplAstElement|TmplAstTemplate): void {
for (const ref of node.references) {
if (this.tcb.boundTarget.getReferenceTarget(ref) === null) {
this.tcb.oobRecorder.missingReferenceTarget(this.tcb.id, ref);
}
}
}
private appendDirectivesAndInputsOfNode(node: TmplAstElement|TmplAstTemplate): void {
// Collect all the inputs on the element.
const claimedInputs = new Set<string>();
const directives = this.tcb.boundTarget.getDirectivesOfNode(node);
if (directives === null || directives.length === 0) {
// If there are no directives, then all inputs are unclaimed inputs, so queue an operation
// to add them if needed.
if (node instanceof TmplAstElement) {
this.opQueue.push(new TcbUnclaimedInputsOp(this.tcb, this, node, claimedInputs));
this.opQueue.push(
new TcbDomSchemaCheckerOp(this.tcb, node, /* checkElement */ true, claimedInputs));
}
return;
}
const dirMap = new Map<TypeCheckableDirectiveMeta, number>();
for (const dir of directives) {
const dirIndex = this.opQueue.push(new TcbDirectiveOp(this.tcb, this, node, dir)) - 1;
dirMap.set(dir, dirIndex);
}
this.directiveOpMap.set(node, dirMap);
// After expanding the directives, we might need to queue an operation to check any unclaimed
// inputs.
if (node instanceof TmplAstElement) {
// Go through the directives and remove any inputs that it claims from `elementInputs`.
for (const dir of directives) {
for (const fieldName of Object.keys(dir.inputs)) {
const value = dir.inputs[fieldName];
claimedInputs.add(Array.isArray(value) ? value[0] : value);
}
}
this.opQueue.push(new TcbUnclaimedInputsOp(this.tcb, this, node, claimedInputs));
// If there are no directives which match this element, then it's a "plain" DOM element (or a
// web component), and should be checked against the DOM schema. If any directives match,
// we must assume that the element could be custom (either a component, or a directive like
// <router-outlet>) and shouldn't validate the element name itself.
const checkElement = directives.length === 0;
this.opQueue.push(new TcbDomSchemaCheckerOp(this.tcb, node, checkElement, claimedInputs));
}
}
private appendOutputsOfNode(node: TmplAstElement|TmplAstTemplate): void {
// Collect all the outputs on the element.
const claimedOutputs = new Set<string>();
const directives = this.tcb.boundTarget.getDirectivesOfNode(node);
if (directives === null || directives.length === 0) {
// If there are no directives, then all outputs are unclaimed outputs, so queue an operation
// to add them if needed.
if (node instanceof TmplAstElement) {
this.opQueue.push(new TcbUnclaimedOutputsOp(this.tcb, this, node, claimedOutputs));
}
return;
}
// Queue operations for all directives to check the relevant outputs for a directive.
for (const dir of directives) {
this.opQueue.push(new TcbDirectiveOutputsOp(this.tcb, this, node, dir));
}
// After expanding the directives, we might need to queue an operation to check any unclaimed
// outputs.
if (node instanceof TmplAstElement) {
// Go through the directives and register any outputs that it claims in `claimedOutputs`.
for (const dir of directives) {
for (const outputField of Object.keys(dir.outputs)) {
claimedOutputs.add(dir.outputs[outputField]);
}
}
this.opQueue.push(new TcbUnclaimedOutputsOp(this.tcb, this, node, claimedOutputs));
}
}
}
/**
* Create the `ctx` parameter to the top-level TCB function.
*
* This is a parameter with a type equivalent to the component type, with all generic type
* parameters listed (without their generic bounds).
*/
function tcbCtxParam(
node: ClassDeclaration<ts.ClassDeclaration>, name: ts.EntityName): ts.ParameterDeclaration {
let typeArguments: ts.TypeNode[]|undefined = undefined;
// Check if the component is generic, and pass generic type parameters if so.
if (node.typeParameters !== undefined) {
typeArguments =
node.typeParameters.map(param => ts.createTypeReferenceNode(param.name, undefined));
}
const type = ts.createTypeReferenceNode(name, typeArguments);
return ts.createParameter(
/* decorators */ undefined,
/* modifiers */ undefined,
/* dotDotDotToken */ undefined,
/* name */ 'ctx',
/* questionToken */ undefined,
/* type */ type,
/* initializer */ undefined);
}
/**
* Process an `AST` expression and convert it into a `ts.Expression`, generating references to the
* correct identifiers in the current scope.
*/
function tcbExpression(
ast: AST, tcb: Context, scope: Scope, sourceSpan: ParseSourceSpan): ts.Expression {
const translator = new TcbExpressionTranslator(tcb, scope, sourceSpan);
return translator.translate(ast);
}
class TcbExpressionTranslator {
constructor(
protected tcb: Context, protected scope: Scope, protected sourceSpan: ParseSourceSpan) {}
translate(ast: AST): ts.Expression {
// `astToTypescript` actually does the conversion. A special resolver `tcbResolve` is passed
// which interprets specific expression nodes that interact with the `ImplicitReceiver`. These
// nodes actually refer to identifiers within the current scope.
return astToTypescript(
ast, ast => this.resolve(ast), this.tcb.env.config,
(span: ParseSpan) => toAbsoluteSpan(span, this.sourceSpan));
}
/**
* Resolve an `AST` expression within the given scope.
*
* Some `AST` expressions refer to top-level concepts (references, variables, the component
* context). This method assists in resolving those.
*/
protected resolve(ast: AST): ts.Expression|null {
if (ast instanceof PropertyRead && ast.receiver instanceof ImplicitReceiver) {
// Try to resolve a bound target for this expression. If no such target is available, then
// the expression is referencing the top-level component context. In that case, `null` is
// returned here to let it fall through resolution so it will be caught when the
// `ImplicitReceiver` is resolved in the branch below.
return this.resolveTarget(ast);
} else if (ast instanceof ImplicitReceiver) {
// AST instances representing variables and references look very similar to property reads
// or method calls from the component context: both have the shape
// PropertyRead(ImplicitReceiver, 'propName') or MethodCall(ImplicitReceiver, 'methodName').
//
// `translate` will first try to `resolve` the outer PropertyRead/MethodCall. If this works,
// it's because the `BoundTarget` found an expression target for the whole expression, and
// therefore `translate` will never attempt to `resolve` the ImplicitReceiver of that
// PropertyRead/MethodCall.
//
// Therefore if `resolve` is called on an `ImplicitReceiver`, it's because no outer
// PropertyRead/MethodCall resolved to a variable or reference, and therefore this is a
// property read or method call on the component context itself.
return ts.createIdentifier('ctx');
} else if (ast instanceof BindingPipe) {
const expr = this.translate(ast.exp);
let pipe: ts.Expression|null;
if (this.tcb.env.config.checkTypeOfPipes) {
pipe = this.tcb.getPipeByName(ast.name);
if (pipe === null) {
// No pipe by that name exists in scope. Record this as an error.
const nameAbsoluteSpan = toAbsoluteSpan(ast.nameSpan, this.sourceSpan);
this.tcb.oobRecorder.missingPipe(this.tcb.id, ast, nameAbsoluteSpan);
// Return an 'any' value to at least allow the rest of the expression to be checked.
pipe = NULL_AS_ANY;