From 36d964146e8e589730f9481ec7c33c5e49828fdf Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 4 Jan 2023 14:26:06 -0800 Subject: [PATCH 1/9] Members' `type` functions now accept an optional descendant context declaration. (...) Component members are discovered within the context of some declaration associated with a single AST node. If that declaration has free type parameters, then asking the type checker for that declaration's AST node's type will return a type that still contains those free type parameters. Further, different choices for those type parameters in descendant component declarations may cause the type of the member to vary. To allow a member to return its type with all type arguments substituted, members' `type` functions can now optionally be passed a component declaration that (inclusively) descends from the member's declaration. The type returned will be the type of this member *for an instance of the given declaration*. --- .../custom-element/discover-members.ts | 26 ++++++++- src/analyze/stages/analyze-declaration.ts | 57 ++++++++++++++++++- src/analyze/types/component-declaration.ts | 8 ++- .../types/features/component-member.ts | 8 ++- 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/analyze/flavors/custom-element/discover-members.ts b/src/analyze/flavors/custom-element/discover-members.ts index dfb10515..27e9d1e0 100644 --- a/src/analyze/flavors/custom-element/discover-members.ts +++ b/src/analyze/flavors/custom-element/discover-members.ts @@ -1,5 +1,6 @@ import { toSimpleType } from "ts-simple-type"; import { BinaryExpression, ExpressionStatement, Node, ReturnStatement } 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"; @@ -74,7 +75,30 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon kind: "property", jsDoc: getJsDoc(node, ts), propName: name.text, - type: lazy(() => checker.getTypeAtLocation(node)), + // 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. + type: (descendant: ComponentDeclaration = context.getDeclaration()) => { + const memberNode = context.getDeclaration().node; + + const ancestorType = descendant.ancestorDeclarationNodeToType.get(memberNode); + if (!ancestorType) { + return checker.getTypeAtLocation(node); + } + + const property = ancestorType.getProperty(name.text); + if (!property) { + return checker.getTypeAtLocation(node); + } + + const type = checker.getTypeOfSymbolAtLocation(property, memberNode); + if (!type) { + return checker.getTypeAtLocation(node); + } + + return type; + }, default: def, visibility: getMemberVisibilityFromNode(node, ts), modifiers: getModifiersFromNode(node, ts) 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; From 79ae9767537959a91c104c816197cd37b06575a6 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 4 Jan 2023 17:38:13 -0800 Subject: [PATCH 2/9] `memberNode` -> `declarationNode` --- src/analyze/flavors/custom-element/discover-members.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/analyze/flavors/custom-element/discover-members.ts b/src/analyze/flavors/custom-element/discover-members.ts index 27e9d1e0..c93cc60e 100644 --- a/src/analyze/flavors/custom-element/discover-members.ts +++ b/src/analyze/flavors/custom-element/discover-members.ts @@ -80,9 +80,9 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon // the used declaration's type, those type parameters will remain free // in the type returned here. type: (descendant: ComponentDeclaration = context.getDeclaration()) => { - const memberNode = context.getDeclaration().node; + const declarationNode = context.getDeclaration().node; - const ancestorType = descendant.ancestorDeclarationNodeToType.get(memberNode); + const ancestorType = descendant.ancestorDeclarationNodeToType.get(declarationNode); if (!ancestorType) { return checker.getTypeAtLocation(node); } @@ -92,7 +92,7 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon return checker.getTypeAtLocation(node); } - const type = checker.getTypeOfSymbolAtLocation(property, memberNode); + const type = checker.getTypeOfSymbolAtLocation(property, declarationNode); if (!type) { return checker.getTypeAtLocation(node); } From 2d30e7a0243672c2408e91ead58f4cacee114ea1 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 4 Jan 2023 17:40:58 -0800 Subject: [PATCH 3/9] Add basic tests for types of members from generic base classes. --- test/flavors/custom-element/member-test.ts | 69 ++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 test/flavors/custom-element/member-test.ts diff --git a/test/flavors/custom-element/member-test.ts b/test/flavors/custom-element/member-test.ts new file mode 100644 index 00000000..4289510b --- /dev/null +++ b/test/flavors/custom-element/member-test.ts @@ -0,0 +1,69 @@ +import { 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"; + +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(toSimpleType(type, checker), { kind: "NUMBER" })); +}); + +tsTest("Member types are specialized if a descendant declaration is provided", 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(toSimpleType(numberElementPropType, checker), { kind: "NUMBER" })); + t.truthy(isAssignableToType(toSimpleType(numberElementPropType, checker), { kind: "NUMBER_LITERAL", value: 123 })); + + const booleanElementDecl = result.componentDefinitions.find(x => x.tagName === "boolean-prop-element")!.declaration!; + const booleanElementPropType = getComponentProp(booleanElementDecl.members, "prop")!.type!(booleanElementDecl); + t.truthy(isAssignableToType(toSimpleType(booleanElementPropType, checker), { kind: "BOOLEAN" })); + t.truthy(isAssignableToType(toSimpleType(booleanElementPropType, checker), { kind: "BOOLEAN_LITERAL", value: false })); +}); From eba3ae92adfe42c817084abe9d182bcad2f982b3 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Thu, 5 Jan 2023 11:59:33 -0800 Subject: [PATCH 4/9] Reverse assignability assertions to confirm that the given type is a subtype of the example type. --- test/flavors/custom-element/member-test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/flavors/custom-element/member-test.ts b/test/flavors/custom-element/member-test.ts index 4289510b..9fb54d3e 100644 --- a/test/flavors/custom-element/member-test.ts +++ b/test/flavors/custom-element/member-test.ts @@ -1,8 +1,12 @@ -import { isAssignableToType, toSimpleType } from "ts-simple-type"; +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], @@ -28,10 +32,10 @@ tsTest("Member types can be retrieved", t => { t.is(1, members.length); const type = getComponentProp(members, "prop")!.type!(); - t.truthy(isAssignableToType(toSimpleType(type, checker), { kind: "NUMBER" })); + t.truthy(isAssignableToType({ kind: "NUMBER" }, toSimpleType(type, checker))); }); -tsTest("Member types are specialized if a descendant declaration is provided", t => { +tsTest("Property declaration member types are specialized", t => { const { results: [result], checker @@ -59,11 +63,9 @@ tsTest("Member types are specialized if a descendant declaration is provided", t 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 })); + 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(toSimpleType(booleanElementPropType, checker), { kind: "BOOLEAN" })); - t.truthy(isAssignableToType(toSimpleType(booleanElementPropType, checker), { kind: "BOOLEAN_LITERAL", value: false })); + t.truthy(isAssignableToType(optional({ kind: "BOOLEAN" }), toSimpleType(booleanElementPropType, checker))); }); From 1c443f38a6b3872ba4e9c5053b8ec48280fe77df Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Thu, 5 Jan 2023 13:12:50 -0800 Subject: [PATCH 5/9] Add support for members discovered from accessors. --- .../custom-element/discover-members.ts | 55 ++++++------- test/flavors/custom-element/member-test.ts | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+), 27 deletions(-) diff --git a/src/analyze/flavors/custom-element/discover-members.ts b/src/analyze/flavors/custom-element/discover-members.ts index c93cc60e..cf8afebd 100644 --- a/src/analyze/flavors/custom-element/discover-members.ts +++ b/src/analyze/flavors/custom-element/discover-members.ts @@ -1,10 +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"; @@ -23,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) { @@ -75,30 +99,7 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon kind: "property", jsDoc: getJsDoc(node, ts), propName: name.text, - // 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. - type: (descendant: ComponentDeclaration = context.getDeclaration()) => { - const declarationNode = context.getDeclaration().node; - - const ancestorType = descendant.ancestorDeclarationNodeToType.get(declarationNode); - if (!ancestorType) { - return checker.getTypeAtLocation(node); - } - - const property = ancestorType.getProperty(name.text); - if (!property) { - return checker.getTypeAtLocation(node); - } - - const type = checker.getTypeOfSymbolAtLocation(property, declarationNode); - if (!type) { - return checker.getTypeAtLocation(node); - } - - return type; - }, + type: (descendant?: ComponentDeclaration) => getMemberType(name.text, descendant) ?? checker.getTypeAtLocation(node), default: def, visibility: getMemberVisibilityFromNode(node, ts), modifiers: getModifiersFromNode(node, ts) @@ -122,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) } diff --git a/test/flavors/custom-element/member-test.ts b/test/flavors/custom-element/member-test.ts index 9fb54d3e..55878790 100644 --- a/test/flavors/custom-element/member-test.ts +++ b/test/flavors/custom-element/member-test.ts @@ -69,3 +69,81 @@ tsTest("Property declaration member types are specialized", t => { 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))); +}); From 8557a6a51292417d30e14a0e46c104494630b4d6 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Thu, 5 Jan 2023 14:32:14 -0800 Subject: [PATCH 6/9] Add support for members discovered from assignments in constructors. --- .../custom-element/discover-members.ts | 3 +- test/flavors/custom-element/member-test.ts | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/analyze/flavors/custom-element/discover-members.ts b/src/analyze/flavors/custom-element/discover-members.ts index cf8afebd..46864958 100644 --- a/src/analyze/flavors/custom-element/discover-members.ts +++ b/src/analyze/flavors/custom-element/discover-members.ts @@ -156,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) => + relaxType(toSimpleType(getMemberType(propName, descendant) ?? checker.getTypeAtLocation(right), checker)), jsDoc: getJsDoc(assignment.parent, ts), visibility: isNamePrivate(propName) ? "private" : undefined }); diff --git a/test/flavors/custom-element/member-test.ts b/test/flavors/custom-element/member-test.ts index 55878790..8960da5d 100644 --- a/test/flavors/custom-element/member-test.ts +++ b/test/flavors/custom-element/member-test.ts @@ -147,3 +147,63 @@ tsTest("Setter member types are specialized", t => { 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))); +}); From ea16f13a1688e1cfd5be6e44600ecd1e7edc3169 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Thu, 5 Jan 2023 14:51:09 -0800 Subject: [PATCH 7/9] Prevent constructor declaration member types from being incorrectly relaxed. --- .../custom-element/discover-members.ts | 2 +- test/flavors/custom-element/member-test.ts | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/analyze/flavors/custom-element/discover-members.ts b/src/analyze/flavors/custom-element/discover-members.ts index 46864958..2e300fdf 100644 --- a/src/analyze/flavors/custom-element/discover-members.ts +++ b/src/analyze/flavors/custom-element/discover-members.ts @@ -157,7 +157,7 @@ export function discoverMembers(node: Node, context: AnalyzerDeclarationVisitCon propName, default: def, type: (descendant?: ComponentDeclaration) => - relaxType(toSimpleType(getMemberType(propName, descendant) ?? checker.getTypeAtLocation(right), checker)), + getMemberType(propName, descendant) ?? relaxType(toSimpleType(checker.getTypeAtLocation(right), checker)), jsDoc: getJsDoc(assignment.parent, ts), visibility: isNamePrivate(propName) ? "private" : undefined }); diff --git a/test/flavors/custom-element/member-test.ts b/test/flavors/custom-element/member-test.ts index 8960da5d..bb5a65ab 100644 --- a/test/flavors/custom-element/member-test.ts +++ b/test/flavors/custom-element/member-test.ts @@ -207,3 +207,65 @@ tsTest("Constructor declaration member types are specialized", t => { 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 })); +}); From 732c6fda32a99d7fd2d2d786a5b8bb1a31836f34 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Thu, 5 Jan 2023 15:28:43 -0800 Subject: [PATCH 8/9] Update a test case that now produces more specific type information. --- test/flavors/custom-element/ctor-test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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, From cb8b7b442b1463231098a1a308f67eb0085aa29f Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Thu, 5 Jan 2023 15:48:56 -0800 Subject: [PATCH 9/9] Add a test for generic mixins. --- test/flavors/custom-element/member-test.ts | 37 +++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/test/flavors/custom-element/member-test.ts b/test/flavors/custom-element/member-test.ts index bb5a65ab..77d6f38d 100644 --- a/test/flavors/custom-element/member-test.ts +++ b/test/flavors/custom-element/member-test.ts @@ -35,7 +35,7 @@ tsTest("Member types can be retrieved", t => { t.truthy(isAssignableToType({ kind: "NUMBER" }, toSimpleType(type, checker))); }); -tsTest("Property declaration member types are specialized", t => { +tsTest("Property declaration member types are specialized (classes)", t => { const { results: [result], checker @@ -70,6 +70,41 @@ tsTest("Property declaration member types are specialized", t => { 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],