blob: bd87a66e826662631123d87c8320a046008ea1bc [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
import {findNodeForTypeReferenceName} from './utils';
interface ExternalImport {
namedImports: Set<string>;
filePath: string;
}
interface NamespaceImport {
filePath: string;
namespace: string;
}
export interface WalkerState {
/* Whilst these are technically different things, for the bridge generation we
* can treat them the same - the Closure output is similar for both - and the
* overhead of an extra piece of state and another set to check isn't worth it
*/
foundInterfaces: Set<ts.InterfaceDeclaration|ts.TypeAliasDeclaration>;
foundEnums: Set<ts.EnumDeclaration>;
typeReferencesToConvert: Set<string>;
componentClass?: ts.ClassDeclaration;
publicMethods: Set<ts.MethodDeclaration>;
customElementsDefineCall?: ts.ExpressionStatement;
// Stores any usage of legacy interfaces - e.g. Common.Color.Color
// Currently only supports "common" but can be extended if needed to other legacy classes.
legacyInterfaceReferences: Set<'common'>;
imports: Set<ExternalImport|NamespaceImport>;
getters: Set<ts.GetAccessorDeclaration>;
setters: Set<ts.SetAccessorDeclaration>;
}
const classExtendsHTMLElement = (classNode: ts.ClassDeclaration): boolean => {
if (!classNode.heritageClauses) {
return false;
}
return classNode.heritageClauses.some(clause => {
return clause.types.find(clauseType => {
if (ts.isIdentifier(clauseType.expression)) {
return clauseType.expression.escapedText === 'HTMLElement';
}
return false;
});
});
};
/*
* Detects if a Node is of type Readonly<X>.
*/
export const nodeIsReadOnlyInterfaceReference = (node: ts.Node): node is ts.TypeReferenceNode => {
return ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.escapedText === 'Readonly';
};
/*
* Detects if a Node is of type ReadonlyArray<X>.
*/
export const nodeIsReadOnlyArrayInterfaceReference = (node: ts.Node): node is ts.TypeReferenceNode => {
return ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) &&
node.typeName.escapedText === 'ReadonlyArray';
};
/**
* Takes a Node and looks for any type references within that Node. This is done
* so that if an interface references another within its definition, that other
* interface is found and generated within the bridge.
*/
const findTypeReferencesWithinNode = (state: WalkerState, node: ts.Node): Set<string> => {
const foundInterfaces = new Set<string>();
/*
* If the Node is ReadOnly<X>, then we want to ditch the ReadOnly and recurse to
* parse the inner type to check if that's an interface.
*/
if (nodeIsReadOnlyInterfaceReference(node) || nodeIsReadOnlyArrayInterfaceReference(node)) {
if (!node.typeArguments) {
throw new Error('Found ReadOnly interface with no type arguments; invalid TS detected.');
}
return findTypeReferencesWithinNode(state, node.typeArguments[0]);
}
if (ts.isArrayTypeNode(node) && ts.isTypeReferenceNode(node.elementType) &&
ts.isIdentifier(node.elementType.typeName)) {
foundInterfaces.add(node.elementType.typeName.escapedText.toString());
} else if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) {
if (node.typeName.escapedText === 'Map' || node.typeName.escapedText === 'Set') {
// Map<X, Y> or Set<X> - we need to check the type arguments for any references>
if (!node.typeArguments) {
throw new Error(`Found a ${node.typeName.escapedText} without type arguments.`);
}
const referencesWithinGenerics =
node.typeArguments.flatMap(node => [...findTypeReferencesWithinNode(state, node)]);
referencesWithinGenerics.forEach(r => foundInterfaces.add(r));
} else {
foundInterfaces.add(node.typeName.escapedText.toString());
}
} else if (ts.isTypeReferenceNode(node) && ts.isQualifiedName(node.typeName)) {
// We will need only the left type to support enum member references (e.g., 'MyEnum.Member').
const left = node.typeName.left;
if (ts.isQualifiedName(left)) {
if (ts.isIdentifier(left.left)) {
const firstPartOfName = left.left.escapedText.toString();
/**
* The bridges generator doesn't understand deeply nested structures, e.g. Common.Color.Color.
* We use Common.X.X quite a lot in the codebase, so if we hit upon a "Common.X", just ignore it and don't try to save it.
*
* If it's not common, throw an error.
* We can extend this if required to allow use of other legacy interfaces.
*/
if (firstPartOfName === 'Common') {
state.legacyInterfaceReferences.add('common');
} else {
throw new Error(`Found deeply nested interface, starting with \`${firstPartOfName}\`.`);
}
}
} else if (ts.isIdentifier(left)) {
foundInterfaces.add(left.escapedText.toString());
}
} else if (ts.isUnionTypeNode(node)) {
/**
* If the param is something like `x: Foo|null` we want to loop over each type
* because we need to pull the `Foo` out.
*/
node.types.forEach(unionTypeMember => {
findTypeReferencesWithinNode(state, unionTypeMember).forEach(i => foundInterfaces.add(i));
});
} else if (ts.isTypeLiteralNode(node)) {
/* type literal here means it's an object: data: { x: string; y: number, z: SomeInterface , ... }
* so we loop over each member and recurse to find any references we need
*/
node.members.forEach(member => {
if (ts.isPropertySignature(member) && member.type) {
const extraInterfaces = findTypeReferencesWithinNode(state, member.type);
extraInterfaces.forEach(i => foundInterfaces.add(i));
}
});
}
return foundInterfaces;
};
const isPrivate = (node: ts.MethodDeclaration|ts.GetAccessorDeclaration|ts.SetAccessorDeclaration): boolean => {
return node.modifiers && node.modifiers.some(modifier => modifier.kind === ts.SyntaxKind.PrivateKeyword) || false;
};
// We want to check that the identifier is either LitHtml.html or just html.
// If it's not a LitHtml.html call, we don't care about this node.
const taggedTemplateExpressionIsLitHtmlCall = (node: ts.TaggedTemplateExpression): boolean => {
// This means it's .y`blah` - so need to check if X.y is LitHtml.html
if (ts.isPropertyAccessExpression(node.tag) && ts.isIdentifier(node.tag.expression) &&
ts.isIdentifier(node.tag.name)) {
const objectName = node.tag.expression.escapedText.toString();
const propertyName = node.tag.name.escapedText.toString();
return objectName === 'LitHtml' && propertyName === 'html';
}
// This means it's just x`blah` - so check if x is named `html`.
if (ts.isIdentifier(node.tag)) {
return node.tag.escapedText.toString() === 'html';
}
return false;
};
const checkTemplateSpanForTypeCastOfData = (matchingSpan: ts.TemplateSpan) => {
const spanHasTypeCast = ts.isAsExpression(matchingSpan.expression);
if (!spanHasTypeCast) {
throw new Error('Error: found a lit-html .data= without an `as X` typecast.');
}
const typeCastIsTypeReference =
ts.isAsExpression(matchingSpan.expression) && ts.isTypeReferenceNode(matchingSpan.expression.type);
if (!typeCastIsTypeReference) {
throw new Error('Error: found a lit-html .data= with an object literal typecast.');
}
};
const CUSTOM_ELEMENTS_LIFECYCLE_METHODS = new Set([
'connectedCallback',
'disconnectedCallback',
'adoptedCallback',
'attributeChangedCallback',
]);
const walkNode = (node: ts.Node, startState?: WalkerState): WalkerState => {
const state: WalkerState = startState || {
foundInterfaces: new Set(),
foundEnums: new Set(),
publicMethods: new Set(),
typeReferencesToConvert: new Set(),
componentClass: undefined,
customElementsDefineCall: undefined,
legacyInterfaceReferences: new Set(),
imports: new Set(),
getters: new Set(),
setters: new Set(),
};
if (ts.isClassDeclaration(node)) {
const extendsHtmlElement = classExtendsHTMLElement(node);
if (extendsHtmlElement) {
state.componentClass = node;
// now we know this is the component, hunt for its public methods
node.members.forEach(member => {
if (ts.isMethodDeclaration(member)) {
if (isPrivate(member)) {
return;
}
const methodName = (member.name as ts.Identifier).escapedText.toString();
if (CUSTOM_ELEMENTS_LIFECYCLE_METHODS.has(methodName) === false) {
/* We skip custom element lifecycle methods. Whilst they are public,
they are never called from user code, so the bridge file does not
need to include them.*/
if (!member.type) {
throw new Error(`Public method ${methodName} needs an explicit return type annotation.`);
}
/* If the method returns an interface, we should include it as an
* interface to convert. Note that this has limitations: it will
* only work with type references, not if the type is defined
* literally in the return type annotation. This is an accepted
* restriction for now; we can revisit if it causes problems.
*/
if (member.type && ts.isTypeReferenceNode(member.type) && ts.isIdentifier(member.type.typeName)) {
state.typeReferencesToConvert.add(member.type.typeName.escapedText.toString());
}
state.publicMethods.add(member);
}
// now find its interfaces that we need to make public from the method parmeters
member.parameters.forEach(param => {
if (!param.type) {
return;
}
const foundTypeReferences = findTypeReferencesWithinNode(state, param.type);
foundTypeReferences.forEach(i => state.typeReferencesToConvert.add(i));
});
} else if (ts.isGetAccessorDeclaration(member)) {
if (isPrivate(member)) {
return;
}
state.getters.add(member);
if (member.type) {
const foundTypeReferences = findTypeReferencesWithinNode(state, member.type);
foundTypeReferences.forEach(i => state.typeReferencesToConvert.add(i));
}
} else if (ts.isSetAccessorDeclaration(member)) {
if (isPrivate(member)) {
return;
}
state.setters.add(member);
if (member.parameters[0]) {
const setterParamType = member.parameters[0].type;
if (setterParamType) {
/* We require that setters are of the form:
* set data(data: SomeInterface)
* rather than defining the interface inline as an object literal.
*/
const setterName = ts.isIdentifier(member.name) ? member.name.escapedText.toString() : '(unknown)';
if (!ts.isTypeReferenceNode(setterParamType)) {
throw new Error(`Setter ${setterName} has an argument whose type is not a direct type reference.`);
}
const foundTypeReferences = findTypeReferencesWithinNode(state, setterParamType);
foundTypeReferences.forEach(i => state.typeReferencesToConvert.add(i));
}
}
}
});
}
} else if (ts.isInterfaceDeclaration(node)) {
const interfaceName = node.name.escapedText.toString();
if (builtInTypeScriptTypes.has(interfaceName)) {
throw new Error(`Found interface ${
interfaceName} that conflicts with TypeScript's built-in type. Please choose a different name!`);
}
state.foundInterfaces.add(node);
} else if (ts.isTypeAliasDeclaration(node)) {
const typeName = node.name.escapedText.toString();
if (builtInTypeScriptTypes.has(typeName)) {
throw new Error(
`Found type ${typeName} that conflicts with TypeScript's built-in type. Please choose a different name!`);
}
state.foundInterfaces.add(node);
} else if (ts.isEnumDeclaration(node)) {
const isConstEnum = node.modifiers && node.modifiers.some(modifier => modifier.kind === ts.SyntaxKind.ConstKeyword);
if (!isConstEnum) {
throw new Error(`Found enum ${node.name.escapedText.toString()} that is not a const enum.`);
}
const allMembersAreExplictlyInitialized = node.members.every(enumMember => {
return enumMember.initializer !== undefined;
});
if (!allMembersAreExplictlyInitialized) {
throw new Error(
`Found enum ${node.name.escapedText.toString()} whose members do not have manually defined values.`);
}
state.foundEnums.add(node);
} else if (ts.isImportDeclaration(node)) {
const filePath = (node.moduleSpecifier as ts.StringLiteral).text;
const fileWithoutExt = path.basename(filePath, '.js');
const sourceFile = `${fileWithoutExt}.ts`;
if (node.importClause && node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
const namedImports = node.importClause.namedBindings.elements.map(namedImport => {
return namedImport.name.escapedText.toString();
});
state.imports.add({
filePath: sourceFile,
namedImports: new Set(namedImports),
});
} else if (
node.importClause && node.importClause.namedBindings && ts.isNamespaceImport(node.importClause.namedBindings)) {
// This means it's import * as Foo from '../foo/foo.js'. We care
// about this, because if the component uses these imports for a type (e.g.
// Common.Color.Color), we need that import in the generated bridge.
const namespace = node.importClause.namedBindings.name.escapedText.toString();
const filePath = (node.moduleSpecifier as ts.StringLiteral).text;
state.imports.add({
filePath,
namespace,
});
}
} else if (ts.isExpressionStatement(node) && ts.isCallExpression(node.expression)) {
if (ts.isPropertyAccessExpression(node.expression.expression)) {
const propertyAccess = node.expression.expression;
if (ts.isIdentifier(propertyAccess.expression) && ts.isIdentifier(propertyAccess.name)) {
const leftSideText = propertyAccess.expression.escapedText.toString();
const rightSideText = propertyAccess.name.escapedText.toString();
if (leftSideText === 'customElements' && rightSideText === 'define') {
state.customElementsDefineCall = node;
}
}
}
} else if (ts.isTaggedTemplateExpression(node)) {
if (taggedTemplateExpressionIsLitHtmlCall(node) && ts.isTemplateExpression(node.template)) {
// Search for a template part that ends in .data=
const dataSetterText = '.data=';
/* This is the easy case: the template starts with it, so we grab the
* first template span and check that.
*
* Here the AST will look something like:
* TemplateExpression
* - head: "<devtools-foo .data="
* TemplateSpans
* - 0: AST representing { foo: 'foo' } as X code
*/
if (node.template.head.text.endsWith(dataSetterText)) {
const matchingSpan = node.template.templateSpans[0];
checkTemplateSpanForTypeCastOfData(matchingSpan);
} else {
/* Slightly harder case, it's not at the start, so we need to look
* through each template span to find a template middle that ends with
* `.data=`. A TemplateSpan contains an expression (the part being
* interpolated) and a "middle", which is the raw text that leads up to
* the next interpolation. Here the AST will look something like: head:
* "foo" TemplateSpans
* - 0:
* - expression representing the interpolation
* - middle: "<devtools-foo .data="
* - 1:
* - AST representing { foo: 'foo' } as X
*
* So we want to find a TemplateSpan whose "middle" ends with ".data=",
* and then look at the expression in the next TemplateSpan.
*/
node.template.templateSpans.forEach((templateSpan, index) => {
if (templateSpan.literal.text.endsWith(dataSetterText) && ts.isTemplateExpression(node.template)) {
// Now we found the span with the literal text, the next span will
// have the expression within.
const spanWithExpression = node.template.templateSpans[index + 1];
checkTemplateSpanForTypeCastOfData(spanWithExpression);
}
});
}
}
}
node.forEachChild(node => {
walkNode(node, state);
});
return state;
};
export const filePathToTypeScriptSourceFile = (filePath: string): ts.SourceFile => {
return ts.createSourceFile(filePath, fs.readFileSync(filePath, {encoding: 'utf8'}), ts.ScriptTarget.ESNext);
};
const findNestedInterfacesInInterface =
(state: WalkerState, interfaceDec: ts.InterfaceDeclaration|ts.TypeLiteralNode): Set<string> => {
const foundNestedInterfaceNames = new Set<string>();
interfaceDec.members.forEach(member => {
if (ts.isPropertySignature(member)) {
if (!member.type) {
return;
}
const nestedInterfacesForMember = findTypeReferencesWithinNode(state, member.type);
nestedInterfacesForMember.forEach(nested => foundNestedInterfaceNames.add(nested));
}
});
return foundNestedInterfaceNames;
};
const findNestedReferencesForTypeReference =
(state: WalkerState,
interfaceOrTypeAliasDeclaration: ts.InterfaceDeclaration|ts.TypeAliasDeclaration): Set<string> => {
const foundNestedReferences = new Set<string>();
if (ts.isTypeAliasDeclaration(interfaceOrTypeAliasDeclaration)) {
if (ts.isTypeLiteralNode(interfaceOrTypeAliasDeclaration.type)) {
/* this means it's a type Person = { name: string } */
const nestedInterfaces = findNestedInterfacesInInterface(state, interfaceOrTypeAliasDeclaration.type);
nestedInterfaces.forEach(nestedInterface => foundNestedReferences.add(nestedInterface));
} else if (ts.isUnionTypeNode(interfaceOrTypeAliasDeclaration.type)) {
interfaceOrTypeAliasDeclaration.type.types.forEach(unionTypeMember => {
if (ts.isTypeReferenceNode(unionTypeMember) &&
ts.isIdentifierOrPrivateIdentifier(unionTypeMember.typeName)) {
foundNestedReferences.add(unionTypeMember.typeName.escapedText.toString());
}
});
} else if (ts.isIntersectionTypeNode(interfaceOrTypeAliasDeclaration.type)) {
/**
* This means it's something like:
*
* type NamedThing = { foo: Foo }
* type Person = NamedThing & { name: 'jack' };
*
* The bridges generator will inline types when they are extended, so we
* _don't_ need `NamedThing` to be defined in the bridge. But `NamedThing`
* mentions `Foo`, so we do need to include `Foo` in the bridge.
*/
interfaceOrTypeAliasDeclaration.type.types.forEach(nestedType => {
if (ts.isTypeLiteralNode(nestedType)) {
// this is any `& { name: string }` parts of the type alias.
const nestedInterfaces = findNestedInterfacesInInterface(state, nestedType);
nestedInterfaces.forEach(nestedInterface => foundNestedReferences.add(nestedInterface));
} else if (ts.isTypeReferenceNode(nestedType) && ts.isIdentifierOrPrivateIdentifier(nestedType.typeName)) {
// This means we have a reference to another interface so we have to
// find the interface and check for any nested interfaces within it.
const typeReferenceName = nestedType.typeName.escapedText.toString();
const nestedTypeReference = findNodeForTypeReferenceName(state, typeReferenceName);
if (!nestedTypeReference) {
throw new Error(`Could not find definition for type reference ${typeReferenceName}.`);
}
if (ts.isEnumDeclaration(nestedTypeReference)) {
state.typeReferencesToConvert.add(nestedTypeReference.name.escapedText.toString());
} else {
// Recurse on the nested interface because if it references any other
// interfaces we need to include those in the bridge.
findNestedReferencesForTypeReference(state, nestedTypeReference)
.forEach(nested => foundNestedReferences.add(nested));
}
}
});
}
} else {
// If it wasn't a type alias, it's an interface, so walk through the interface and add any found nested types.
const nestedInterfaces = findNestedInterfacesInInterface(state, interfaceOrTypeAliasDeclaration);
nestedInterfaces.forEach(nestedInterface => foundNestedReferences.add(nestedInterface));
// if the interface has any extensions, we need to dive into those too
// e.g. interface X extends Y means we have to check Y for any additional type references
if (interfaceOrTypeAliasDeclaration.heritageClauses) {
interfaceOrTypeAliasDeclaration.heritageClauses.forEach(heritageClause => {
const extendNames = heritageClause.types.map(heritageClauseName => {
if (ts.isIdentifier(heritageClauseName.expression)) {
return heritageClauseName.expression.escapedText.toString();
}
throw new Error('Unexpected heritageClauseName with no identifier.');
});
extendNames.forEach(interfaceName => {
const interfaceDec = findNodeForTypeReferenceName(state, interfaceName);
if (!interfaceDec) {
throw new Error(`Could not find interface: ${interfaceName}`);
}
if (!ts.isInterfaceDeclaration(interfaceDec)) {
throw new Error('Found invalid TypeScript: an interface cannot extend a type.');
}
const nestedInterfaces = findNestedInterfacesInInterface(state, interfaceDec);
nestedInterfaces.forEach(nestedInterface => foundNestedReferences.add(nestedInterface));
});
});
}
}
return foundNestedReferences;
};
const populateTypeReferencesToConvert = (state: WalkerState): WalkerState => {
state.typeReferencesToConvert.forEach(typeReferenceName => {
const typeDeclaration = findNodeForTypeReferenceName(state, typeReferenceName);
// if the interface isn't found, it might be imported, so just move on.
if (!typeDeclaration) {
return;
}
if (ts.isEnumDeclaration(typeDeclaration)) {
// Enums can't have any types nested within them so we can stop at this point.
return;
}
const nestedReferences = findNestedReferencesForTypeReference(state, typeDeclaration);
nestedReferences.forEach(nested => state.typeReferencesToConvert.add(nested));
});
return state;
};
// This is a list of types that TS + Closure understand that aren't defined by
// the user and therefore we don't need to generate typedefs for them, and
// just convert them into Closure Note that built-in types that take generics
// are not in this list (e.g. Map, Set) because we special case parsing them
// because of the generics.
const builtInTypeScriptTypes = new Set([
'CanvasRenderingContext2D',
'Element',
'HTMLElement',
'HTMLDivElement',
'HTMLTextAreaElement',
'HTMLInputElement',
'HTMLSelectElement',
'HTMLOptionElement',
'HTMLCanvasElement',
'Object',
'Uint8Array',
]);
export const walkTree = (startNode: ts.SourceFile, resolvedFilePath: string): WalkerState => {
let state = walkNode(startNode);
/**
* Now we have a list of top level interfaces we need to convert, we need to
* go through each one and look for any interfaces referenced within e.g.:
*
* ```
* interface Baz {...}
*
* interface Foo {
* x: Baz
* }
*
* // in the component
* set data(data: { foo: Foo }) {}
* ```
*
* We know we have to convert the Foo interface in the _bridge.js, but we need
* to also convert Baz because Foo references it.
*/
state = populateTypeReferencesToConvert(state);
/* if we are here and found an interface passed to a public method
* that we didn't find the definition for, that means it's imported
* so we now need to walk that imported file
*/
const allFoundTypeReferencesNames = new Set([
...Array.from(
state.foundInterfaces,
foundInterface => {
return foundInterface.name.escapedText.toString();
}),
...Array.from(
state.foundEnums,
foundEnum => {
return foundEnum.name.escapedText.toString();
}),
]);
const missingTypeReferences = Array.from(state.typeReferencesToConvert).filter(name => {
return allFoundTypeReferencesNames.has(name) === false;
});
/* now look at all the imports and see if we have the name of the missing interface
* and if we do, walk that file to find the interface
* else, error loudly
*/
const importsToCheck = new Set<string>();
missingTypeReferences.forEach(missingInterfaceName => {
if (builtInTypeScriptTypes.has(missingInterfaceName)) {
return;
}
const importForMissingInterface =
Array.from(state.imports).find(imp => 'namedImports' in imp && imp.namedImports.has(missingInterfaceName));
if (!importForMissingInterface) {
throw new Error(`Could not find definition for type reference ${
missingInterfaceName} in the source file or any of its imports.`);
}
importsToCheck.add(path.join(path.dirname(resolvedFilePath), importForMissingInterface.filePath));
});
importsToCheck.forEach(fullPathToImport => {
const sourceFile = filePathToTypeScriptSourceFile(fullPathToImport);
const stateFromSubFile = walkTree(sourceFile, fullPathToImport);
// now merge the foundInterfaces part
stateFromSubFile.foundInterfaces.forEach(foundInterface => {
state.foundInterfaces.add(foundInterface);
});
stateFromSubFile.foundEnums.forEach(foundEnum => {
state.foundEnums.add(foundEnum);
});
stateFromSubFile.typeReferencesToConvert.forEach(interfaceToConvert => {
state.typeReferencesToConvert.add(interfaceToConvert);
});
});
// We did this before parsing any imports from other files, but we now do it
// again. If we found a definition in another module that we care about, we
// need to parse it to check its nested state. This could be more efficient
// (we have to do two passes, before and after parsing 3rd party imports), but
// given component bridges are not going to be around forever, we will defer
// any performance concerns here until they start slowing us down day to day.
state = populateTypeReferencesToConvert(state);
// If we found any nested references that reference built-in TS types we can
// just delete them.
builtInTypeScriptTypes.forEach(builtIn => state.typeReferencesToConvert.delete(builtIn));
return state;
};