Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat (labs/analyzer): support custom element analysis #3621

Merged
merged 3 commits into from
Feb 4, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/labs/analyzer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
Parameter,
Return,
LitElementDeclaration,
CustomElementDeclaration,
LitElementExport,
PackageJson,
ModuleWithLitElementDeclarations,
Expand Down
175 changes: 175 additions & 0 deletions packages/labs/analyzer/src/lib/custom-elements/custom-elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/

/**
* @fileoverview
*
* Utilities for analyzing native custom elements (i.e. `HTMLElement`)
* subclasses.
*/

import ts from 'typescript';
import {getClassMembers, getHeritage} from '../javascript/classes.js';
import {
AnalyzerInterface,
CustomElementDeclaration,
Event,
NamedDescribed,
} from '../model.js';
import {addEventsToMap} from './events.js';
import {parseNodeJSDocInfo, parseNamedJSDocInfo} from '../javascript/jsdoc.js';

const _isCustomElementClassDeclaration = (
t: ts.BaseType,
analyzer: AnalyzerInterface
): boolean => {
const declarations = t.getSymbol()?.getDeclarations();
return (
declarations?.some(
(declaration) =>
(ts.isInterfaceDeclaration(declaration) &&
declaration.name?.text === 'HTMLElement') ||
isCustomElementSubclass(declaration, analyzer)
) === true
);
};

export type CustomElementClassDeclaration = ts.ClassDeclaration & {
_customElementBrand: never;
};

export const isCustomElementSubclass = (
node: ts.Node,
analyzer: AnalyzerInterface
): node is CustomElementClassDeclaration => {
if (!ts.isClassLike(node)) {
return false;
}
if (getTagName(node) !== undefined) {
return true;
}
const checker = analyzer.program.getTypeChecker();
const type = checker.getTypeAtLocation(node) as ts.InterfaceType;
const baseTypes = checker.getBaseTypes(type);
for (const t of baseTypes) {
if (_isCustomElementClassDeclaration(t, analyzer)) {
return true;
}
}
return false;
};

export const getTagName = (
node: ts.ClassDeclaration | ts.ClassExpression
): string | undefined => {
const jsdocTag = ts
.getJSDocTags(node)
.find((tag) => tag.tagName.text.toLowerCase() === 'customelement');

if (jsdocTag && typeof jsdocTag.comment === 'string') {
return jsdocTag.comment.trim();
}

let tagName: string | undefined = undefined;

// Otherwise, look for imperative define in the form of:
// `customElements.define('x-foo', XFoo);`
node.parent.forEachChild((child) => {
if (
ts.isExpressionStatement(child) &&
ts.isCallExpression(child.expression) &&
ts.isPropertyAccessExpression(child.expression.expression) &&
child.expression.arguments.length >= 2
) {
const [tagNameArg, ctorArg] = child.expression.arguments;
const {expression, name} = child.expression.expression;
if (
ts.isIdentifier(expression) &&
expression.text === 'customElements' &&
ts.isIdentifier(name) &&
name.text === 'define' &&
ts.isStringLiteralLike(tagNameArg) &&
ts.isIdentifier(ctorArg) &&
ctorArg.text === node.name?.text
) {
tagName = tagNameArg.text;
}
}
});

return tagName;
};

/**
* Adds name, description, and summary info for a given jsdoc tag into the
* provided map.
*/
const addNamedJSDocInfoToMap = (
map: Map<string, NamedDescribed>,
tag: ts.JSDocTag
) => {
const info = parseNamedJSDocInfo(tag);
if (info !== undefined) {
map.set(info.name, info);
}
};

/**
* Parses element metadata from jsDoc tags from a LitElement declaration into
* Maps of <name, info>.
*/
export const getJSDocData = (
node: ts.ClassDeclaration,
analyzer: AnalyzerInterface
) => {
const events = new Map<string, Event>();
const slots = new Map<string, NamedDescribed>();
const cssProperties = new Map<string, NamedDescribed>();
const cssParts = new Map<string, NamedDescribed>();
const jsDocTags = ts.getJSDocTags(node);
if (jsDocTags !== undefined) {
for (const tag of jsDocTags) {
switch (tag.tagName.text) {
case 'fires':
addEventsToMap(tag, events, analyzer);
break;
case 'slot':
addNamedJSDocInfoToMap(slots, tag);
break;
case 'cssProp':
addNamedJSDocInfoToMap(cssProperties, tag);
break;
case 'cssProperty':
addNamedJSDocInfoToMap(cssProperties, tag);
break;
case 'cssPart':
addNamedJSDocInfoToMap(cssParts, tag);
break;
}
}
}
return {
...parseNodeJSDocInfo(node),
events,
slots,
cssProperties,
cssParts,
};
};

