Skip to content
Open
36 changes: 31 additions & 5 deletions src/analyze/flavors/custom-element/discover-members.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
});
Expand Down
57 changes: 55 additions & 2 deletions src/analyze/stages/analyze-declaration.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -51,6 +51,8 @@ export function analyzeComponentDeclaration(
}
}

const checker = baseContext.checker;

// Get symbol of main declaration node
const symbol = getSymbol(mainDeclarationNode, baseContext);

Expand All @@ -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
Expand Down Expand Up @@ -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<T> { p: T; }
* class B extends A<number> {}
* class C extends A<boolean> {}
* ```
*
* 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<T> ...`).
*
* Calling this function with `B`'s `Type` produces a map with two entries:
* `B`'s `Node` mapped to `B`'s `Type` and `A<T>`'s `Node` mapped to
* `A<number>`'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<T>`'s `Node`
* mapped to `A<boolean>`'s `Type`. Calling this function with `A<T>`'s
* *unspecialized* type produces a map with one entry: `A<T>`'s `Node` mapped to
* `A<T>`'s *unspecialized* `Type` (distinct from the types of `A<number>` and
* `A<boolean>`). In each case, the resulting map contains an entry with
* `A<T>`'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<Node, Type> {
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
Expand Down
8 changes: 7 additions & 1 deletion src/analyze/types/component-declaration.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Node, Type>;
}
8 changes: 7 additions & 1 deletion src/analyze/types/features/component-member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;

Expand Down
8 changes: 7 additions & 1 deletion test/flavors/custom-element/ctor-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading