Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 123 additions & 27 deletions packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,46 +447,54 @@ export class SymbolBuilder {
});
const bindings: BindingSymbol[] = [];
for (const node of nodes) {
if (!isAccessExpression(node.left)) {
continue;
}

const signalInputAssignment = unwrapSignalInputWriteTAccessor(node.left);
let fieldAccessExpr: ts.PropertyAccessExpression | ts.ElementAccessExpression;
// Signal inputs need special treatment because they are generated with an extra keyed
// access. E.g. `_t1.prop[WriteT_ACCESSOR_SYMBOL]`. Observations:
// - The keyed access for the write type needs to be resolved for the "input type".
// - The definition symbol of the input should be the input class member, and not the
// internal write accessor. Symbol should resolve `_t1.prop`.
let tcbLocation: TcbLocation;
if (signalInputAssignment !== null) {
// Note: If the field expression for the input binding refers to just an identifier,
// then we are handling the case of a temporary variable being used for the input field.
// This is the case with `honorAccessModifiersForInputBindings = false` and in those cases
// we cannot resolve the owning directive, similar to how we guard above with `isAccessExpression`.
if (ts.isIdentifier(signalInputAssignment.fieldExpr)) {
const signalInputAssignment = isAccessExpression(node.left)
? unwrapSignalInputWriteTAccessor(node.left)
: null;
const fieldExpr =
signalInputAssignment !== null ? signalInputAssignment.fieldExpr : node.left;

// The node whose location resolves to the input's class member.
let fieldNode: ts.Node;
// The node whose type is the type of the input's class member.
let fieldTypeNode: ts.Node;
// The node whose location resolves to the directive instance.
let instanceNode: ts.Node;

if (isAccessExpression(fieldExpr)) {
fieldNode = fieldExpr;
fieldTypeNode = fieldExpr;
instanceNode = fieldExpr.expression;
} else if (ts.isIdentifier(fieldExpr)) {
// If the field expression refers to just an identifier, the input binding was assigned
// into a temporary variable. This is the case for fields with restricted access
// (private/protected/readonly) when `honorAccessModifiersForInputBindings = false`, where
// the TCB generates `var _tmp = null! as typeof _dir.fieldName; _tmp = expr;`.
// Recover the class member and the directive instance from the type in the temporary
// variable's declaration.
const restrictedField = unwrapRestrictedFieldTempVariable(this.typeCheckBlock, fieldExpr);
if (restrictedField === null) {
continue;
}

fieldAccessExpr = signalInputAssignment.fieldExpr;
tcbLocation = this.getTcbLocationForNode(fieldAccessExpr);
fieldNode = restrictedField.fieldNode;
fieldTypeNode = restrictedField.typeNode;
instanceNode = restrictedField.instanceNode;
} else {
fieldAccessExpr = node.left;
tcbLocation = this.getTcbLocationForNode(fieldAccessExpr);
}

const target = this.getDirectiveSymbolForAccessExpression(fieldAccessExpr, consumer);
if (target === null) {
continue;
}

if (!consumer.inputs.hasBindingPropertyName(binding.name)) {
const target = this.getDirectiveSymbolForInstanceNode(instanceNode, consumer);
if (target === null) {
continue;
}

bindings.push({
tcbLocation,
tcbTypeLocation: this.getTcbSpanForNode(fieldAccessExpr),
tcbLocation: this.getTcbLocationForNode(fieldNode),
tcbTypeLocation: this.getTcbSpanForNode(fieldTypeNode),
kind: SymbolKind.Binding,
target,
});
Expand All @@ -501,11 +509,18 @@ export class SymbolBuilder {
private getDirectiveSymbolForAccessExpression(
fieldAccessExpr: ts.ElementAccessExpression | ts.PropertyAccessExpression,
meta: SymbolDirectiveMeta,
): DirectiveSymbol | null {
return this.getDirectiveSymbolForInstanceNode(fieldAccessExpr.expression, meta);
}

private getDirectiveSymbolForInstanceNode(
instanceNode: ts.Node,
meta: SymbolDirectiveMeta,
): DirectiveSymbol | null {
return {
ref: meta.getSymbolReference(),
kind: SymbolKind.Directive,
tcbLocation: this.getTcbLocationForNode(fieldAccessExpr.expression),
tcbLocation: this.getTcbLocationForNode(instanceNode),
isComponent: meta.isComponent,
isStructural: meta.isStructural,
selector: meta.selector,
Expand Down Expand Up @@ -660,7 +675,11 @@ export class SymbolBuilder {
const expressionTarget = this.boundTarget.getExpressionTarget(expression);
if (expressionTarget !== null) {
return this.getSymbol(expressionTarget) as
VariableSymbol | ReferenceSymbol | ExpressionSymbol | LetDeclarationSymbol | null;
| VariableSymbol
| ReferenceSymbol
| ExpressionSymbol
| LetDeclarationSymbol
| null;
}

let withSpan = expression.sourceSpan;
Expand Down Expand Up @@ -853,3 +872,80 @@ function unwrapSignalInputWriteTAccessor(expr: ts.LeftHandSideExpression): null
typeExpr: expr,
};
}

/**
* Resolves the temporary variable that an input binding is assigned into when the input's class
* member has restricted access (private/protected/readonly) and
* `honorAccessModifiersForInputBindings` is disabled.
*
* Such bindings are generated as `var _tmp = null! as typeof _dir.fieldName; _tmp = expr;` (or
* `var _tmp = null! as (typeof _dir)["fieldName"]; _tmp = expr;` for field names that are not
* valid identifiers), so the input's class member, the member type and the directive instance
* (`_dir`) can all be recovered from the type in the declaration of the temporary variable.
*/
function unwrapRestrictedFieldTempVariable(
typeCheckBlock: ts.Node,
id: ts.Identifier,
): null | {
fieldNode: ts.Node;
typeNode: ts.TypeNode;
instanceNode: ts.Node;
} {
const declaration = findVariableDeclaration(typeCheckBlock, id.text);
if (
declaration === null ||
declaration.initializer === undefined ||
!ts.isAsExpression(declaration.initializer)
) {
return null;
}
const type = declaration.initializer.type;

// `var _tmp = null! as typeof _dir.fieldName;`
if (ts.isTypeQueryNode(type) && ts.isQualifiedName(type.exprName)) {
return {
fieldNode: type.exprName,
typeNode: type,
instanceNode: type.exprName.left,
};
}

// `var _tmp = null! as (typeof _dir)["fieldName"];`
if (ts.isIndexedAccessTypeNode(type)) {
const {indexType} = type;
let objectType: ts.TypeNode = type.objectType;
while (ts.isParenthesizedTypeNode(objectType)) {
objectType = objectType.type;
}
if (
ts.isTypeQueryNode(objectType) &&
ts.isLiteralTypeNode(indexType) &&
ts.isStringLiteralLike(indexType.literal)
) {
return {
fieldNode: indexType.literal,
typeNode: type,
instanceNode: objectType.exprName,
};
}
}

return null;
}

/** Finds the declaration of the variable with the given name within the given node. */
function findVariableDeclaration(root: ts.Node, name: string): ts.VariableDeclaration | null {
let result: ts.VariableDeclaration | null = null;
const visit = (node: ts.Node): void => {
if (result !== null) {
return;
}
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) {
result = node;
return;
}
node.forEachChild(visit);
};
visit(root);
return result;
}
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,24 @@ describe('type check blocks diagnostics', () => {
});
});

describe('attaching comments for restricted directive inputs', () => {
it('should annotate the type of the temporary variable with the key span', () => {
const DIRECTIVES: TestDeclaration[] = [
{
type: 'directive',
name: 'Dir',
selector: '[dir]',
inputs: {fieldA: 'inputA'},
restrictedInputFields: ['fieldA'],
},
];
const TEMPLATE = `<div dir [inputA]="foo"></div>`;
expect(tcbWithSpans(TEMPLATE, DIRECTIVES)).toContain(
'var _t2 = null! as typeof _t1.fieldA /*T:VAE*/ /*D:ignore*/ /*10,16*/;',
);
});
});

describe('control flow', () => {
it('@for', () => {
const template = `@for (user of users; track user; let i = $index) { {{i}} }`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@ describe('type check blocks', () => {
];
expect(tcb(TEMPLATE, DIRECTIVES)).toContain(
'var _t1 = null! as i0.Dir; ' +
'var _t2 = null! as (typeof _t1)["fieldA"]; ' +
'var _t2 = null! as typeof _t1.fieldA; ' +
'_t2 = (((this).foo)); ',
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1713,7 +1713,7 @@ runInEachFileSystem(() => {

// Note that `honorAccessModifiersForInputBindings` is `false` even with `--strictTemplates`,
// so this captures a potential common scenario, assuming the input is restricted.
it('should not throw when retrieving a symbol for a signal-input with restricted access', () => {
it('can retrieve a symbol for a signal-input with restricted access', () => {
const fileName = absoluteFrom('/main.ts');
const dirFile = absoluteFrom('/dir.ts');
const templateString = `
Expand Down Expand Up @@ -1767,13 +1767,69 @@ runInEachFileSystem(() => {
const testElement = ifBranchNode.children[0] as TmplAstElement;

const inputAbinding = testElement.inputs[0];
const aSymbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp);
expect(aSymbol)
.withContext(
'Symbol builder does not return symbols for restricted inputs with ' +
'`honorAccessModifiersForInputBindings = false` (same for decorator inputs)',
)
.toBe(null);
const aSymbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp)!;
assertInputBindingSymbol(aSymbol);
expect(
(
templateTypeChecker.getTsSymbolOfSymbol(aSymbol.bindings[0])!
.declarations![0] as ts.PropertyDeclaration
).name.getText(),
).toEqual('inputA');
});

// Note that `honorAccessModifiersForInputBindings` is `false` even with `--strictTemplates`,
// so this captures a potential common scenario, assuming the input is restricted.
it('can retrieve a symbol for a readonly decorator input with restricted access', () => {
const fileName = absoluteFrom('/main.ts');
const dirFile = absoluteFrom('/dir.ts');
const templateString = `<div dir [inputA]="'ok'"></div>`;
const {program, templateTypeChecker} = setup(
[
{
fileName,
templates: {'Cmp': templateString},
declarations: [
{
name: 'TestDir',
selector: '[dir]',
file: dirFile,
type: 'directive',
restrictedInputFields: ['inputA'],
inputs: {inputA: 'inputA'},
},
],
},
{
fileName: dirFile,
source: `
export class TestDir {
readonly inputA: string = '';
}
`,
},
],
{honorAccessModifiersForInputBindings: false},
);
const sf = getSourceFileOrError(program, fileName);
const cmp = getClass(sf, 'Cmp');

const nodes = templateTypeChecker.getTemplate(cmp)!;
const testElement = nodes[0] as TmplAstElement;

const inputAbinding = testElement.inputs[0];
const aSymbol = templateTypeChecker.getSymbolOfNode(inputAbinding, cmp)!;
assertInputBindingSymbol(aSymbol);
expect(
(
templateTypeChecker.getTsSymbolOfSymbol(aSymbol.bindings[0])!
.declarations![0] as ts.PropertyDeclaration
).name.getText(),
).toEqual('inputA');
expect(
program
.getTypeChecker()
.typeToString(templateTypeChecker.getTypeOfSymbol(aSymbol.bindings[0])!),
).toBe('string');
});

it('does not retrieve a symbol for an input when undeclared', () => {
Expand Down
19 changes: 16 additions & 3 deletions packages/compiler/src/typecheck/ops/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,22 @@ export class TcbDirectiveInputsOp extends TcbOp {
}

const id = new TcbExpr(this.tcb.allocateId());
const type = new TcbExpr(
`(typeof ${dirId.print()})[${TcbExpr.quoteAndEscape(fieldName)}]`,
);
let type: TcbExpr;
if (this.dir.stringLiteralInputFields.has(fieldName)) {
// Non-identifier field names cannot be expressed as a type query.
type = new TcbExpr(`(typeof ${dirId.print()})[${TcbExpr.quoteAndEscape(fieldName)}]`);
} else {
// Use a type query with a qualified name (`typeof _t1.fieldName`) rather than an
// indexed access type (`(typeof _t1)["fieldName"]`) so that the type retains a
// TypeScript-visible reference to the input's class member, which the language
// service relies on e.g. to find references to a `readonly` input. Reading the
// field is an error if it is private/protected, so diagnostics are ignored for the
// type; the assignment into the temporary variable remains fully type-checked.
type = new TcbExpr(`typeof ${dirId.print()}.${fieldName}`).markIgnoreDiagnostics();
if (attr.keySpan !== null) {
type.addParseSpanInfo(attr.keySpan);
}
}
const temp = declareVariable(id, type);
this.scope.addStatement(temp);
target = id;
Expand Down
Loading
Loading