export const getCustomElementDeclaration = (
node: CustomElementClassDeclaration,
analyzer: AnalyzerInterface
): CustomElementDeclaration => {
return new CustomElementDeclaration({
tagname: getTagName(node),
name: node.name?.text ?? '',
node,
...getJSDocData(node, analyzer),
getHeritage: () => getHeritage(node, analyzer),
...getClassMembers(node, analyzer),
});
};
7 changes: 7 additions & 0 deletions packages/labs/analyzer/src/lib/javascript/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import {
} from '../utils.js';
import {getFunctionLikeInfo} from './functions.js';
import {getTypeForNode} from '../types.js';
import {
isCustomElementSubclass,
getCustomElementDeclaration,
} from '../custom-elements/custom-elements.js';

/**
* Returns an analyzer `ClassDeclaration` model for the given
Expand All @@ -47,6 +51,9 @@ const getClassDeclaration = (
if (isLitElementSubclass(declaration, analyzer)) {
return getLitElementDeclaration(declaration, analyzer);
}
if (isCustomElementSubclass(declaration, analyzer)) {
return getCustomElementDeclaration(declaration, analyzer);
}
return new ClassDeclaration({
// TODO(kschaaf): support anonymous class expressions when assigned to a const
name: declaration.name?.text ?? '',
Expand Down
100 changes: 7 additions & 93 deletions packages/labs/analyzer/src/lib/lit-element/lit-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,13 @@

import ts from 'typescript';
import {getClassMembers, getHeritage} from '../javascript/classes.js';
import {parseNodeJSDocInfo, parseNamedJSDocInfo} from '../javascript/jsdoc.js';
import {
LitElementDeclaration,
AnalyzerInterface,
Event,
NamedDescribed,
} from '../model.js';
import {LitElementDeclaration, AnalyzerInterface} from '../model.js';
import {isCustomElementDecorator} from './decorators.js';
import {addEventsToMap} from './events.js';
import {getProperties} from './properties.js';
import {
getJSDocData,
getTagName as getCustomElementTagName,
} from '../custom-elements/custom-elements.js';

/**
* Gets an analyzer LitElementDeclaration object from a ts.ClassDeclaration
Expand All @@ -43,63 +40,6 @@ export const getLitElementDeclaration = (
});
};

/**
* Parses element metadata from jsDoc tags from a LitElement declaration into
* Maps of <name, info>.
*/
export const getJSDocData = (
node: LitClassDeclaration,
analyzer: AnalyzerInterface
) => {
const events = new Map<string, Event>();
const slots = new Map<string, NamedDescribed>();
const cssProperties = new Map<string, NamedDescribed>();
const cssParts = new Map<string, NamedDescribed>();
const jsDocTags = ts.getJSDocTags(node);
if (jsDocTags !== undefined) {
for (const tag of jsDocTags) {
switch (tag.tagName.text) {
case 'fires':
addEventsToMap(tag, events, analyzer);
break;
case 'slot':
addNamedJSDocInfoToMap(slots, tag);
break;
case 'cssProp':
addNamedJSDocInfoToMap(cssProperties, tag);
break;
case 'cssProperty':
addNamedJSDocInfoToMap(cssProperties, tag);
break;
case 'cssPart':
addNamedJSDocInfoToMap(cssParts, tag);
break;
}
}
}
return {
...parseNodeJSDocInfo(node),
events,
slots,
cssProperties,
cssParts,
};
};

/**
* Adds name, description, and summary info for a given jsdoc tag into the
* provided map.
*/
const addNamedJSDocInfoToMap = (
map: Map<string, NamedDescribed>,
tag: ts.JSDocTag
) => {
const info = parseNamedJSDocInfo(tag);
if (info !== undefined) {
map.set(info.name, info);
}
};

/**
* Returns true if this type represents the actual LitElement class.
*/
Expand Down Expand Up @@ -179,7 +119,6 @@ export const isLitElementSubclass = (
* @returns
*/
export const getTagName = (declaration: LitClassDeclaration) => {
let tagName: string | undefined = undefined;
const customElementDecorator = declaration.decorators?.find(
isCustomElementDecorator
);
Expand All @@ -189,32 +128,7 @@ export const getTagName = (declaration: LitClassDeclaration) => {
ts.isStringLiteral(customElementDecorator.expression.arguments[0])
) {
// Get tag from decorator: `@customElement('x-foo')`
tagName = customElementDecorator.expression.arguments[0].text;
} else {
// Otherwise, look for imperative define in the form of:
// `customElements.define('x-foo', XFoo);`
declaration.parent.forEachChild((child) => {
if (
ts.isExpressionStatement(child) &&
ts.isCallExpression(child.expression) &&
ts.isPropertyAccessExpression(child.expression.expression) &&
child.expression.arguments.length >= 2
) {
const [tagNameArg, ctorArg] = child.expression.arguments;
const {expression, name} = child.expression.expression;
if (
ts.isIdentifier(expression) &&
expression.text === 'customElements' &&
ts.isIdentifier(name) &&
name.text === 'define' &&
ts.isStringLiteralLike(tagNameArg) &&
ts.isIdentifier(ctorArg) &&
ctorArg.text === declaration.name?.text
) {
tagName = tagNameArg.text;
}
}
});
return customElementDecorator.expression.arguments[0].text;
}
return tagName;
return getCustomElementTagName(declaration);
};