From ad0910bfa23513cbb8a21632e474cb2a95a9776d Mon Sep 17 00:00:00 2001 From: runem Date: Sun, 12 Jul 2020 19:44:01 +0200 Subject: [PATCH] Add support event types in JSDoc. Fixes #165 --- dev/src/custom-element/custom-element.ts | 3 +- dev/tsconfig.json | 2 +- package-lock.json | 6 +- package.json | 4 +- src/analyze/analyze-text.ts | 20 ++++++- src/analyze/analyzer-visit-context.ts | 3 +- .../flavors/custom-element/discover-events.ts | 40 +++++++++----- .../flavors/js-doc/discover-features.ts | 8 +-- src/analyze/flavors/js-doc/refine-feature.ts | 29 ++++++---- .../flavors/lit-element/discover-members.ts | 2 +- src/analyze/make-context-from-config.ts | 1 + src/analyze/stages/merge/merge-feature.ts | 4 +- src/analyze/types/features/component-event.ts | 2 +- src/analyze/util/js-doc-util.ts | 51 +++++++++++++---- src/cli/util/analyze-globs.ts | 16 +----- src/cli/util/compile.ts | 19 ++----- src/transformers/json2/json2-transformer.ts | 24 +++++--- .../markdown/markdown-transformer.ts | 3 +- src/util/type-util.ts | 55 +++++++++++++++++++ test/flavors/custom-element/event-test.ts | 51 +++++++++++++++++ test/flavors/jsdoc/event-test.ts | 55 ++++++++++++++++--- 21 files changed, 303 insertions(+), 95 deletions(-) create mode 100644 src/util/type-util.ts create mode 100644 test/flavors/custom-element/event-test.ts diff --git a/dev/src/custom-element/custom-element.ts b/dev/src/custom-element/custom-element.ts index 32c6be1e..f90b84ca 100644 --- a/dev/src/custom-element/custom-element.ts +++ b/dev/src/custom-element/custom-element.ts @@ -28,7 +28,8 @@ export class CustomElement extends MySuperClass { set attr1(val: string) {} onClick() { - new CustomEvent("my-custom-event"); + this.dispatchEvent(new CustomEvent("my-custom-event", { detail: "hello" })); + this.dispatchEvent(new MouseEvent("mouse-move")); } } diff --git a/dev/tsconfig.json b/dev/tsconfig.json index 190f4f9e..4f3ec19b 100644 --- a/dev/tsconfig.json +++ b/dev/tsconfig.json @@ -3,7 +3,7 @@ "experimentalDecorators": true, "target": "es5", "module": "commonjs", - "lib": ["esnext", "dom"], + "lib": ["ESnext", "DOM"], "strict": true, "esModuleInterop": true } diff --git a/package-lock.json b/package-lock.json index 21b4bd2f..43d218ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4856,9 +4856,9 @@ } }, "ts-simple-type": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ts-simple-type/-/ts-simple-type-1.0.0.tgz", - "integrity": "sha512-IfYROh5Z0jOkepPNjxIRFA1BOxsUxDN8f9pwSyY8aA+TazgDFZ5eTg5KX7yNFRGIP4itQtR/le4R2S4hkFNHNw==" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ts-simple-type/-/ts-simple-type-1.0.4.tgz", + "integrity": "sha512-y9EDw3CUx25ZZOAzARELkWTHwoAYmQTWOtITYJaCxNY1E5dJRLv+bdmJqTPTrubArwaRz863Yu/WLOze4w1LLg==" }, "tslib": { "version": "2.0.0", diff --git a/package.json b/package.json index 9d90f57b..ebfb74a2 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "homepage": "https://github.com/runem/web-component-analyzer#readme", "dependencies": { "fast-glob": "^3.2.2", - "ts-simple-type": "~1.0.0", + "ts-simple-type": "~1.0.4", "typescript": "^3.8.3", "yargs": "^15.3.1" }, @@ -87,7 +87,7 @@ "test/**/*.ts", "!test/{helpers,snapshots}/**/*" ], - "timeout": "200s" + "timeout": "2m" }, "husky": { "hooks": { diff --git a/src/analyze/analyze-text.ts b/src/analyze/analyze-text.ts index 8c47f0f0..f0dbc296 100644 --- a/src/analyze/analyze-text.ts +++ b/src/analyze/analyze-text.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join } from "path"; import * as tsModule from "typescript"; import { CompilerOptions, Program, ScriptKind, ScriptTarget, SourceFile, System, TypeChecker } from "typescript"; //import * as ts from "typescript"; @@ -10,6 +12,7 @@ export interface IVirtualSourceFile { fileName: string; text?: string; analyze?: boolean; + includeLib?: boolean; } export type VirtualSourceFile = IVirtualSourceFile | string; @@ -45,9 +48,24 @@ export function analyzeText(inputFiles: VirtualSourceFile[] | VirtualSourceFile, ) .map(file => ({ ...file, fileName: file.fileName })); + const includeLib = files.some(file => file.includeLib); + const readFile = (fileName: string): string | undefined => { const matchedFile = files.find(currentFile => currentFile.fileName === fileName); - return matchedFile == null ? undefined : matchedFile.text; + if (matchedFile != null) { + return matchedFile.text; + } + + if (includeLib) { + // TODO: find better method of finding the current typescript module path + fileName = fileName.match(/[/\\]/) ? fileName : join(dirname(require.resolve("typescript")), fileName); + } + + if (existsSync(fileName)) { + return readFileSync(fileName, "utf8").toString(); + } + + return undefined; }; const fileExists = (fileName: string): boolean => { diff --git a/src/analyze/analyzer-visit-context.ts b/src/analyze/analyzer-visit-context.ts index 0953a6e9..ed6906cd 100644 --- a/src/analyze/analyzer-visit-context.ts +++ b/src/analyze/analyzer-visit-context.ts @@ -1,5 +1,5 @@ import * as tsModule from "typescript"; -import { Node, SourceFile, TypeChecker } from "typescript"; +import { Node, SourceFile, TypeChecker, Program } from "typescript"; import { AnalyzerFlavor, ComponentFeatureCollection } from "./flavors/analyzer-flavor"; import { AnalyzerConfig } from "./types/analyzer-config"; import { ComponentDeclaration } from "./types/component-declaration"; @@ -10,6 +10,7 @@ import { ComponentDeclaration } from "./types/component-declaration"; */ export interface AnalyzerVisitContext { checker: TypeChecker; + program: Program; ts: typeof tsModule; config: AnalyzerConfig; flavors: AnalyzerFlavor[]; diff --git a/src/analyze/flavors/custom-element/discover-events.ts b/src/analyze/flavors/custom-element/discover-events.ts index 2e0095f6..cbd171ed 100644 --- a/src/analyze/flavors/custom-element/discover-events.ts +++ b/src/analyze/flavors/custom-element/discover-events.ts @@ -1,9 +1,30 @@ -import { SimpleType } from "ts-simple-type"; import { Node } from "typescript"; import { AnalyzerVisitContext } from "../../analyzer-visit-context"; import { ComponentEvent } from "../../types/features/component-event"; import { getJsDoc } from "../../util/js-doc-util"; import { lazy } from "../../util/lazy"; +import { resolveNodeValue } from "../../util/resolve-node-value"; + +const EVENT_NAMES = [ + "Event", + "CustomEvent", + "AnimationEvent", + "ClipboardEvent", + "DragEvent", + "FocusEvent", + "HashChangeEvent", + "InputEvent", + "KeyboardEvent", + "MouseEvent", + "PageTransitionEvent", + "PopStateEvent", + "ProgressEvent", + "StorageEvent", + "TouchEvent", + "TransitionEvent", + "UiEvent", + "WheelEvent" +]; /** * Discovers events dispatched @@ -15,14 +36,14 @@ export function discoverEvents(node: Node, context: AnalyzerVisitContext): Compo // new CustomEvent("my-event"); if (ts.isNewExpression(node)) { - const { expression, arguments: args, typeArguments } = node; + const { expression, arguments: args } = node; - if (expression.getText() === "CustomEvent" && args && args.length >= 1) { + if (EVENT_NAMES.includes(expression.getText()) && args && args.length >= 1) { const arg = args[0]; - if (ts.isStringLiteralLike(arg)) { - const eventName = arg.text; + const eventName = resolveNodeValue(arg, context)?.value; + if (typeof eventName === "string") { // Either grab jsdoc from the new expression or from a possible call expression that its wrapped in const jsDoc = getJsDoc(expression, ts) || @@ -35,14 +56,7 @@ export function discoverEvents(node: Node, context: AnalyzerVisitContext): Compo jsDoc, name: eventName, node, - type: lazy(() => { - return ( - (typeArguments?.[0] != null && checker.getTypeFromTypeNode(typeArguments[0])) || - ({ - kind: "ANY" - } as SimpleType) - ); - }) + type: lazy(() => checker.getTypeAtLocation(node)) } ]; } diff --git a/src/analyze/flavors/js-doc/discover-features.ts b/src/analyze/flavors/js-doc/discover-features.ts index a7325e6e..0ca58b2c 100644 --- a/src/analyze/flavors/js-doc/discover-features.ts +++ b/src/analyze/flavors/js-doc/discover-features.ts @@ -59,7 +59,7 @@ export const discoverFeatures: Partial (type && parseSimpleJsDocTypeExpression(type)) || { kind: "ANY" }), + type: type != null ? lazy(() => parseSimpleJsDocTypeExpression(type, context) || { kind: "ANY" }) : undefined, typeHint: type, node: tagNode }; @@ -77,7 +77,7 @@ export const discoverFeatures: Partial { // Grab the type from jsdoc and use it to find permitted tag names // Example: @slot {"div"|"span"} myslot - const permittedTagNameType = type == null ? undefined : parseSimpleJsDocTypeExpression(type); + const permittedTagNameType = type == null ? undefined : parseSimpleJsDocTypeExpression(type, context); const permittedTagNames: string[] | undefined = (() => { if (permittedTagNameType == null) { return undefined; @@ -120,7 +120,7 @@ export const discoverFeatures: Partial (type && parseSimpleJsDocTypeExpression(type)) || { kind: "ANY" }), + type: lazy(() => (type && parseSimpleJsDocTypeExpression(type, context)) || { kind: "ANY" }), node: tagNode, default: def, visibility: undefined, @@ -143,7 +143,7 @@ export const discoverFeatures: Partial (type && parseSimpleJsDocTypeExpression(type)) || { kind: "ANY" }), + type: lazy(() => (type && parseSimpleJsDocTypeExpression(type, context)) || { kind: "ANY" }), typeHint: type, node: tagNode, default: def, diff --git a/src/analyze/flavors/js-doc/refine-feature.ts b/src/analyze/flavors/js-doc/refine-feature.ts index ff0b5327..bbef691b 100644 --- a/src/analyze/flavors/js-doc/refine-feature.ts +++ b/src/analyze/flavors/js-doc/refine-feature.ts @@ -1,6 +1,5 @@ -import { ComponentEvent } from "../../types/features/component-event"; +import { AnalyzerVisitContext } from "../../analyzer-visit-context"; import { ComponentMember, ComponentMemberReflectKind } from "../../types/features/component-member"; -import { ComponentMethod } from "../../types/features/component-method"; import { JsDoc } from "../../types/js-doc"; import { VisibilityKind } from "../../types/visibility-kind"; import { parseSimpleJsDocTypeExpression } from "../../util/js-doc-util"; @@ -11,7 +10,7 @@ import { AnalyzerFlavor } from "../analyzer-flavor"; * Refines features by looking at the jsdoc tags on the feature */ export const refineFeature: AnalyzerFlavor["refineFeature"] = { - event: (event: ComponentEvent) => { + event: (event, context) => { if (event.jsDoc == null || event.jsDoc.tags == null) return event; // Check if the feature has "@ignore" jsdoc tag @@ -20,11 +19,11 @@ export const refineFeature: AnalyzerFlavor["refineFeature"] = { } return [applyJsDocDeprecated, applyJsDocVisibility, applyJsDocType].reduce( - (event, applyFunc) => (applyFunc as Function)(event, event.jsDoc), + (event, applyFunc) => (applyFunc as Function)(event, event.jsDoc, context), event ); }, - method: (method: ComponentMethod) => { + method: (method, context) => { if (method.jsDoc == null || method.jsDoc.tags == null) return method; // Check if the feature has "@ignore" jsdoc tag @@ -32,11 +31,14 @@ export const refineFeature: AnalyzerFlavor["refineFeature"] = { return undefined; } - method = [applyJsDocDeprecated, applyJsDocVisibility].reduce((method, applyFunc) => (applyFunc as Function)(method, method.jsDoc), method); + method = [applyJsDocDeprecated, applyJsDocVisibility].reduce( + (method, applyFunc) => (applyFunc as Function)(method, method.jsDoc, context), + method + ); return method; }, - member: (member: ComponentMember) => { + member: (member, context) => { // Return right away if the member doesn't have jsdoc if (member.jsDoc == null || member.jsDoc.tags == null) return member; @@ -54,7 +56,7 @@ export const refineFeature: AnalyzerFlavor["refineFeature"] = { applyJsDocType, applyJsDocAttribute, applyJsDocModifiers - ].reduce((member, applyFunc) => (applyFunc as Function)(member, member.jsDoc), member); + ].reduce((member, applyFunc) => (applyFunc as Function)(member, member.jsDoc, context), member); } }; @@ -122,10 +124,12 @@ function applyJsDocVisibility>>( feature: T, - jsDoc: JsDoc + jsDoc: JsDoc, + context: AnalyzerVisitContext ): T { const attributeTag = jsDoc.tags?.find(tag => ["attr", "attribute"].includes(tag.tag)); @@ -141,7 +145,7 @@ function applyJsDocAttribute parseSimpleJsDocTypeExpression(parsed.type || "")); + result.type = feature.type ?? lazy(() => parseSimpleJsDocTypeExpression(parsed.type || "", context)); } return result; @@ -237,8 +241,9 @@ function applyJsDocReflect>>( * Applies the "@type" jsdoc tag * @param feature * @param jsDoc + * @param context */ -function applyJsDocType>>(feature: T, jsDoc: JsDoc): T { +function applyJsDocType>>(feature: T, jsDoc: JsDoc, context: AnalyzerVisitContext): T { const typeTag = jsDoc.tags?.find(tag => tag.tag === "type"); if (typeTag != null && feature.typeHint == null) { @@ -248,7 +253,7 @@ function applyJsDocType parseSimpleJsDocTypeExpression(parsed.type || "")) + type: feature.type ?? lazy(() => parseSimpleJsDocTypeExpression(parsed.type || "", context)) }; } } diff --git a/src/analyze/flavors/lit-element/discover-members.ts b/src/analyze/flavors/lit-element/discover-members.ts index e0f1bd36..8dbc0b55 100644 --- a/src/analyze/flavors/lit-element/discover-members.ts +++ b/src/analyze/flavors/lit-element/discover-members.ts @@ -202,7 +202,7 @@ function parseStaticProperties(returnStatement: ReturnStatement, context: Analyz priority: "high", kind: "property", type: lazy(() => { - return (jsDoc && getJsDocType(jsDoc)) || (typeof litConfig.type === "object" && litConfig.type) || { kind: "ANY" }; + return (jsDoc && getJsDocType(jsDoc, context)) || (typeof litConfig.type === "object" && litConfig.type) || { kind: "ANY" }; }), propName: propName, attrName: emitAttribute ? attrName : undefined, diff --git a/src/analyze/make-context-from-config.ts b/src/analyze/make-context-from-config.ts index ffe86a71..c8f03d5d 100644 --- a/src/analyze/make-context-from-config.ts +++ b/src/analyze/make-context-from-config.ts @@ -21,6 +21,7 @@ export function makeContextFromConfig(options: AnalyzerOptions): AnalyzerVisitCo // Create context return { checker, + program: options.program, ts, flavors, cache: { diff --git a/src/analyze/stages/merge/merge-feature.ts b/src/analyze/stages/merge/merge-feature.ts index 82a72995..5d22757a 100644 --- a/src/analyze/stages/merge/merge-feature.ts +++ b/src/analyze/stages/merge/merge-feature.ts @@ -70,7 +70,9 @@ export function mergeEvents(events: ComponentEvent[]): ComponentEvent[] { event => event.name, (left, right) => ({ ...left, - jsDoc: mergeJsDoc(left.jsDoc, right.jsDoc) + jsDoc: mergeJsDoc(left.jsDoc, right.jsDoc), + type: () => (left.type != null ? left.type() : right.type != null ? right.type() : { kind: "ANY" }), + typeHint: left.typeHint || right.typeHint }) ); } diff --git a/src/analyze/types/features/component-event.ts b/src/analyze/types/features/component-event.ts index 633933e3..67fc294b 100644 --- a/src/analyze/types/features/component-event.ts +++ b/src/analyze/types/features/component-event.ts @@ -6,7 +6,7 @@ import { ComponentFeatureBase } from "./component-feature"; export interface ComponentEvent extends ComponentFeatureBase { name: string; node: Node; - type: () => SimpleType | Type; + type?: () => SimpleType | Type; typeHint?: string; visibility?: VisibilityKind; deprecated?: boolean | string; diff --git a/src/analyze/util/js-doc-util.ts b/src/analyze/util/js-doc-util.ts index ae05d8e0..c630f6a6 100644 --- a/src/analyze/util/js-doc-util.ts +++ b/src/analyze/util/js-doc-util.ts @@ -1,7 +1,8 @@ import { SimpleType, SimpleTypeStringLiteral } from "ts-simple-type"; import * as tsModule from "typescript"; -import { JSDoc, JSDocParameterTag, JSDocTypeTag, Node } from "typescript"; +import { JSDoc, JSDocParameterTag, JSDocTypeTag, Node, Program } from "typescript"; import { arrayDefined } from "../../util/array-util"; +import { getLibTypeWithName } from "../../util/type-util"; import { JsDoc, JsDocTag, JsDocTagParsed } from "../types/js-doc"; import { getLeadingCommentForNode } from "./ast-util"; import { lazy } from "./lazy"; @@ -97,8 +98,9 @@ export function getJsDoc(node: Node, tagNamesOrTs: string[] | typeof tsModule, t * Defaults to ANY * See http://usejsdoc.org/tags-type.html * @param str + * @param context */ -export function parseSimpleJsDocTypeExpression(str: string): SimpleType { +export function parseSimpleJsDocTypeExpression(str: string, context: { program: Program; ts: typeof tsModule }): SimpleType { // Fail safe if "str" is somehow undefined if (str == null) { return { kind: "ANY" }; @@ -128,7 +130,7 @@ export function parseSimpleJsDocTypeExpression(str: string): SimpleType { // Match // { string } if (str.startsWith(" ") || str.endsWith(" ")) { - return parseSimpleJsDocTypeExpression(str.trim()); + return parseSimpleJsDocTypeExpression(str.trim(), context); } // Match: @@ -137,7 +139,7 @@ export function parseSimpleJsDocTypeExpression(str: string): SimpleType { return { kind: "UNION", types: str.split("|").map(str => { - const childType = parseSimpleJsDocTypeExpression(str); + const childType = parseSimpleJsDocTypeExpression(str, context); // Convert ANY types to string literals so that {on|off} is "on"|"off" and not ANY|ANY if (childType.kind === "ANY") { @@ -160,7 +162,7 @@ export function parseSimpleJsDocTypeExpression(str: string): SimpleType { if (prefixMatch != null) { const modifier = prefixMatch[1]; - const type = parseSimpleJsDocTypeExpression(prefixMatch[3]); + const type = parseSimpleJsDocTypeExpression(prefixMatch[3], context); switch (modifier) { case "?": return { @@ -186,7 +188,7 @@ export function parseSimpleJsDocTypeExpression(str: string): SimpleType { // {(......)} const parenMatch = str.match(/^\((.+)\)$/); if (parenMatch != null) { - return parseSimpleJsDocTypeExpression(parenMatch[1]); + return parseSimpleJsDocTypeExpression(parenMatch[1], context); } // Match @@ -205,18 +207,47 @@ export function parseSimpleJsDocTypeExpression(str: string): SimpleType { if (arrayMatch != null) { return { kind: "ARRAY", - type: parseSimpleJsDocTypeExpression(arrayMatch[1]) + type: parseSimpleJsDocTypeExpression(arrayMatch[1], context) }; } - return { kind: "ANY" }; + // Match + // CustomEvent + // MyInterface + // MyInterface<{foo: string, bar: string}, number> + const genericArgsMatch = str.match(/^(.*)<(.*)>$/); + if (genericArgsMatch != null) { + // Here we split generic arguments by "," and + // afterwards remerge parts that were incorrectly split + // For example: "{foo: string, bar: string}, number" would result in + // ["{foo: string", "bar: string}", "number"] + // The correct way to improve "parseSimpleJsDocTypeExpression" is to build a custom lexer/parser. + const typeArgStrings: string[] = []; + for (const part of genericArgsMatch[2].split(/\s*,\s*/)) { + if (part.match(/[}:]/) != null && typeArgStrings.length > 0) { + typeArgStrings[typeArgStrings.length - 1] += `, ${part}`; + } else { + typeArgStrings.push(part); + } + } + + return { + kind: "GENERIC_ARGUMENTS", + target: parseSimpleJsDocTypeExpression(genericArgsMatch[1], context), + typeArguments: typeArgStrings.map(typeArg => parseSimpleJsDocTypeExpression(typeArg, context)) + }; + } + + // If nothing else, try to find the type in Typescript global lib or else return "any" + return getLibTypeWithName(str, context) || { kind: "ANY" }; } /** * Finds a @type jsdoc tag in the jsdoc and returns the corresponding simple type * @param jsDoc + * @param context */ -export function getJsDocType(jsDoc: JsDoc): SimpleType | undefined { +export function getJsDocType(jsDoc: JsDoc, context: { program: Program; ts: typeof tsModule }): SimpleType | undefined { if (jsDoc.tags != null) { const typeJsDocTag = jsDoc.tags.find(t => t.tag === "type"); @@ -225,7 +256,7 @@ export function getJsDocType(jsDoc: JsDoc): SimpleType | undefined { const parsedJsDoc = parseJsDocTagString(typeJsDocTag.node?.getText() || ""); if (parsedJsDoc.type != null) { - return parseSimpleJsDocTypeExpression(parsedJsDoc.type); + return parseSimpleJsDocTypeExpression(parsedJsDoc.type, context); } } } diff --git a/src/cli/util/analyze-globs.ts b/src/cli/util/analyze-globs.ts index 553cef25..b3e672ed 100644 --- a/src/cli/util/analyze-globs.ts +++ b/src/cli/util/analyze-globs.ts @@ -1,6 +1,6 @@ import fastGlob from "fast-glob"; import { existsSync, lstatSync } from "fs"; -import { Diagnostic, flattenDiagnosticMessageText, Program, SourceFile } from "typescript"; +import { Program, SourceFile } from "typescript"; import { analyzeSourceFile } from "../../analyze/analyze-source-file"; import { AnalyzerResult } from "../../analyze/types/analyzer-result"; import { arrayFlat } from "../../util/array-util"; @@ -16,7 +16,6 @@ const DEFAULT_GLOBS = [DEFAULT_DIR_GLOB]; export interface AnalyzeGlobsContext { didExpandGlobs?(filePaths: string[]): void; willAnalyzeFiles?(filePaths: string[]): void; - didFindTypescriptDiagnostics?(diagnostics: ReadonlyArray, options: { program: Program }): void; emitAnalyzedFile?(file: SourceFile, result: AnalyzerResult, options: { program: Program }): Promise | void; } @@ -46,16 +45,7 @@ export async function analyzeGlobs( context.willAnalyzeFiles?.(filePaths); // Parse all the files with typescript - const { program, files, diagnostics } = compileTypescript(filePaths); - - if (diagnostics.length > 0) { - logVerbose( - () => diagnostics.map(d => `${(d.file && d.file.fileName) || "unknown"}: ${flattenDiagnosticMessageText(d.messageText, "\n")}`), - config - ); - - context.didFindTypescriptDiagnostics?.(diagnostics, { program }); - } + const { program, files } = compileTypescript(filePaths); // Analyze each file with web component analyzer const results: AnalyzerResult[] = []; @@ -82,7 +72,7 @@ export async function analyzeGlobs( results.push(result); } - return { program, diagnostics, files, results }; + return { program, files, results }; } /** diff --git a/src/cli/util/compile.ts b/src/cli/util/compile.ts index 777c3695..2c9e0b58 100644 --- a/src/cli/util/compile.ts +++ b/src/cli/util/compile.ts @@ -1,14 +1,4 @@ -import { - CompilerOptions, - createProgram, - Diagnostic, - getPreEmitDiagnostics, - ModuleKind, - ModuleResolutionKind, - Program, - ScriptTarget, - SourceFile -} from "typescript"; +import { CompilerOptions, createProgram, ModuleKind, ModuleResolutionKind, Program, ScriptTarget, SourceFile } from "typescript"; /** * The most general version of compiler options. @@ -16,12 +6,13 @@ import { const defaultOptions: CompilerOptions = { noEmitOnError: false, allowJs: true, + maxNodeModuleJsDepth: 3, experimentalDecorators: true, target: ScriptTarget.Latest, downlevelIteration: true, module: ModuleKind.ESNext, //module: ModuleKind.CommonJS, - //lib: ["esnext", "dom"], + //lib: ["ESNext", "DOM", "DOM.Iterable"], strictNullChecks: true, moduleResolution: ModuleResolutionKind.NodeJs, esModuleInterop: true, @@ -34,7 +25,6 @@ const defaultOptions: CompilerOptions = { }; export interface CompileResult { - diagnostics: ReadonlyArray; program: Program; files: SourceFile[]; } @@ -47,7 +37,6 @@ export interface CompileResult { export function compileTypescript(filePaths: string | string[], options: CompilerOptions = defaultOptions): CompileResult { filePaths = Array.isArray(filePaths) ? filePaths : [filePaths]; const program = createProgram(filePaths, options); - const diagnostics = getPreEmitDiagnostics(program); const files = program.getSourceFiles().filter(sf => filePaths.includes(sf.fileName)); - return { diagnostics, program, files }; + return { program, files }; } diff --git a/src/transformers/json2/json2-transformer.ts b/src/transformers/json2/json2-transformer.ts index a16f759e..4c94c094 100644 --- a/src/transformers/json2/json2-transformer.ts +++ b/src/transformers/json2/json2-transformer.ts @@ -1,4 +1,5 @@ import { basename, relative } from "path"; +import { isSimpleType, toSimpleType } from "ts-simple-type"; import * as tsModule from "typescript"; import { Node, Program, SourceFile, Type, TypeChecker } from "typescript"; import { AnalyzerResult } from "../../analyze/types/analyzer-result"; @@ -261,14 +262,21 @@ function getExportsDocFromDeclaration( * @param context */ function getEventDocsFromDeclaration(declaration: ComponentDeclaration, context: TransformerContext): EventDoc[] { - return filterVisibility(context.config.visibility, declaration.events).map(event => ({ - description: event.jsDoc?.description, - name: event.name, - detailType: getTypeHintFromType(event.typeHint || event.type?.(), context.checker, context.config), - type: "Event", - inheritedFrom: getInheritedFromReference(declaration, event, context) - // TODO: missing "type" - })); + return filterVisibility(context.config.visibility, declaration.events).map(event => { + const type = event.type?.() || { kind: "ANY" }; + const simpleType = isSimpleType(type) ? type : toSimpleType(type, context.checker); + + const typeName = simpleType.kind === "GENERIC_ARGUMENTS" ? simpleType.target.name : simpleType.name; + const customEventDetailType = typeName === "CustomEvent" && simpleType.kind === "GENERIC_ARGUMENTS" ? simpleType.typeArguments[0] : undefined; + + return { + description: event.jsDoc?.description, + name: event.name, + inheritedFrom: getInheritedFromReference(declaration, event, context), + type: typeName == null || simpleType.kind === "ANY" ? "Event" : typeName, + detailType: customEventDetailType != null ? getTypeHintFromType(customEventDetailType, context.checker, context.config) : undefined + }; + }); } /** diff --git a/src/transformers/markdown/markdown-transformer.ts b/src/transformers/markdown/markdown-transformer.ts index e2a054ce..d6cc6206 100644 --- a/src/transformers/markdown/markdown-transformer.ts +++ b/src/transformers/markdown/markdown-transformer.ts @@ -191,7 +191,8 @@ function methodSection(methods: ComponentMethod[], checker: TypeChecker, config: */ function eventSection(events: ComponentEvent[], checker: TypeChecker, config: TransformerConfig): string { const showVisibility = shouldShowVisibility(events, config); - const rows: string[][] = [["Event", "Visibility", "Detail", "Description"]]; + const rows: string[][] = [["Event", "Visibility", "Type", "Description"]]; + rows.push( ...events.map(event => [ (event.name && markdownHighlight(event.name)) || "", diff --git a/src/util/type-util.ts b/src/util/type-util.ts new file mode 100644 index 00000000..751a0788 --- /dev/null +++ b/src/util/type-util.ts @@ -0,0 +1,55 @@ +import { SimpleType, toSimpleType } from "ts-simple-type"; +import * as tsModule from "typescript"; +import { Node, Program } from "typescript"; + +// Only search in "lib.dom.d.ts" performance reasons for now +const LIB_FILE_NAMES = ["lib.dom.d.ts"]; + +const LIB_TYPE_CACHE = new Map(); + +/** + * Return a Typescript library type with a specific name + * @param name + * @param ts + * @param program + */ +export function getLibTypeWithName(name: string, { ts, program }: { program: Program; ts: typeof tsModule }): SimpleType | undefined { + if (LIB_TYPE_CACHE.has(name)) { + return LIB_TYPE_CACHE.get(name); + } + + let node: Node | undefined; + + for (const libFileName of LIB_FILE_NAMES) { + const sourceFile = program.getSourceFile(libFileName); + if (sourceFile == null) { + continue; + } + + for (const statement of sourceFile.statements) { + if (ts.isInterfaceDeclaration(statement) && statement.name?.text === name) { + node = statement; + break; + } + } + + if (node != null) { + break; + } + } + + const checker = program.getTypeChecker(); + let type = node == null ? undefined : toSimpleType(node, checker); + + if (type != null) { + // Apparently Typescript wraps the type in "generic arguments" when take the type from the interface declaration + // Remove "generic arguments" here + if (type.kind === "GENERIC_ARGUMENTS") { + type = type.target; + } + } + + LIB_TYPE_CACHE.set(name, type); + + return type; +} diff --git a/test/flavors/custom-element/event-test.ts b/test/flavors/custom-element/event-test.ts new file mode 100644 index 00000000..feefc212 --- /dev/null +++ b/test/flavors/custom-element/event-test.ts @@ -0,0 +1,51 @@ +import { isAssignableToType, SimpleType, typeToString } from "ts-simple-type"; +import { getLibTypeWithName } from "../../../src/util/type-util"; +import { analyzeTextWithCurrentTsModule } from "../../helpers/analyze-text-with-current-ts-module"; +import { getCurrentTsModule, tsTest } from "../../helpers/ts-test"; + +tsTest("Correctly discovers dispatched events and corresponding event types", t => { + // Higher timeout because we need to include lib types + t.timeout(1000 * 60 * 5); + + const { + results: [result], + program + } = analyzeTextWithCurrentTsModule({ + includeLib: true, + fileName: "test.d.ts", + text: ` + class MyElement extends HTMLElement { + myMethod() { + this.dispatchEvent(new CustomEvent("my-event")); + this.dispatchEvent(new CustomEvent("my-event-2", {detail: "foobar"})); + this.dispatchEvent(new CustomEvent("my-event-3")); + this.dispatchEvent(new MouseEvent("my-event-4")); + this.dispatchEvent(new Event("my-event-5")); + } + } + customElements.define("my-element", MyElement); + ` + }); + + const { events } = result.componentDefinitions[0].declaration!; + + const assertEvent = (name: string, typeName: string, type: SimpleType) => { + const event = events.find(e => e.name === name); + if (event == null) { + t.fail(`Couldn't find event with name: ${name}`); + return; + } + + //console.log(event.type()); + t.is(typeToString(event.type!() as SimpleType, program.getTypeChecker()), typeName); + t.truthy(isAssignableToType(type, event.type!(), program)); + }; + + t.is(events.length, 5); + + assertEvent("my-event", "CustomEvent", getLibTypeWithName("CustomEvent", { ts: getCurrentTsModule(), program: program })!); + assertEvent("my-event-2", "CustomEvent", getLibTypeWithName("CustomEvent", { ts: getCurrentTsModule(), program: program })!); + assertEvent("my-event-3", "CustomEvent", getLibTypeWithName("CustomEvent", { ts: getCurrentTsModule(), program: program })!); + assertEvent("my-event-4", "MouseEvent", getLibTypeWithName("MouseEvent", { ts: getCurrentTsModule(), program: program })!); + assertEvent("my-event-5", "Event", getLibTypeWithName("Event", { ts: getCurrentTsModule(), program: program })!); +}); diff --git a/test/flavors/jsdoc/event-test.ts b/test/flavors/jsdoc/event-test.ts index f5243024..b8fbd9e1 100644 --- a/test/flavors/jsdoc/event-test.ts +++ b/test/flavors/jsdoc/event-test.ts @@ -1,6 +1,7 @@ -import { isAssignableToSimpleTypeKind, SimpleType } from "ts-simple-type"; +import { isAssignableToSimpleTypeKind, isAssignableToType, SimpleType, typeToString } from "ts-simple-type"; +import { getLibTypeWithName } from "../../../src/util/type-util"; import { analyzeTextWithCurrentTsModule } from "../../helpers/analyze-text-with-current-ts-module"; -import { tsTest } from "../../helpers/ts-test"; +import { getCurrentTsModule, tsTest } from "../../helpers/ts-test"; tsTest("jsdoc: Discovers custom events with @fires", t => { const { @@ -19,10 +20,9 @@ tsTest("jsdoc: Discovers custom events with @fires", t => { t.is(events.length, 1); t.is(events[0].name, "my-event"); t.is(events[0].jsDoc?.description, "This is a comment"); - t.truthy(isAssignableToSimpleTypeKind(events[0].type() as SimpleType, "ANY")); }); -tsTest.only("jsdoc: Discovers the detail type of custom events with @fires", t => { +tsTest("jsdoc: Discovers the detail type of custom events with @fires", t => { const { results: [result] } = analyzeTextWithCurrentTsModule(` @@ -38,8 +38,8 @@ tsTest.only("jsdoc: Discovers the detail type of custom events with @fires", t = const { events } = result.componentDefinitions[0].declaration!; const myEvent = events.find(e => e.name === "my-event")!; const mySecondEvent = events.find(e => e.name === "my-second-event")!; - t.truthy(isAssignableToSimpleTypeKind(myEvent.type() as SimpleType, "STRING")); - t.truthy(isAssignableToSimpleTypeKind(mySecondEvent.type() as SimpleType, "NUMBER")); + t.truthy(isAssignableToSimpleTypeKind(myEvent.type!() as SimpleType, "STRING")); + t.truthy(isAssignableToSimpleTypeKind(mySecondEvent.type!() as SimpleType, "NUMBER")); }); tsTest("jsdoc: Discovers events declared with @fires that includes extra jsdoc information", t => { @@ -59,5 +59,46 @@ tsTest("jsdoc: Discovers events declared with @fires that includes extra jsdoc i t.is(events.length, 1); t.is(events[0].name, "input-switch-check-changed"); t.is(events[0].jsDoc?.description, "Fires when check property changes"); - t.truthy(isAssignableToSimpleTypeKind(events[0].type() as SimpleType, "ANY")); +}); + +tsTest("jsdoc: Discovers and correctly parses event types", t => { + const { + results: [result], + program + } = analyzeTextWithCurrentTsModule({ + includeLib: true, + fileName: "file.ts", + text: ` + /** + * @element + * @fires {MouseEvent} mouse-move + * @fires {CustomEvent} custom-event-1 + * @fires {CustomEvent} custom-event-2 + * @fires {Event} my-event + */ + class MyElement extends HTMLElement { + } + ` + }); + + const { events } = result.componentDefinitions[0].declaration!; + + const assertEvent = (name: string, typeName: string, type: SimpleType) => { + const event = events.find(e => e.name === name); + if (event == null) { + t.fail(`Couldn't find event with name: ${name}`); + return; + } + + //console.log(event.type()); + t.is(typeToString(event.type!() as SimpleType, program.getTypeChecker()), typeName); + t.truthy(isAssignableToType(type, event.type!(), program)); + }; + + t.is(events.length, 4); + + assertEvent("mouse-move", "MouseEvent", getLibTypeWithName("MouseEvent", { ts: getCurrentTsModule(), program: program })!); + assertEvent("custom-event-1", "CustomEvent", getLibTypeWithName("CustomEvent", { ts: getCurrentTsModule(), program: program })!); + assertEvent("custom-event-2", "CustomEvent", getLibTypeWithName("CustomEvent", { ts: getCurrentTsModule(), program: program })!); + assertEvent("my-event", "Event", getLibTypeWithName("Event", { ts: getCurrentTsModule(), program: program })!); });