diff --git a/src/analyze/flavors/custom-element/discover-members.ts b/src/analyze/flavors/custom-element/discover-members.ts index dfb10515..2e300fdf 100644 --- a/src/analyze/flavors/custom-element/discover-members.ts +++ b/src/analyze/flavors/custom-element/discover-members.ts @@ -1,9 +1,9 @@ import { toSimpleType } from "ts-simple-type"; -import { BinaryExpression, ExpressionStatement, Node, ReturnStatement } from "typescript"; +import { BinaryExpression, ExpressionStatement, Node, ReturnStatement, Type } from "typescript"; +import { ComponentDeclaration } from "../../types/component-declaration"; import { ComponentMember } from "../../types/features/component-member"; import { getMemberVisibilityFromNode, getModifiersFromNode, hasModifier } from "../../util/ast-util"; import { getJsDoc } from "../../util/js-doc-util"; -import { lazy } from "../../util/lazy"; import { resolveNodeValue } from "../../util/resolve-node-value"; import { isNamePrivate } from "../../util/text-util"; import { relaxType } from "../../util/type-util"; @@ -22,6 +22,31 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon return undefined; } + // If no `descendant` declaration is given, use the declaration that generated + // this member instead. If there are free type parameters in the used + // declaration's type, those type parameters will remain free in the type + // returned here. + const getMemberType = (name: string, descendant: ComponentDeclaration = context.getDeclaration()): Type | undefined => { + const declarationNode = context.getDeclaration().node; + + const ancestorType = descendant.ancestorDeclarationNodeToType.get(declarationNode); + if (!ancestorType) { + return undefined; + } + + const property = ancestorType.getProperty(name); + if (!property) { + return undefined; + } + + const type = checker.getTypeOfSymbolAtLocation(property, declarationNode); + if (!type) { + return undefined; + } + + return type; + }; + // static get observedAttributes() { return ['c', 'l']; } if (ts.isGetAccessor(node) && hasModifier(node, ts.SyntaxKind.StaticKeyword)) { if (node.name.getText() === "observedAttributes" && node.body != null) { @@ -74,7 +99,7 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon kind: "property", jsDoc: getJsDoc(node, ts), propName: name.text, - type: lazy(() => checker.getTypeAtLocation(node)), + type: (descendant?: ComponentDeclaration) => getMemberType(name.text, descendant) ?? checker.getTypeAtLocation(node), default: def, visibility: getMemberVisibilityFromNode(node, ts), modifiers: getModifiersFromNode(node, ts) @@ -98,7 +123,7 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon jsDoc: getJsDoc(node, ts), kind: "property", propName: name.text, - type: lazy(() => (parameter == null ? context.checker.getTypeAtLocation(node) : context.checker.getTypeAtLocation(parameter))), + type: (descendant?: ComponentDeclaration) => getMemberType(name.text, descendant) ?? checker.getTypeAtLocation(parameter ?? node), visibility: getMemberVisibilityFromNode(node, ts), modifiers: getModifiersFromNode(node, ts) } @@ -131,7 +156,8 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon kind: "property", propName, default: def, - type: () => relaxType(toSimpleType(checker.getTypeAtLocation(right), checker)), + type: (descendant?: ComponentDeclaration) => + getMemberType(propName, descendant) ?? relaxType(toSimpleType(checker.getTypeAtLocation(right), checker)), jsDoc: getJsDoc(assignment.parent, ts), visibility: isNamePrivate(propName) ? "private" : undefined }); diff --git a/src/analyze/stages/analyze-declaration.ts b/src/analyze/stages/analyze-declaration.ts index ecf89a48..ca9573ac 100644 --- a/src/analyze/stages/analyze-declaration.ts +++ b/src/analyze/stages/analyze-declaration.ts @@ -1,4 +1,4 @@ -import { Node } from "typescript"; +import { Node, Type, TypeChecker } from "typescript"; import { AnalyzerVisitContext } from "../analyzer-visit-context"; import { AnalyzerDeclarationVisitContext, ComponentFeatureCollection } from "../flavors/analyzer-flavor"; import { ComponentDeclaration } from "../types/component-declaration"; @@ -51,6 +51,8 @@ export function analyzeComponentDeclaration( } } + const checker = baseContext.checker; + // Get symbol of main declaration node const symbol = getSymbol(mainDeclarationNode, baseContext); @@ -69,7 +71,8 @@ export function analyzeComponentDeclaration( members: [], methods: [], slots: [], - jsDoc: getJsDoc(mainDeclarationNode, baseContext.ts) + jsDoc: getJsDoc(mainDeclarationNode, baseContext.ts), + ancestorDeclarationNodeToType: buildAncestorNodeToTypeMap(checker.getTypeAtLocation(mainDeclarationNode), checker) }; // Add the "get declaration" hook to the context @@ -138,6 +141,56 @@ export function analyzeComponentDeclaration( return baseDeclaration; } +/** + * Generates a map from declaration nodes in the AST to the type they produce in + * the base type tree of a given type. + * + * For example, this snippet contains three class declarations that produce more + * than three types: + * + * ``` + * class A { p: T; } + * class B extends A {} + * class C extends A {} + * ``` + * + * Classes `B` and `C` each extend `A`, but with different arguments for `A`'s + * type parameter `T`. This results in the base types of `B` and `C` being + * distinct specializations of `A` - one for each choice of type arguments - + * which both have the same declaration `Node` in the AST (`class A ...`). + * + * Calling this function with `B`'s `Type` produces a map with two entries: + * `B`'s `Node` mapped to `B`'s `Type` and `A`'s `Node` mapped to + * `A`'s `Type`. Calling this function with the `C`'s `Type` produces a + * map with two entries: `C`'s `Node` mapped to `C`'s `Type` and `A`'s `Node` + * mapped to `A`'s `Type`. Calling this function with `A`'s + * *unspecialized* type produces a map with one entry: `A`'s `Node` mapped to + * `A`'s *unspecialized* `Type` (distinct from the types of `A` and + * `A`). In each case, the resulting map contains an entry with + * `A`'s `Node` as a key but the type that it maps to is different. + * + * @param node + * @param checker + */ +function buildAncestorNodeToTypeMap(rootType: Type, checker: TypeChecker): Map { + const m = new Map(); + const walkAncestorTypeTree = (t: Type) => { + // If the type has any declarations, map them to that type. + for (const declaration of t.getSymbol()?.getDeclarations() ?? []) { + m.set(declaration, t); + } + + // Recurse into base types if `t is InterfaceType`. + if (t.isClassOrInterface()) { + for (const baseType of checker.getBaseTypes(t)) { + walkAncestorTypeTree(baseType); + } + } + }; + walkAncestorTypeTree(rootType); + return m; +} + /** * Returns if a node should be excluded from the analyzing * @param node diff --git a/src/analyze/types/component-declaration.ts b/src/analyze/types/component-declaration.ts index dde533c9..12154ba1 100644 --- a/src/analyze/types/component-declaration.ts +++ b/src/analyze/types/component-declaration.ts @@ -1,4 +1,4 @@ -import { Node, SourceFile, Symbol } from "typescript"; +import { Node, SourceFile, Symbol, Type } from "typescript"; import { ComponentCssPart } from "./features/component-css-part"; import { ComponentCssProperty } from "./features/component-css-property"; import { ComponentEvent } from "./features/component-event"; @@ -36,4 +36,10 @@ export interface ComponentDeclaration extends ComponentFeatures { symbol?: Symbol; deprecated?: boolean | string; heritageClauses: ComponentHeritageClause[]; + /** + * A map from declaration nodes of this declarations's ancestors to the types + * they generate in the base type tree of this component's type (i.e. with any + * known type arguments resolved). + */ + ancestorDeclarationNodeToType: Map; } diff --git a/src/analyze/types/features/component-member.ts b/src/analyze/types/features/component-member.ts index 6ba7c21c..f7f52bef 100644 --- a/src/analyze/types/features/component-member.ts +++ b/src/analyze/types/features/component-member.ts @@ -3,6 +3,7 @@ import { Node, Type } from "typescript"; import { PriorityKind } from "../../flavors/analyzer-flavor"; import { ModifierKind } from "../modifier-kind"; import { VisibilityKind } from "../visibility-kind"; +import { ComponentDeclaration } from "../component-declaration"; import { ComponentFeatureBase } from "./component-feature"; import { LitElementPropertyConfig } from "./lit-element-property-config"; @@ -16,7 +17,12 @@ export interface ComponentMemberBase extends ComponentFeatureBase { priority?: PriorityKind; typeHint?: string; - type: undefined | (() => Type | SimpleType); + /** + * @param {ComponentDeclaration} descendant - The component declaration for + * which this member's type is being retrieved, which may vary if there are + * generic types in that component's inheritance chain. + */ + type: undefined | ((descendant?: ComponentDeclaration) => Type | SimpleType); meta?: LitElementPropertyConfig; diff --git a/test/flavors/custom-element/ctor-test.ts b/test/flavors/custom-element/ctor-test.ts index 34eca753..6f9cd857 100644 --- a/test/flavors/custom-element/ctor-test.ts +++ b/test/flavors/custom-element/ctor-test.ts @@ -76,7 +76,13 @@ tsTest("Property assignments in the constructor are picked up", t => { attrName: undefined, jsDoc: undefined, default: { title: "foo", description: "bar" }, - type: () => ({ kind: "OBJECT" }), + type: () => ({ + kind: "OBJECT", + members: [ + { name: "title", optional: false, type: { kind: "STRING" } }, + { name: "description", optional: false, type: { kind: "STRING" } } + ] + }), visibility: undefined, reflect: undefined, deprecated: undefined, diff --git a/test/flavors/custom-element/member-test.ts b/test/flavors/custom-element/member-test.ts new file mode 100644 index 00000000..77d6f38d --- /dev/null +++ b/test/flavors/custom-element/member-test.ts @@ -0,0 +1,306 @@ +import { SimpleType, isAssignableToType, toSimpleType } from "ts-simple-type"; +import { analyzeTextWithCurrentTsModule } from "../../helpers/analyze-text-with-current-ts-module"; +import { tsTest } from "../../helpers/ts-test"; +import { getComponentProp } from "../../helpers/util"; + +const optional = (type: SimpleType): SimpleType => { + return { kind: "UNION", types: [{ kind: "UNDEFINED" }, type] }; +}; + +tsTest("Member types can be retrieved", t => { + const { + results: [result], + checker + } = analyzeTextWithCurrentTsModule([ + { + fileName: "main.ts", + text: ` + class SomeElement extends HTMLElement { + prop: number = 123; + } + + declare global { + interface HTMLElementTagNameMap { + "some-element": SomeElement; + } + } + ` + } + ]); + + const { members = [] } = result.componentDefinitions[0]?.declaration ?? {}; + + t.is(1, members.length); + const type = getComponentProp(members, "prop")!.type!(); + t.truthy(isAssignableToType({ kind: "NUMBER" }, toSimpleType(type, checker))); +}); + +tsTest("Property declaration member types are specialized (classes)", t => { + const { + results: [result], + checker + } = analyzeTextWithCurrentTsModule([ + { + fileName: "main.ts", + text: ` + class GenericPropElement extends HTMLElement { + prop?: T; + } + + class NumberPropElement extends GenericPropElement {} + + class BooleanPropElement extends GenericPropElement {} + + declare global { + interface HTMLElementTagNameMap { + "number-prop-element": NumberPropElement; + "boolean-prop-element": BooleanPropElement; + } + } + ` + } + ]); + + const numberElementDecl = result.componentDefinitions.find(x => x.tagName === "number-prop-element")!.declaration!; + const numberElementPropType = getComponentProp(numberElementDecl.members, "prop")!.type!(numberElementDecl); + t.truthy(isAssignableToType(optional({ kind: "NUMBER" }), toSimpleType(numberElementPropType, checker))); + + const booleanElementDecl = result.componentDefinitions.find(x => x.tagName === "boolean-prop-element")!.declaration!; + const booleanElementPropType = getComponentProp(booleanElementDecl.members, "prop")!.type!(booleanElementDecl); + t.truthy(isAssignableToType(optional({ kind: "BOOLEAN" }), toSimpleType(booleanElementPropType, checker))); +}); + +tsTest("Property declaration member types are specialized (mixins)", t => { + const { + results: [result], + checker + } = analyzeTextWithCurrentTsModule([ + { + fileName: "main.ts", + text: ` + const SomeMixin = (Base: C) => class extends Base { + prop?: T; + }; + + class NumberPropElement extends SomeMixin(HTMLElement) {} + + class BooleanPropElement extends SomeMixin(HTMLElement) {} + + declare global { + interface HTMLElementTagNameMap { + "number-prop-element": NumberPropElement; + "boolean-prop-element": BooleanPropElement; + } + } + ` + } + ]); + + const numberElementDecl = result.componentDefinitions.find(x => x.tagName === "number-prop-element")!.declaration!; + const numberElementPropType = getComponentProp(numberElementDecl.members, "prop")!.type!(numberElementDecl); + t.truthy(isAssignableToType(optional({ kind: "NUMBER" }), toSimpleType(numberElementPropType, checker))); + + const booleanElementDecl = result.componentDefinitions.find(x => x.tagName === "boolean-prop-element")!.declaration!; + const booleanElementPropType = getComponentProp(booleanElementDecl.members, "prop")!.type!(booleanElementDecl); + t.truthy(isAssignableToType(optional({ kind: "BOOLEAN" }), toSimpleType(booleanElementPropType, checker))); +}); + +tsTest("Getter member types are specialized", t => { + const { + results: [result], + checker + } = analyzeTextWithCurrentTsModule([ + { + fileName: "main.ts", + text: ` + class GenericPropElement extends HTMLElement { + storage?: T; + + get prop(): T | undefined { + return this.storage; + } + } + + class NumberPropElement extends GenericPropElement {} + + class BooleanPropElement extends GenericPropElement {} + + declare global { + interface HTMLElementTagNameMap { + "number-prop-element": NumberPropElement; + "boolean-prop-element": BooleanPropElement; + } + } + ` + } + ]); + + const numberElementDecl = result.componentDefinitions.find(x => x.tagName === "number-prop-element")!.declaration!; + const numberElementPropType = getComponentProp(numberElementDecl.members, "prop")!.type!(numberElementDecl); + t.truthy(isAssignableToType(optional({ kind: "NUMBER" }), toSimpleType(numberElementPropType, checker))); + + const booleanElementDecl = result.componentDefinitions.find(x => x.tagName === "boolean-prop-element")!.declaration!; + const booleanElementPropType = getComponentProp(booleanElementDecl.members, "prop")!.type!(booleanElementDecl); + t.truthy(isAssignableToType(optional({ kind: "BOOLEAN" }), toSimpleType(booleanElementPropType, checker))); +}); + +tsTest("Setter member types are specialized", t => { + const { + results: [result], + checker + } = analyzeTextWithCurrentTsModule([ + { + fileName: "main.ts", + text: ` + class GenericPropElement extends HTMLElement { + storage?: T; + + set prop(value: T) { + this.storage = value; + } + } + + class NumberPropElement extends GenericPropElement {} + + class BooleanPropElement extends GenericPropElement {} + + declare global { + interface HTMLElementTagNameMap { + "number-prop-element": NumberPropElement; + "boolean-prop-element": BooleanPropElement; + } + } + ` + } + ]); + + const numberElementDecl = result.componentDefinitions.find(x => x.tagName === "number-prop-element")!.declaration!; + const numberElementPropType = getComponentProp(numberElementDecl.members, "prop")!.type!(numberElementDecl); + t.truthy(isAssignableToType(optional({ kind: "NUMBER" }), toSimpleType(numberElementPropType, checker))); + + const booleanElementDecl = result.componentDefinitions.find(x => x.tagName === "boolean-prop-element")!.declaration!; + const booleanElementPropType = getComponentProp(booleanElementDecl.members, "prop")!.type!(booleanElementDecl); + t.truthy(isAssignableToType(optional({ kind: "BOOLEAN" }), toSimpleType(booleanElementPropType, checker))); +}); + +tsTest("Constructor declaration member types are specialized", t => { + const analyzeResult = analyzeTextWithCurrentTsModule([ + // tsc only allows JS to implicitly define members using assignment in the + // constructor. In TS, `prop` would require a declaration on the class + // itself (i.e. `prop: T;`). + { + fileName: "GenericPropElement.js", + text: ` + /** + * @template T + */ + export class GenericPropElement extends HTMLElement { + /** + * @param {T} value + */ + constructor(value) { + super(); + this.prop = value; + } + } + ` + }, + { + fileName: "main.ts", + text: ` + import {GenericPropElement} from "./GenericPropElement"; + + class NumberPropElement extends GenericPropElement { + constructor() { + super(123); + } + } + + class BooleanPropElement extends GenericPropElement { + constructor() { + super(false); + } + } + + declare global { + interface HTMLElementTagNameMap { + "number-prop-element": NumberPropElement; + "boolean-prop-element": BooleanPropElement; + } + } + ` + } + ]); + const { results, checker } = analyzeResult; + const result = results.find(x => x.sourceFile.fileName === "main.ts")!; + + const numberElementDecl = result.componentDefinitions.find(x => x.tagName === "number-prop-element")!.declaration!; + const numberElementPropType = getComponentProp(numberElementDecl.members, "prop")!.type!(numberElementDecl); + t.truthy(isAssignableToType({ kind: "NUMBER" }, toSimpleType(numberElementPropType, checker))); + + const booleanElementDecl = result.componentDefinitions.find(x => x.tagName === "boolean-prop-element")!.declaration!; + const booleanElementPropType = getComponentProp(booleanElementDecl.members, "prop")!.type!(booleanElementDecl); + t.truthy(isAssignableToType({ kind: "BOOLEAN" }, toSimpleType(booleanElementPropType, checker))); +}); + +tsTest("Constructor declaration member types specialized with literals maintain their strictness", t => { + const analyzeResult = analyzeTextWithCurrentTsModule([ + // tsc only allows JS to implicitly define members using assignment in the + // constructor. + { + fileName: "GenericPropElement.js", + text: ` + /** + * @template T + */ + export class GenericPropElement extends HTMLElement { + /** + * @param {T} value + */ + constructor(value) { + super(); + this.prop = value; + } + } + ` + }, + { + fileName: "main.ts", + text: ` + import {GenericPropElement} from "./GenericPropElement"; + + class NumberPropElement extends GenericPropElement { + constructor() { + super(123); + } + } + + class NumberLiteralPropElement extends GenericPropElement<456> { + constructor() { + super(456); + } + } + + declare global { + interface HTMLElementTagNameMap { + "number-prop-element": NumberPropElement; + "number-literal-prop-element": NumberLiteralPropElement; + } + } + ` + } + ]); + const { results, checker } = analyzeResult; + const result = results.find(x => x.sourceFile.fileName === "main.ts")!; + + const numberElementDecl = result.componentDefinitions.find(x => x.tagName === "number-prop-element")!.declaration!; + const numberElementPropType = getComponentProp(numberElementDecl.members, "prop")!.type!(numberElementDecl); + t.truthy(isAssignableToType(toSimpleType(numberElementPropType, checker), { kind: "NUMBER" })); + t.truthy(isAssignableToType(toSimpleType(numberElementPropType, checker), { kind: "NUMBER_LITERAL", value: 123 })); + + const numberLiteralElementDecl = result.componentDefinitions.find(x => x.tagName === "number-literal-prop-element")!.declaration!; + const numberLiteralElementPropType = getComponentProp(numberLiteralElementDecl.members, "prop")!.type!(numberLiteralElementDecl); + t.falsy(isAssignableToType(toSimpleType(numberLiteralElementPropType, checker), { kind: "NUMBER" })); + t.falsy(isAssignableToType(toSimpleType(numberLiteralElementPropType, checker), { kind: "NUMBER_LITERAL", value: 123 })); + t.truthy(isAssignableToType(toSimpleType(numberLiteralElementPropType, checker), { kind: "NUMBER_LITERAL", value: 456 })); +});