diff --git a/.changeset/nice-rockets-smile.md b/.changeset/nice-rockets-smile.md new file mode 100644 index 0000000000..f147a729c4 --- /dev/null +++ b/.changeset/nice-rockets-smile.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/analyzer': minor +--- + +Added support for analyzing function declarations. diff --git a/packages/labs/analyzer/src/index.ts b/packages/labs/analyzer/src/index.ts index 932e9f93ca..32c29f7963 100644 --- a/packages/labs/analyzer/src/index.ts +++ b/packages/labs/analyzer/src/index.ts @@ -26,6 +26,7 @@ export type { PackageJson, ModuleWithLitElementDeclarations, DeprecatableDescribed, + FunctionDeclaration, } from './lib/model.js'; export type {AbsolutePath, PackagePath} from './lib/paths.js'; diff --git a/packages/labs/analyzer/src/lib/javascript/functions.ts b/packages/labs/analyzer/src/lib/javascript/functions.ts index d4dfdba756..fc14a7ac99 100644 --- a/packages/labs/analyzer/src/lib/javascript/functions.ts +++ b/packages/labs/analyzer/src/lib/javascript/functions.ts @@ -12,9 +12,58 @@ import ts from 'typescript'; import {DiagnosticsError} from '../errors.js'; -import {AnalyzerInterface, Parameter, Return} from '../model.js'; +import { + AnalyzerInterface, + DeclarationInfo, + FunctionDeclaration, + Parameter, + Return, +} from '../model.js'; import {getTypeForNode, getTypeForType} from '../types.js'; -import {parseJSDocDescription} from './jsdoc.js'; +import {parseJSDocDescription, parseNodeJSDocInfo} from './jsdoc.js'; +import {hasDefaultModifier, hasExportModifier} from '../utils.js'; + +/** + * Returns the name of a function declaration. + */ +const getFunctionDeclarationName = (declaration: ts.FunctionDeclaration) => { + const name = + declaration.name?.text ?? + // The only time a function declaration will not have a name is when it is + // a default export, aka `export default function() {...}` + (hasDefaultModifier(declaration) ? 'default' : undefined); + if (name === undefined) { + throw new DiagnosticsError( + declaration, + 'Unexpected function declaration without a name' + ); + } + return name; +}; + +export const getFunctionDeclarationInfo = ( + declaration: ts.FunctionDeclaration, + analyzer: AnalyzerInterface +): DeclarationInfo => { + const name = getFunctionDeclarationName(declaration); + return { + name, + factory: () => getFunctionDeclaration(declaration, name, analyzer), + isExport: hasExportModifier(declaration), + }; +}; + +const getFunctionDeclaration = ( + declaration: ts.FunctionLikeDeclaration, + name: string, + analyzer: AnalyzerInterface +): FunctionDeclaration => { + return new FunctionDeclaration({ + name, + ...parseNodeJSDocInfo(declaration), + ...getFunctionLikeInfo(declaration, analyzer), + }); +}; /** * Returns information on FunctionLike nodes diff --git a/packages/labs/analyzer/src/lib/javascript/modules.ts b/packages/labs/analyzer/src/lib/javascript/modules.ts index b42b70882f..c8b56fc410 100644 --- a/packages/labs/analyzer/src/lib/javascript/modules.ts +++ b/packages/labs/analyzer/src/lib/javascript/modules.ts @@ -37,6 +37,7 @@ import { getSpecifierString, } from '../references.js'; import {parseModuleJSDocInfo} from './jsdoc.js'; +import {getFunctionDeclarationInfo} from './functions.js'; /** * Returns the sourcePath, jsPath, and package.json contents of the containing @@ -116,6 +117,8 @@ export const getModule = ( for (const statement of sourceFile.statements) { if (ts.isClassDeclaration(statement)) { addDeclaration(getClassDeclarationInfo(statement, analyzer)); + } else if (ts.isFunctionDeclaration(statement)) { + addDeclaration(getFunctionDeclarationInfo(statement, analyzer)); } else if (ts.isVariableStatement(statement)) { getVariableDeclarationInfo(statement, analyzer).forEach(addDeclaration); } else if (ts.isEnumDeclaration(statement)) { diff --git a/packages/labs/analyzer/src/lib/model.ts b/packages/labs/analyzer/src/lib/model.ts index 95230e70bd..642a9dc2ce 100644 --- a/packages/labs/analyzer/src/lib/model.ts +++ b/packages/labs/analyzer/src/lib/model.ts @@ -355,21 +355,17 @@ export interface ClassMethodInit extends FunctionLikeInit { source?: SourceReference | undefined; } -export class ClassMethod extends Declaration { +export class ClassMethod extends FunctionDeclaration { static?: boolean | undefined; privacy?: Privacy | undefined; inheritedFrom?: Reference | undefined; source?: SourceReference | undefined; - parameters?: Parameter[] | undefined; - return?: Return | undefined; constructor(init: ClassMethodInit) { super(init); this.static = init.static; this.privacy = init.privacy; this.inheritedFrom = init.inheritedFrom; this.source = init.source; - this.parameters = init.parameters; - this.return = init.return; } } diff --git a/packages/labs/analyzer/src/test/javascript/functions_test.ts b/packages/labs/analyzer/src/test/javascript/functions_test.ts new file mode 100644 index 0000000000..fc76281523 --- /dev/null +++ b/packages/labs/analyzer/src/test/javascript/functions_test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {suite} from 'uvu'; +// eslint-disable-next-line import/extensions +import * as assert from 'uvu/assert'; +import {fileURLToPath} from 'url'; +import {getSourceFilename, languages} from '../utils.js'; + +import { + createPackageAnalyzer, + Analyzer, + AbsolutePath, + Module, +} from '../../index.js'; + +for (const lang of languages) { + const test = suite<{ + analyzer: Analyzer; + packagePath: AbsolutePath; + module: Module; + }>(`Module tests (${lang})`); + + test.before((ctx) => { + try { + const packagePath = fileURLToPath( + new URL(`../../test-files/${lang}/functions`, import.meta.url).href + ) as AbsolutePath; + const analyzer = createPackageAnalyzer(packagePath); + + const result = analyzer.getPackage(); + const file = getSourceFilename('functions', lang); + const module = result.modules.find((m) => m.sourcePath === file); + if (module === undefined) { + throw new Error(`Analyzer did not analyze file '${file}'`); + } + + ctx.packagePath = packagePath; + ctx.analyzer = analyzer; + ctx.module = module; + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu before error', error); + process.exit(1); + } + }); + + test('Function 1', ({module}) => { + const exportedFn = module.getResolvedExport('function1'); + const fn = module.getDeclaration('function1'); + assert.equal(fn, exportedFn); + assert.ok(fn?.isFunctionDeclaration()); + assert.equal(fn.name, `function1`); + assert.equal(fn.description, `Function 1 description`); + assert.equal(fn.summary, undefined); + assert.equal(fn.parameters?.length, 0); + assert.equal(fn.deprecated, undefined); + }); + + test('Function 2', ({module}) => { + const exportedFn = module.getResolvedExport('function2'); + const fn = module.getDeclaration('function2'); + assert.equal(fn, exportedFn); + assert.ok(fn?.isFunctionDeclaration()); + assert.equal(fn.name, `function2`); + assert.equal(fn.summary, `Function 2 summary\nwith wraparound`); + assert.equal(fn.description, `Function 2 description\nwith wraparound`); + assert.equal(fn.parameters?.length, 3); + assert.equal(fn.parameters?.[0].name, 'a'); + assert.equal(fn.parameters?.[0].description, 'Param a description'); + assert.equal(fn.parameters?.[0].summary, undefined); + assert.equal(fn.parameters?.[0].type?.text, 'string'); + assert.equal(fn.parameters?.[0].default, undefined); + assert.equal(fn.parameters?.[0].rest, false); + assert.equal(fn.parameters?.[1].name, 'b'); + assert.equal( + fn.parameters?.[1].description, + 'Param b description\nwith wraparound' + ); + assert.equal(fn.parameters?.[1].type?.text, 'boolean'); + assert.equal(fn.parameters?.[1].optional, true); + assert.equal(fn.parameters?.[1].default, 'false'); + assert.equal(fn.parameters?.[1].rest, false); + assert.equal(fn.parameters?.[2].name, 'c'); + assert.equal(fn.parameters?.[2].description, 'Param c description'); + assert.equal(fn.parameters?.[2].summary, undefined); + assert.equal(fn.parameters?.[2].type?.text, 'number[]'); + assert.equal(fn.parameters?.[2].optional, false); + assert.equal(fn.parameters?.[2].default, undefined); + assert.equal(fn.parameters?.[2].rest, true); + assert.equal(fn.return?.type?.text, 'string'); + assert.equal(fn.return?.description, 'Function 2 return description'); + assert.equal(fn.deprecated, 'Function 2 deprecated'); + }); + + test('Default function', ({module}) => { + const exportedFn = module.getResolvedExport('default'); + const fn = module.getDeclaration('default'); + assert.equal(fn, exportedFn); + assert.ok(fn?.isFunctionDeclaration()); + assert.equal(fn.name, `default`); + assert.equal(fn.description, `Default function description`); + assert.equal(fn.summary, undefined); + assert.equal(fn.parameters?.length, 0); + assert.equal(fn.return?.type?.text, 'string'); + assert.equal(fn.return?.description, 'Default function return description'); + assert.equal(fn.deprecated, undefined); + }); + + test.run(); +} diff --git a/packages/labs/analyzer/test-files/js/functions/functions.js b/packages/labs/analyzer/test-files/js/functions/functions.js new file mode 100644 index 0000000000..35af9c1249 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/functions/functions.js @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Function 1 description + */ +export function function1() {} + +/** + * @summary Function 2 summary + * with wraparound + * + * @description Function 2 description + * with wraparound + * + * @param {string} a Param a description + * @param {boolean} b Param b description + * with wraparound + * + * @param {number[]} c Param c description + * @returns {string} Function 2 return description + * + * @deprecated Function 2 deprecated + */ +export function function2(a, b = false, ...c) { + return b ? a : c[0].toFixed(); +} + +/** + * Default function description + * @returns {string} Default function return description + */ +export default function () { + return 'default'; +} diff --git a/packages/labs/analyzer/test-files/js/functions/package.json b/packages/labs/analyzer/test-files/js/functions/package.json new file mode 100644 index 0000000000..d5efd0f5f6 --- /dev/null +++ b/packages/labs/analyzer/test-files/js/functions/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-functions", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/ts/functions/package.json b/packages/labs/analyzer/test-files/ts/functions/package.json new file mode 100644 index 0000000000..d5efd0f5f6 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/functions/package.json @@ -0,0 +1,6 @@ +{ + "name": "@lit-internal/test-functions", + "dependencies": { + "lit": "^2.0.0" + } +} diff --git a/packages/labs/analyzer/test-files/ts/functions/src/functions.ts b/packages/labs/analyzer/test-files/ts/functions/src/functions.ts new file mode 100644 index 0000000000..0c1dbeaa27 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/functions/src/functions.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +/** + * Function 1 description + */ +export function function1() {} + +/** + * @summary Function 2 summary + * with wraparound + * + * @description Function 2 description + * with wraparound + * + * @param a Param a description + * @param b Param b description + * with wraparound + * + * @param c Param c description + * @returns Function 2 return description + * + * @deprecated Function 2 deprecated + */ +export function function2(a: string, b = false, ...c: number[]) { + return b ? a : c[0].toFixed(); +} + +/** + * Default function description + * @returns Default function return description + */ +export default function () { + return 'default'; +} diff --git a/packages/labs/analyzer/test-files/ts/functions/tsconfig.json b/packages/labs/analyzer/test-files/ts/functions/tsconfig.json new file mode 100644 index 0000000000..204701fc83 --- /dev/null +++ b/packages/labs/analyzer/test-files/ts/functions/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2020", + "lib": ["es2020", "DOM"], + "module": "ES2020", + "rootDir": "./src", + "outDir": "./", + "moduleResolution": "node", + "experimentalDecorators": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json b/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json index efeca49673..4830406710 100644 --- a/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json +++ b/packages/labs/gen-manifest/goldens/test-element-a/custom-elements.json @@ -733,11 +733,68 @@ } } ] + }, + { + "kind": "function", + "name": "function1", + "description": "Function 1 description", + "return": {"type": {"text": "void"}} + }, + { + "kind": "function", + "name": "function2", + "description": "Function 2 description\nwith wraparound", + "summary": "Function 2 summary\nwith wraparound", + "deprecated": "Function 2 deprecated", + "parameters": [ + { + "name": "a", + "description": "Param a description", + "type": {"text": "string"} + }, + { + "name": "b", + "description": "Param b description\nwith wraparound", + "type": {"text": "boolean"}, + "default": "false", + "optional": true + }, + { + "name": "c", + "description": "Param c description", + "type": {"text": "number[]"}, + "rest": true + } + ], + "return": { + "type": {"text": "string"}, + "description": "Function 2 return description" + } + }, + { + "kind": "function", + "name": "default", + "description": "Default function description", + "return": { + "type": {"text": "string"}, + "description": "Default function return description" + } } ], "exports": [ {"kind": "js", "name": "Bar", "declaration": {"name": "Bar"}}, - {"kind": "js", "name": "Foo", "declaration": {"name": "Foo"}} + {"kind": "js", "name": "Foo", "declaration": {"name": "Foo"}}, + { + "kind": "js", + "name": "function1", + "declaration": {"name": "function1"} + }, + { + "kind": "js", + "name": "function2", + "declaration": {"name": "function2"} + }, + {"kind": "js", "name": "default", "declaration": {"name": "default"}} ] }, { diff --git a/packages/labs/gen-manifest/src/index.ts b/packages/labs/gen-manifest/src/index.ts index 2109c8c349..ff53cd69f0 100644 --- a/packages/labs/gen-manifest/src/index.ts +++ b/packages/labs/gen-manifest/src/index.ts @@ -20,6 +20,7 @@ import { Parameter, Return, DeprecatableDescribed, + FunctionDeclaration, } from '@lit-labs/analyzer'; import {FileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; import type * as cem from 'custom-elements-manifest/schema'; @@ -128,8 +129,9 @@ const convertDeclaration = (declaration: Declaration): cem.Declaration => { return convertClassDeclaration(declaration); } else if (declaration.isVariableDeclaration()) { return convertVariableDeclaration(declaration); + } else if (declaration.isFunctionDeclaration()) { + return convertFunctionDeclaration(declaration); } else { - // TODO: FunctionDeclaration // TODO: MixinDeclaration // TODO: CustomElementMixinDeclaration; throw new Error( @@ -215,7 +217,17 @@ const convertCommonMemberInfo = (member: ClassField | ClassMethod) => { }; }; -const convertCommonFunctionLikeInfo = (functionLike: ClassMethod) => { +const convertFunctionDeclaration = ( + declaration: FunctionDeclaration +): cem.FunctionDeclaration => { + return { + kind: 'function', + ...convertCommonDeclarationInfo(declaration), + ...convertCommonFunctionLikeInfo(declaration), + }; +}; + +const convertCommonFunctionLikeInfo = (functionLike: FunctionDeclaration) => { return { parameters: transformIfNotEmpty(functionLike.parameters, (p) => p.map(convertParameter) diff --git a/packages/labs/test-projects/test-element-a/src/package-stuff.ts b/packages/labs/test-projects/test-element-a/src/package-stuff.ts index 5832074814..6ac9e694a0 100644 --- a/packages/labs/test-projects/test-element-a/src/package-stuff.ts +++ b/packages/labs/test-projects/test-element-a/src/package-stuff.ts @@ -15,3 +15,36 @@ export class Bar implements BarInterface { export class Foo { bar?: T; } + +/** + * Function 1 description + */ +export function function1() {} + +/** + * @summary Function 2 summary + * with wraparound + * + * @description Function 2 description + * with wraparound + * + * @param a Param a description + * @param b Param b description + * with wraparound + * + * @param c Param c description + * @returns Function 2 return description + * + * @deprecated Function 2 deprecated + */ +export function function2(a: string, b = false, ...c: number[]) { + return b ? a : c[0].toFixed(); +} + +/** + * Default function description + * @returns Default function return description + */ +export default function () { + return 'default'; +}