Skip to content

Commit

Permalink
feat (labs/analyzer): support custom element analysis (#3621)
Browse files Browse the repository at this point in the history
* feat (labs/analyzer): support custom element analysis

This separates the current lit-element analysis into two layers:

* custom elements
* lit

Primarily this is so we can detect non-lit elements we may depend on
inside a lit project, giving the user full coverage of all the elements
they're consuming.

* Align vanilla jsdoc tests to LitElement tests

* Add changeset

---------

Co-authored-by: Kevin Schaaf <kschaaf@google.com>
  • Loading branch information
43081j and kevinpschaaf committed Feb 4, 2023
1 parent 83ff025 commit dfdc3f7
Show file tree
Hide file tree
Showing 21 changed files with 1,131 additions and 100 deletions.
5 changes: 5 additions & 0 deletions .changeset/big-squids-drive.md
@@ -0,0 +1,5 @@
---
'@lit-labs/analyzer': minor
---

Added analysys of vanilla custom elements that extend HTMLElement.
1 change: 1 addition & 0 deletions packages/labs/analyzer/src/index.ts
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
@@ -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
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
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);
};

0 comments on commit dfdc3f7

Please sign in to comment.