From b6a40eae2032f574b233bf90a6394a532a1d92f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Thu, 7 Sep 2023 09:44:48 -0400 Subject: [PATCH] feat: add collectVariableUsage API (#274) ## PR Checklist - [x] Addresses an existing open issue: fixes #263 - [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/ts-api-utils/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22) - [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/ts-api-utils/blob/main/.github/CONTRIBUTING.md) were taken ## Overview Directly ports the exported public `collectVariableUsage` function from `tsutils`. Adds in some unit test coverage too. I'd wanted to refactor its source to be less reliant on class hierarchies... but there's a lot going on there. Out of scope for now. I did apply a few touchups: * Changed "soft" privates to use `#`. * Renamed `VariableInfo` to `UsageInfo`. * It's used both for types and values, so referring to them as "variables" was confusing to me. * Please shout at me if you can think of a better name or believe the original name to be better! * Switches the `const enum`s to just `enum`s. * Adds test coverage for the most common cases. * It's not super thorough, so if you spot some logic you think should be tested, shout at me and I'm happy to add it in if I can --- .eslintrc.cjs | 5 + cspell.json | 2 + package.json | 1 + pnpm-lock.yaml | 23 ++ src/index.ts | 1 + src/modifiers.ts | 4 +- src/test/utils.ts | 15 +- src/usage/Scope.ts | 72 ++++ src/usage/UsageWalker.ts | 440 +++++++++++++++++++++ src/usage/collectVariableUsage.test.ts | 524 +++++++++++++++++++++++++ src/usage/collectVariableUsage.ts | 28 ++ src/usage/declarations.ts | 68 ++++ src/usage/getPropertyName.ts | 44 +++ src/usage/getUsageDomain.ts | 106 +++++ src/usage/index.ts | 5 + src/usage/scopes.ts | 463 ++++++++++++++++++++++ src/usage/usage.ts | 64 +++ src/usage/utils.ts | 37 ++ 18 files changed, 1895 insertions(+), 7 deletions(-) create mode 100644 src/usage/Scope.ts create mode 100644 src/usage/UsageWalker.ts create mode 100644 src/usage/collectVariableUsage.test.ts create mode 100644 src/usage/collectVariableUsage.ts create mode 100644 src/usage/declarations.ts create mode 100644 src/usage/getPropertyName.ts create mode 100644 src/usage/getUsageDomain.ts create mode 100644 src/usage/index.ts create mode 100644 src/usage/scopes.ts create mode 100644 src/usage/usage.ts create mode 100644 src/usage/utils.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6c76a7ec..6982301d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -32,6 +32,7 @@ module.exports = { "@typescript-eslint/explicit-module-boundary-types": "error", // TODO? + "@typescript-eslint/prefer-literal-enum-member": "off", "@typescript-eslint/no-confusing-void-expression": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-unnecessary-condition": "off", @@ -72,6 +73,10 @@ module.exports = { root: true, rules: { // These off-by-default rules work well for this repo and we like them on. + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", caughtErrors: "all" }, + ], "import/extensions": ["error"], "import/no-useless-path-segments": [ "error", diff --git a/cspell.json b/cspell.json index f902b88d..7bc20fd1 100644 --- a/cspell.json +++ b/cspell.json @@ -24,7 +24,9 @@ "konamimojisplosion", "lcov", "packagejson", + "phenomnomnominal", "quickstart", + "tsquery", "tsup", "tsutils", "tsvfs", diff --git a/package.json b/package.json index d8b7bcf6..c61e3680 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "*": "prettier --ignore-unknown --write" }, "devDependencies": { + "@phenomnomnominal/tsquery": "^6.1.3", "@typescript-eslint/eslint-plugin": "^6.4.0", "@typescript-eslint/parser": "6.5.0", "@typescript/vfs": "^1.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ad23c9b..5667ed23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false devDependencies: + '@phenomnomnominal/tsquery': + specifier: ^6.1.3 + version: 6.1.3(typescript@5.2.2) '@typescript-eslint/eslint-plugin': specifier: ^6.4.0 version: 6.4.0(@typescript-eslint/parser@6.5.0)(eslint@8.47.0)(typescript@5.2.2) @@ -1342,6 +1345,16 @@ packages: '@octokit/openapi-types': 18.0.0 dev: true + /@phenomnomnominal/tsquery@6.1.3(typescript@5.2.2): + resolution: {integrity: sha512-CEqpJ872StsxRmwv9ePCZ4BCisrJSlREUC5XxIRYxhvODt4aQoJFFmjTgaP6meyKiiXxxN/VWPZ58j4yHXRkmw==} + peerDependencies: + typescript: ^3 || ^4 || ^5 + dependencies: + '@types/esquery': 1.5.0 + esquery: 1.5.0 + typescript: 5.2.2 + dev: true + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1410,6 +1423,16 @@ packages: resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==} dev: true + /@types/esquery@1.5.0: + resolution: {integrity: sha512-MNQ5gCt3j1idWHlj/dEF+WPS1kl6Woe0Agzwy96JvrwDQdDadqeIBhY7mUca51CCUzxf7BsnXzcyKi6ENpEtmQ==} + dependencies: + '@types/estree': 1.0.1 + dev: true + + /@types/estree@1.0.1: + resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + dev: true + /@types/http-cache-semantics@4.0.1: resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==} dev: true diff --git a/src/index.ts b/src/index.ts index 8e2644c9..ca1f951b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,3 +7,4 @@ export * from "./scopes"; export * from "./syntax"; export * from "./tokens"; export * from "./types"; +export * from "./usage"; diff --git a/src/modifiers.ts b/src/modifiers.ts index 6eb48945..2d7482df 100644 --- a/src/modifiers.ts +++ b/src/modifiers.ts @@ -15,11 +15,11 @@ import ts from "typescript"; * ``` */ export function includesModifier( - modifiers: Iterable | undefined, + modifiers: Iterable | undefined, ...kinds: ts.ModifierSyntaxKind[] ): boolean { if (modifiers === undefined) return false; for (const modifier of modifiers) - if (kinds.includes(modifier.kind)) return true; + if (kinds.includes(modifier.kind as ts.ModifierSyntaxKind)) return true; return false; } diff --git a/src/test/utils.ts b/src/test/utils.ts index 22ee5cb5..e40869b4 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -4,11 +4,7 @@ import ts from "typescript"; export function createNodeAndSourceFile( sourceText: string, ): { node: Node; sourceFile: ts.SourceFile } { - const sourceFile = ts.createSourceFile( - "file.tsx", - sourceText, - ts.ScriptTarget.ESNext, - ); + const sourceFile = createSourceFile(sourceText); const statement = sourceFile.statements.at(-1)!; const node = (ts.isExpressionStatement(statement) @@ -18,6 +14,15 @@ export function createNodeAndSourceFile( return { node, sourceFile }; } +export function createSourceFile(sourceText: string): ts.SourceFile { + return ts.createSourceFile( + "file.tsx", + sourceText, + ts.ScriptTarget.ESNext, + true, + ); +} + export function createNode( nodeOrSourceText: Node | string, ): Node { diff --git a/src/usage/Scope.ts b/src/usage/Scope.ts new file mode 100644 index 00000000..931db929 --- /dev/null +++ b/src/usage/Scope.ts @@ -0,0 +1,72 @@ +// Code largely based on https://github.com/ajafff/tsutils +// Original license: https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import ts from "typescript"; + +import { isFunctionScopeBoundary } from "../scopes"; +import { DeclarationDomain } from "./declarations"; +import type { EnumScope, NamespaceScope } from "./scopes"; +import { InternalUsageInfo, Usage, UsageInfoCallback } from "./usage"; + +export enum ScopeBoundary { + None = 0, + Function = 1, + Block = 2, + Type = 4, + ConditionalType = 8, +} + +export enum ScopeBoundarySelector { + Function = ScopeBoundary.Function, + Block = ScopeBoundarySelector.Function | ScopeBoundary.Block, + Type = ScopeBoundarySelector.Block | ScopeBoundary.Type, + InferType = ScopeBoundary.ConditionalType, +} + +export interface Scope { + addUse(use: Usage, scope?: Scope): void; + addVariable( + identifier: string, + name: ts.PropertyName, + selector: ScopeBoundarySelector, + exported: boolean, + domain: DeclarationDomain, + ): void; + createOrReuseEnumScope(name: string, exported: boolean): EnumScope; + createOrReuseNamespaceScope( + name: string, + exported: boolean, + ambient: boolean, + hasExportStatement: boolean, + ): NamespaceScope; + end(cb: UsageInfoCallback): void; + getDestinationScope(selector: ScopeBoundarySelector): Scope; + getFunctionScope(): Scope; + getVariables(): Map; + markExported(name: ts.Identifier, as?: ts.Identifier): void; +} + +export function isBlockScopeBoundary(node: ts.Node): ScopeBoundary { + switch (node.kind) { + case ts.SyntaxKind.Block: { + const parent = node.parent; + return parent.kind !== ts.SyntaxKind.CatchClause && + // blocks inside SourceFile are block scope boundaries + (parent.kind === ts.SyntaxKind.SourceFile || + // blocks that are direct children of a function scope boundary are no scope boundary + // for example the FunctionBlock is part of the function scope of the containing function + !isFunctionScopeBoundary(parent)) + ? ScopeBoundary.Block + : ScopeBoundary.None; + } + case ts.SyntaxKind.ForStatement: + case ts.SyntaxKind.ForInStatement: + case ts.SyntaxKind.ForOfStatement: + case ts.SyntaxKind.CaseBlock: + case ts.SyntaxKind.CatchClause: + case ts.SyntaxKind.WithStatement: + return ScopeBoundary.Block; + default: + return ScopeBoundary.None; + } +} diff --git a/src/usage/UsageWalker.ts b/src/usage/UsageWalker.ts new file mode 100644 index 00000000..da9271cb --- /dev/null +++ b/src/usage/UsageWalker.ts @@ -0,0 +1,440 @@ +// Code largely based on https://github.com/ajafff/tsutils +// Original license: https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import ts from "typescript"; + +import { includesModifier } from "../modifiers"; +import { DeclarationDomain } from "./declarations"; +import { getPropertyName } from "./getPropertyName"; +import { getUsageDomain } from "./getUsageDomain"; +import { + isBlockScopeBoundary, + Scope, + ScopeBoundary, + ScopeBoundarySelector, +} from "./Scope"; +import { + BlockScope, + ClassExpressionScope, + ConditionalTypeScope, + ConditionalTypeScopeState, + FunctionExpressionScope, + FunctionScope, + NonRootScope, + RootScope, +} from "./scopes"; +import { UsageInfo, UsageInfoCallback } from "./usage"; +import { + canHaveDecorators, + getDecorators, + identifierToKeywordKind, +} from "./utils"; + +// TODO class decorators resolve outside of class, element and parameter decorator resolve inside/at the class +// TODO computed property name resolves inside/at the class +// TODO this and super in all of them are resolved outside of the class +export class UsageWalker { + #result = new Map(); + #scope!: Scope; + + getUsage(sourceFile: ts.SourceFile): Map { + const variableCallback = (variable: UsageInfo, key: ts.Identifier) => { + this.#result.set(key, variable); + }; + const isModule = ts.isExternalModule(sourceFile); + this.#scope = new RootScope( + sourceFile.isDeclarationFile && + isModule && + !containsExportStatement(sourceFile), + !isModule, + ); + const cb = (node: ts.Node): void => { + if (isBlockScopeBoundary(node)) + return continueWithScope( + node, + new BlockScope(this.#scope.getFunctionScope(), this.#scope), + handleBlockScope, + ); + switch (node.kind) { + case ts.SyntaxKind.ClassExpression: + return continueWithScope( + node, + (node).name !== undefined + ? new ClassExpressionScope( + (node).name!, + this.#scope, + ) + : new NonRootScope(this.#scope, ScopeBoundary.Function), + ); + case ts.SyntaxKind.ClassDeclaration: + this.#handleDeclaration( + node, + true, + DeclarationDomain.Value | DeclarationDomain.Type, + ); + return continueWithScope( + node, + new NonRootScope(this.#scope, ScopeBoundary.Function), + ); + case ts.SyntaxKind.InterfaceDeclaration: + case ts.SyntaxKind.TypeAliasDeclaration: + this.#handleDeclaration( + node, + true, + DeclarationDomain.Type, + ); + return continueWithScope( + node, + new NonRootScope(this.#scope, ScopeBoundary.Type), + ); + case ts.SyntaxKind.EnumDeclaration: + this.#handleDeclaration( + node, + true, + DeclarationDomain.Any, + ); + return continueWithScope( + node, + this.#scope.createOrReuseEnumScope( + (node).name.text, + includesModifier( + (node as ts.HasModifiers).modifiers, + ts.SyntaxKind.ExportKeyword, + ), + ), + ); + case ts.SyntaxKind.ModuleDeclaration: + return this.#handleModule( + node, + continueWithScope, + ); + case ts.SyntaxKind.MappedType: + return continueWithScope( + node, + new NonRootScope(this.#scope, ScopeBoundary.Type), + ); + case ts.SyntaxKind.FunctionExpression: + case ts.SyntaxKind.ArrowFunction: + case ts.SyntaxKind.Constructor: + case ts.SyntaxKind.MethodDeclaration: + case ts.SyntaxKind.FunctionDeclaration: + case ts.SyntaxKind.GetAccessor: + case ts.SyntaxKind.SetAccessor: + case ts.SyntaxKind.MethodSignature: + case ts.SyntaxKind.CallSignature: + case ts.SyntaxKind.ConstructSignature: + case ts.SyntaxKind.ConstructorType: + case ts.SyntaxKind.FunctionType: + return this.#handleFunctionLikeDeclaration( + node, + cb, + variableCallback, + ); + case ts.SyntaxKind.ConditionalType: + return this.#handleConditionalType( + node, + cb, + variableCallback, + ); + // End of Scope specific handling + case ts.SyntaxKind.VariableDeclarationList: + this.#handleVariableDeclaration(node); + break; + case ts.SyntaxKind.Parameter: + if ( + node.parent.kind !== ts.SyntaxKind.IndexSignature && + ((node).name.kind !== + ts.SyntaxKind.Identifier || + identifierToKeywordKind( + (node).name, + ) !== ts.SyntaxKind.ThisKeyword) + ) + this.#handleBindingName( + (node).name, + false, + false, + ); + break; + case ts.SyntaxKind.EnumMember: + this.#scope.addVariable( + getPropertyName((node).name)!, + (node).name, + ScopeBoundarySelector.Function, + true, + DeclarationDomain.Value, + ); + break; + case ts.SyntaxKind.ImportClause: + case ts.SyntaxKind.ImportSpecifier: + case ts.SyntaxKind.NamespaceImport: + case ts.SyntaxKind.ImportEqualsDeclaration: + this.#handleDeclaration( + node, + false, + DeclarationDomain.Any | DeclarationDomain.Import, + ); + break; + case ts.SyntaxKind.TypeParameter: + this.#scope.addVariable( + (node).name.text, + (node).name, + node.parent.kind === ts.SyntaxKind.InferType + ? ScopeBoundarySelector.InferType + : ScopeBoundarySelector.Type, + false, + DeclarationDomain.Type, + ); + break; + case ts.SyntaxKind.ExportSpecifier: + if ((node).propertyName !== undefined) + return this.#scope.markExported( + (node).propertyName!, + (node).name, + ); + return this.#scope.markExported((node).name); + case ts.SyntaxKind.ExportAssignment: + if ( + (node).expression.kind === + ts.SyntaxKind.Identifier + ) + return this.#scope.markExported( + (node).expression, + ); + break; + case ts.SyntaxKind.Identifier: { + const domain = getUsageDomain(node); + if (domain !== undefined) + this.#scope.addUse({ domain, location: node }); + return; + } + } + + return ts.forEachChild(node, cb); + }; + const continueWithScope = ( + node: T, + scope: Scope, + next: (node: T) => void = forEachChild, + ) => { + const savedScope = this.#scope; + this.#scope = scope; + next(node); + this.#scope.end(variableCallback); + this.#scope = savedScope; + }; + const handleBlockScope = (node: ts.Node) => { + if ( + node.kind === ts.SyntaxKind.CatchClause && + (node).variableDeclaration !== undefined + ) + this.#handleBindingName( + (node).variableDeclaration!.name, + true, + false, + ); + return ts.forEachChild(node, cb); + }; + + ts.forEachChild(sourceFile, cb); + this.#scope.end(variableCallback); + return this.#result; + + function forEachChild(node: ts.Node) { + return ts.forEachChild(node, cb); + } + } + + #handleConditionalType( + node: ts.ConditionalTypeNode, + cb: (node: ts.Node) => void, + varCb: UsageInfoCallback, + ) { + const savedScope = this.#scope; + const scope = (this.#scope = new ConditionalTypeScope(savedScope)); + cb(node.checkType); + scope.updateState(ConditionalTypeScopeState.Extends); + cb(node.extendsType); + scope.updateState(ConditionalTypeScopeState.TrueType); + cb(node.trueType); + scope.updateState(ConditionalTypeScopeState.FalseType); + cb(node.falseType); + scope.end(varCb); + this.#scope = savedScope; + } + + #handleFunctionLikeDeclaration( + node: ts.FunctionLikeDeclaration, + cb: (node: ts.Node) => void, + varCb: UsageInfoCallback, + ) { + if (canHaveDecorators(node)) { + getDecorators(node)?.forEach(cb); + } + + const savedScope = this.#scope; + if (node.kind === ts.SyntaxKind.FunctionDeclaration) + this.#handleDeclaration(node, false, DeclarationDomain.Value); + const scope = (this.#scope = + node.kind === ts.SyntaxKind.FunctionExpression && node.name !== undefined + ? new FunctionExpressionScope(node.name, savedScope) + : new FunctionScope(savedScope)); + if (node.name !== undefined) cb(node.name); + if (node.typeParameters !== undefined) node.typeParameters.forEach(cb); + node.parameters.forEach(cb); + if (node.type !== undefined) cb(node.type); + if (node.body !== undefined) { + scope.beginBody(); + cb(node.body); + } + scope.end(varCb); + this.#scope = savedScope; + } + + #handleModule( + node: ts.ModuleDeclaration, + next: (node: ts.Node, scope: Scope) => void, + ) { + if (node.flags & ts.NodeFlags.GlobalAugmentation) + return next( + node, + this.#scope.createOrReuseNamespaceScope("-global", false, true, false), + ); + if (node.name.kind === ts.SyntaxKind.Identifier) { + const exported = isNamespaceExported(node); + this.#scope.addVariable( + node.name.text, + node.name, + ScopeBoundarySelector.Function, + exported, + DeclarationDomain.Namespace | DeclarationDomain.Value, + ); + const ambient = includesModifier( + node.modifiers, + ts.SyntaxKind.DeclareKeyword, + ); + return next( + node, + this.#scope.createOrReuseNamespaceScope( + node.name.text, + exported, + ambient, + ambient && namespaceHasExportStatement(node), + ), + ); + } + return next( + node, + this.#scope.createOrReuseNamespaceScope( + `"${node.name.text}"`, + false, + true, + namespaceHasExportStatement(node), + ), + ); + } + + #handleDeclaration( + node: ts.NamedDeclaration, + blockScoped: boolean, + domain: DeclarationDomain, + ) { + if (node.name !== undefined) + this.#scope.addVariable( + (node.name).text, + node.name, + blockScoped + ? ScopeBoundarySelector.Block + : ScopeBoundarySelector.Function, + includesModifier( + (node as ts.HasModifiers).modifiers, + ts.SyntaxKind.ExportKeyword, + ), + domain, + ); + } + + #handleBindingName( + name: ts.BindingName, + blockScoped: boolean, + exported: boolean, + ) { + if (name.kind === ts.SyntaxKind.Identifier) + return this.#scope.addVariable( + name.text, + name, + blockScoped + ? ScopeBoundarySelector.Block + : ScopeBoundarySelector.Function, + exported, + DeclarationDomain.Value, + ); + forEachDestructuringIdentifier(name, (declaration) => { + this.#scope.addVariable( + declaration.name.text, + declaration.name, + blockScoped + ? ScopeBoundarySelector.Block + : ScopeBoundarySelector.Function, + exported, + DeclarationDomain.Value, + ); + }); + } + + #handleVariableDeclaration(declarationList: ts.VariableDeclarationList) { + const blockScoped = isBlockScopedVariableDeclarationList(declarationList); + const exported = + declarationList.parent.kind === ts.SyntaxKind.VariableStatement && + includesModifier( + declarationList.parent.modifiers, + ts.SyntaxKind.ExportKeyword, + ); + for (const declaration of declarationList.declarations) + this.#handleBindingName(declaration.name, blockScoped, exported); + } +} + +function isNamespaceExported(node: ts.NamespaceDeclaration) { + return ( + node.parent.kind === ts.SyntaxKind.ModuleDeclaration || + includesModifier(node.modifiers, ts.SyntaxKind.ExportKeyword) + ); +} + +function namespaceHasExportStatement(ns: ts.ModuleDeclaration): boolean { + if (ns.body === undefined || ns.body.kind !== ts.SyntaxKind.ModuleBlock) + return false; + return containsExportStatement(ns.body); +} + +function containsExportStatement(block: ts.BlockLike): boolean { + for (const statement of block.statements) + if ( + statement.kind === ts.SyntaxKind.ExportDeclaration || + statement.kind === ts.SyntaxKind.ExportAssignment + ) + return true; + return false; +} + +function isBlockScopedVariableDeclarationList( + declarationList: ts.VariableDeclarationList, +): boolean { + return (declarationList.flags & ts.NodeFlags.BlockScoped) !== 0; +} + +function forEachDestructuringIdentifier( + pattern: ts.BindingPattern, + fn: (element: ts.BindingElement & { name: ts.Identifier }) => T, +): T | undefined { + for (const element of pattern.elements) { + if (element.kind !== ts.SyntaxKind.BindingElement) continue; + let result: T | undefined; + if (element.name.kind === ts.SyntaxKind.Identifier) { + result = fn(element); + } else { + result = forEachDestructuringIdentifier(element.name, fn); + } + if (result) return result; + } +} diff --git a/src/usage/collectVariableUsage.test.ts b/src/usage/collectVariableUsage.test.ts new file mode 100644 index 00000000..335c280f --- /dev/null +++ b/src/usage/collectVariableUsage.test.ts @@ -0,0 +1,524 @@ +import { query } from "@phenomnomnominal/tsquery"; +import ts from "typescript"; +import { describe, expect, test } from "vitest"; + +import { createSourceFile } from "../test/utils"; +import { collectVariableUsage } from "./collectVariableUsage"; +import { DeclarationDomain } from "./declarations"; +import { UsageDomain } from "./getUsageDomain"; + +describe("collectVariableUsage", () => { + test("conditional type", () => { + const sourceFile = createSourceFile(` + export type TrueIfZero = T extends 0 ? true : false; + `); + + const [nameIdentifier] = query(sourceFile, "Identifier"); + const [typeParameterIdentifier] = query( + sourceFile, + "TypeParameter Identifier", + ); + + const [conditionalTypeReferenceIdentifier] = query( + sourceFile, + "ConditionalType TypeReference Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + typeParameterIdentifier, + { + declarations: [typeParameterIdentifier], + domain: DeclarationDomain.Type, + exported: false, + inGlobalScope: false, + uses: [ + { + domain: DeclarationDomain.Type, + location: conditionalTypeReferenceIdentifier, + }, + ], + }, + ], + [ + nameIdentifier, + { + declarations: [nameIdentifier], + domain: DeclarationDomain.Type, + exported: true, + inGlobalScope: false, + uses: [], + }, + ], + ]), + ); + }); + + test("class declaration and property", () => { + const sourceFile = createSourceFile(` + class Box { + value = 1; + } + + export const { value } = new Box(); + `); + + const [classDeclaration] = query( + sourceFile, + "ClassDeclaration", + ); + + const [variableDeclaration] = query(sourceFile, "VariableDeclaration"); + + const [variableIdentifier] = query( + variableDeclaration, + "ObjectBindingPattern Identifier", + ); + + const [newIdentifier] = query( + variableDeclaration, + "NewExpression Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + classDeclaration.name, + { + declarations: [classDeclaration.name], + domain: DeclarationDomain.Type | DeclarationDomain.Value, + exported: false, + inGlobalScope: false, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: newIdentifier, + }, + ], + }, + ], + [ + variableIdentifier, + { + declarations: [variableIdentifier], + domain: DeclarationDomain.Value, + exported: true, + inGlobalScope: false, + uses: [], + }, + ], + ]), + ); + }); + + test("class expression and property", () => { + const sourceFile = createSourceFile(` + const Box = class { + value = 1; + } + + export const { value } = new Box(); + `); + + const [classNameIdentifier] = query( + sourceFile, + "VariableDeclaration Identifier", + ); + + const [usageVariableDeclaration] = query( + sourceFile, + "VariableStatement:has(ExportKeyword) VariableDeclaration", + ); + + const [variableIdentifier] = query( + usageVariableDeclaration, + "ObjectBindingPattern Identifier", + ); + + const [newIdentifier] = query( + usageVariableDeclaration, + "NewExpression Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + classNameIdentifier, + { + declarations: [classNameIdentifier], + domain: DeclarationDomain.Value, + exported: false, + inGlobalScope: false, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: newIdentifier, + }, + ], + }, + ], + [ + variableIdentifier, + { + declarations: [variableIdentifier], + domain: DeclarationDomain.Value, + exported: true, + inGlobalScope: false, + uses: [], + }, + ], + ]), + ); + }); + + test("enum declaration and property", () => { + const sourceFile = createSourceFile(` + enum Values { First } + Values.First; + `); + + const [enumIdentifier] = query(sourceFile, "EnumDeclaration Identifier"); + + const [propertyReference] = query( + sourceFile, + "ExpressionStatement Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + enumIdentifier, + { + declarations: [enumIdentifier], + domain: DeclarationDomain.Any, + exported: false, + inGlobalScope: true, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: propertyReference, + }, + ], + }, + ], + ]), + ); + }); + + test("function declaration and call", () => { + const sourceFile = createSourceFile(` + function createValue() { + return 123; + } + + createValue(); + `); + + const [functionDeclaration] = query( + sourceFile, + "FunctionDeclaration Identifier", + ); + + const [callExpressionIdentifier] = query( + sourceFile, + "CallExpression Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + functionDeclaration, + { + declarations: [functionDeclaration], + domain: DeclarationDomain.Value, + exported: false, + inGlobalScope: true, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: callExpressionIdentifier, + }, + ], + }, + ], + ]), + ); + }); + + test("namespace and property", () => { + const sourceFile = createSourceFile(` + namespace Values { export const First = 0 } + Values.First; + `); + + const [namespaceIdentifier] = query( + sourceFile, + "ModuleDeclaration Identifier", + ); + + const [variableIdentifier] = query( + sourceFile, + "VariableDeclaration Identifier", + ); + + const [propertyReference] = query( + sourceFile, + "ExpressionStatement Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + namespaceIdentifier, + { + declarations: [namespaceIdentifier], + domain: DeclarationDomain.Namespace | DeclarationDomain.Value, + exported: false, + inGlobalScope: true, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: propertyReference, + }, + ], + }, + ], + [ + variableIdentifier, + { + declarations: [variableIdentifier], + domain: DeclarationDomain.Value, + exported: true, + inGlobalScope: false, + uses: [], + }, + ], + ]), + ); + }); + + test("nested Namespace and property", () => { + const sourceFile = createSourceFile(` + namespace Outer { + export namespace Inner { + export const First = 0; + } + } + + Outer.Inner.First; + `); + + const [namespaceIdentifierOuter] = query( + sourceFile, + "ModuleDeclaration Identifier", + ); + + const [namespaceIdentifierInner] = query( + sourceFile, + "ModuleDeclaration ModuleDeclaration Identifier", + ); + + const [variableIdentifier] = query( + sourceFile, + "VariableDeclaration Identifier", + ); + + const [propertyReference] = query( + sourceFile, + "ExpressionStatement Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + namespaceIdentifierOuter, + { + declarations: [namespaceIdentifierOuter], + domain: DeclarationDomain.Namespace | DeclarationDomain.Value, + exported: false, + inGlobalScope: true, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: propertyReference, + }, + ], + }, + ], + [ + variableIdentifier, + { + declarations: [variableIdentifier], + domain: DeclarationDomain.Value, + exported: true, + inGlobalScope: false, + uses: [], + }, + ], + [ + namespaceIdentifierInner, + { + declarations: [namespaceIdentifierInner], + domain: DeclarationDomain.Namespace, + exported: true, + inGlobalScope: false, + uses: [], + }, + ], + ]), + ); + }); + + test("variable reference", () => { + const sourceFile = createSourceFile(` + let value = 123; + value; + `); + + const [variableIdentifier] = query( + sourceFile, + "VariableDeclaration Identifier", + ); + + const [variableReference] = query( + sourceFile, + "ExpressionStatement Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + variableIdentifier, + { + declarations: [variableIdentifier], + domain: DeclarationDomain.Value, + exported: false, + inGlobalScope: true, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: variableReference, + }, + ], + }, + ], + ]), + ); + }); + + test("variable reference in a block", () => { + const sourceFile = createSourceFile(` + let value = 123; + + { + value; + } + `); + + const [variableIdentifier] = query( + sourceFile, + "VariableDeclaration Identifier", + ); + + const [variableReference] = query( + sourceFile, + "ExpressionStatement Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + variableIdentifier, + { + declarations: [variableIdentifier], + domain: DeclarationDomain.Value, + exported: false, + inGlobalScope: true, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: variableReference, + }, + ], + }, + ], + ]), + ); + }); + + test("variable with a reference inside a class", () => { + const sourceFile = createSourceFile(` + let value = 123; + + class Getter { + getValue = () => value; + } + `); + + const [variableIdentifier] = query( + sourceFile, + "VariableDeclaration Identifier", + ); + + const [classDeclaration] = query( + sourceFile, + "ClassDeclaration", + ); + + const [variableReference] = query( + classDeclaration, + "ArrowFunction Identifier", + ); + + const actual = collectVariableUsage(sourceFile); + + expect(actual).toEqual( + new Map([ + [ + variableIdentifier, + { + declarations: [variableIdentifier], + domain: DeclarationDomain.Value, + exported: false, + inGlobalScope: true, + uses: [ + { + domain: UsageDomain.ValueOrNamespace, + location: variableReference, + }, + ], + }, + ], + [ + classDeclaration.name, + { + declarations: [classDeclaration.name], + domain: DeclarationDomain.Type | DeclarationDomain.Value, + exported: false, + inGlobalScope: true, + uses: [], + }, + ], + ]), + ); + }); +}); diff --git a/src/usage/collectVariableUsage.ts b/src/usage/collectVariableUsage.ts new file mode 100644 index 00000000..438448e6 --- /dev/null +++ b/src/usage/collectVariableUsage.ts @@ -0,0 +1,28 @@ +// Code largely based on https://github.com/ajafff/tsutils +// Original license: https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import ts from "typescript"; + +import { UsageInfo } from "./usage"; +import { UsageWalker } from "./UsageWalker"; + +/** + * Creates a mapping of each declared type and value to its type information. + * + * @category Nodes - Other Utilities + * @example + * ```ts + * declare const sourceFile: ts.SourceFile; + * + * const usage = collectVariableUsage(sourceFile); + * + * for (const [identifier, information] of usage) { + * console.log(`${identifier.getText()} is used ${information.uses.length} time(s).`); + * } + * ``` + */ +export function collectVariableUsage( + sourceFile: ts.SourceFile, +): Map { + return new UsageWalker().getUsage(sourceFile); +} diff --git a/src/usage/declarations.ts b/src/usage/declarations.ts new file mode 100644 index 00000000..313e3c3f --- /dev/null +++ b/src/usage/declarations.ts @@ -0,0 +1,68 @@ +// Code largely based on https://github.com/ajafff/tsutils +// Original license: https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import ts from "typescript"; + +import { identifierToKeywordKind } from "./utils"; + +/** + * Metadata for how a declaration was declared and/or referenced. + */ +export interface DeclarationInfo { + declaration: ts.PropertyName; + domain: DeclarationDomain; + exported: boolean; +} + +/** + * Which "domain"(s) (most commonly, type or value space) a declaration is within. + */ +export enum DeclarationDomain { + Namespace = 1, + Type = 2, + Value = 4, + Import = 8, + Any = Namespace | Type | Value, +} + +export function getDeclarationDomain( + node: ts.Identifier, +): DeclarationDomain | undefined { + switch (node.parent.kind) { + case ts.SyntaxKind.TypeParameter: + case ts.SyntaxKind.InterfaceDeclaration: + case ts.SyntaxKind.TypeAliasDeclaration: + return DeclarationDomain.Type; + case ts.SyntaxKind.ClassDeclaration: + case ts.SyntaxKind.ClassExpression: + return DeclarationDomain.Type | DeclarationDomain.Value; + case ts.SyntaxKind.EnumDeclaration: + return DeclarationDomain.Any; + case ts.SyntaxKind.NamespaceImport: + case ts.SyntaxKind.ImportClause: + return DeclarationDomain.Any | DeclarationDomain.Import; // TODO handle type-only imports + case ts.SyntaxKind.ImportEqualsDeclaration: + case ts.SyntaxKind.ImportSpecifier: + return (node.parent) + .name === node + ? DeclarationDomain.Any | DeclarationDomain.Import // TODO handle type-only imports + : undefined; + case ts.SyntaxKind.ModuleDeclaration: + return DeclarationDomain.Namespace; + case ts.SyntaxKind.Parameter: + if ( + node.parent.parent.kind === ts.SyntaxKind.IndexSignature || + identifierToKeywordKind(node) === ts.SyntaxKind.ThisKeyword + ) + return; + // falls through + case ts.SyntaxKind.BindingElement: + case ts.SyntaxKind.VariableDeclaration: + return (node.parent).name === node + ? DeclarationDomain.Value + : undefined; + case ts.SyntaxKind.FunctionDeclaration: + case ts.SyntaxKind.FunctionExpression: + return DeclarationDomain.Value; + } +} diff --git a/src/usage/getPropertyName.ts b/src/usage/getPropertyName.ts new file mode 100644 index 00000000..06c8f159 --- /dev/null +++ b/src/usage/getPropertyName.ts @@ -0,0 +1,44 @@ +// Code largely based on https://github.com/ajafff/tsutils +// Original license: https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import ts from "typescript"; + +import { isNumericOrStringLikeLiteral } from "../nodes/typeGuards/compound"; + +function unwrapParentheses(node: ts.Expression) { + while (node.kind === ts.SyntaxKind.ParenthesizedExpression) + node = (node).expression; + return node; +} + +export function getPropertyName( + propertyName: ts.PropertyName, +): string | undefined { + if (propertyName.kind === ts.SyntaxKind.ComputedPropertyName) { + const expression = unwrapParentheses(propertyName.expression); + if (ts.isPrefixUnaryExpression(expression)) { + let negate = false; + switch (expression.operator) { + case ts.SyntaxKind.MinusToken: + negate = true; + // falls through + case ts.SyntaxKind.PlusToken: + return ts.isNumericLiteral(expression.operand) + ? `${negate ? "-" : ""}${expression.operand.text}` + : ts.isBigIntLiteral(expression.operand) + ? `${negate ? "-" : ""}${expression.operand.text.slice(0, -1)}` + : undefined; + default: + return; + } + } + if (ts.isBigIntLiteral(expression)) + // handle BigInt, even though TypeScript doesn't allow BigInt as computed property name + return expression.text.slice(0, -1); + if (isNumericOrStringLikeLiteral(expression)) return expression.text; + return; + } + return propertyName.kind === ts.SyntaxKind.PrivateIdentifier + ? undefined + : propertyName.text; +} diff --git a/src/usage/getUsageDomain.ts b/src/usage/getUsageDomain.ts new file mode 100644 index 00000000..b5c287d9 --- /dev/null +++ b/src/usage/getUsageDomain.ts @@ -0,0 +1,106 @@ +// Code largely based on https://github.com/ajafff/tsutils +// Original license: https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import ts from "typescript"; + +import { identifierToKeywordKind } from "./utils"; + +/** + * Which "domain"(s) (most commonly, type or value space) a usage is within. + */ +export enum UsageDomain { + Namespace = 1, + Type = 2, + Value = 4, + ValueOrNamespace = Value | Namespace, + Any = Namespace | Type | Value, + TypeQuery = 8, +} + +// TODO handle cases where values are used only for their types, e.g. `declare [propSymbol]: number` +export function getUsageDomain(node: ts.Identifier): UsageDomain | undefined { + const parent = node.parent; + switch (parent.kind) { + case ts.SyntaxKind.TypeReference: + return identifierToKeywordKind(node) !== ts.SyntaxKind.ConstKeyword + ? UsageDomain.Type + : undefined; + case ts.SyntaxKind.ExpressionWithTypeArguments: + return (parent.parent).token === + ts.SyntaxKind.ImplementsKeyword || + parent.parent.parent.kind === ts.SyntaxKind.InterfaceDeclaration + ? UsageDomain.Type + : UsageDomain.Value; + case ts.SyntaxKind.TypeQuery: + return UsageDomain.ValueOrNamespace | UsageDomain.TypeQuery; + case ts.SyntaxKind.QualifiedName: + if ((parent).left === node) { + if ( + getEntityNameParent(parent).kind === + ts.SyntaxKind.TypeQuery + ) + return UsageDomain.Namespace | UsageDomain.TypeQuery; + return UsageDomain.Namespace; + } + break; + case ts.SyntaxKind.ExportSpecifier: + // either {name} or {propertyName as name} + if ( + (parent).propertyName === undefined || + (parent).propertyName === node + ) + return UsageDomain.Any; // TODO handle type-only exports + break; + case ts.SyntaxKind.ExportAssignment: + return UsageDomain.Any; + // Value + case ts.SyntaxKind.BindingElement: + if ((parent).initializer === node) + return UsageDomain.ValueOrNamespace; + break; + case ts.SyntaxKind.Parameter: + case ts.SyntaxKind.EnumMember: + case ts.SyntaxKind.PropertyDeclaration: + case ts.SyntaxKind.VariableDeclaration: + case ts.SyntaxKind.PropertyAssignment: + case ts.SyntaxKind.PropertyAccessExpression: + case ts.SyntaxKind.ImportEqualsDeclaration: + if ((parent).name !== node) + return UsageDomain.ValueOrNamespace; // TODO handle type-only imports + break; + case ts.SyntaxKind.JsxAttribute: + case ts.SyntaxKind.FunctionDeclaration: + case ts.SyntaxKind.FunctionExpression: + case ts.SyntaxKind.NamespaceImport: + case ts.SyntaxKind.ClassDeclaration: + case ts.SyntaxKind.ClassExpression: + case ts.SyntaxKind.ModuleDeclaration: + case ts.SyntaxKind.MethodDeclaration: + case ts.SyntaxKind.EnumDeclaration: + case ts.SyntaxKind.GetAccessor: + case ts.SyntaxKind.SetAccessor: + case ts.SyntaxKind.LabeledStatement: + case ts.SyntaxKind.BreakStatement: + case ts.SyntaxKind.ContinueStatement: + case ts.SyntaxKind.ImportClause: + case ts.SyntaxKind.ImportSpecifier: + case ts.SyntaxKind.TypePredicate: // TODO this actually references a parameter + case ts.SyntaxKind.MethodSignature: + case ts.SyntaxKind.PropertySignature: + case ts.SyntaxKind.NamespaceExportDeclaration: + case ts.SyntaxKind.NamespaceExport: + case ts.SyntaxKind.InterfaceDeclaration: + case ts.SyntaxKind.TypeAliasDeclaration: + case ts.SyntaxKind.TypeParameter: + case ts.SyntaxKind.NamedTupleMember: + break; + default: + return UsageDomain.ValueOrNamespace; + } +} + +function getEntityNameParent(name: ts.EntityName) { + let parent = name.parent; + while (parent.kind === ts.SyntaxKind.QualifiedName) parent = parent.parent!; + return parent; +} diff --git a/src/usage/index.ts b/src/usage/index.ts new file mode 100644 index 00000000..7a9a93a5 --- /dev/null +++ b/src/usage/index.ts @@ -0,0 +1,5 @@ +export { collectVariableUsage } from "./collectVariableUsage"; +export { DeclarationDomain } from "./declarations"; +export { UsageDomain } from "./getUsageDomain"; +export { UsageInfo as VariableInfo } from "./usage"; +export { Usage as VariableUse } from "./usage"; diff --git a/src/usage/scopes.ts b/src/usage/scopes.ts new file mode 100644 index 00000000..f5ded37b --- /dev/null +++ b/src/usage/scopes.ts @@ -0,0 +1,463 @@ +// Code largely based on https://github.com/ajafff/tsutils +// Original license: https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import ts from "typescript"; + +import { + DeclarationDomain, + DeclarationInfo, + getDeclarationDomain, +} from "./declarations"; +import { Scope, ScopeBoundary, ScopeBoundarySelector } from "./Scope"; +import { + InternalUsageInfo, + Usage, + UsageInfo, + UsageInfoCallback, +} from "./usage"; + +abstract class AbstractScope implements Scope { + protected variables = new Map(); + protected uses: Usage[] = []; + protected namespaceScopes: Map | undefined = + undefined; + #enumScopes: Map | undefined = undefined; + + constructor(protected global: boolean) {} + + addVariable( + identifier: string, + name: ts.PropertyName, + selector: ScopeBoundarySelector, + exported: boolean, + domain: DeclarationDomain, + ): void { + const variables = this.getDestinationScope(selector).getVariables(); + const declaration: DeclarationInfo = { + domain, + exported, + declaration: name, + }; + const variable = variables.get(identifier); + if (variable === undefined) { + variables.set(identifier, { + domain, + declarations: [declaration], + uses: [], + }); + } else { + variable.domain |= domain; + variable.declarations.push(declaration); + } + } + + addUse(use: Usage): void { + this.uses.push(use); + } + + getVariables(): Map { + return this.variables; + } + + getFunctionScope(): Scope { + return this; + } + + end(cb: UsageInfoCallback): void { + if (this.namespaceScopes !== undefined) { + this.namespaceScopes.forEach((value) => value.finish(cb)); + } + + this.namespaceScopes = this.#enumScopes = undefined; + this.applyUses(); + this.variables.forEach((variable) => { + for (const declaration of variable.declarations) { + const result: UsageInfo = { + declarations: [], + domain: declaration.domain, + exported: declaration.exported, + inGlobalScope: this.global, + uses: [], + }; + for (const other of variable.declarations) + if (other.domain & declaration.domain) + result.declarations.push(other.declaration); + for (const use of variable.uses) + if (use.domain & declaration.domain) result.uses.push(use); + cb(result, declaration.declaration, this); + } + }); + } + + // tslint:disable-next-line:prefer-function-over-method + markExported(_name: ts.Identifier): void {} // only relevant for the root scope + + createOrReuseNamespaceScope( + name: string, + _exported: boolean, + ambient: boolean, + hasExportStatement: boolean, + ): NamespaceScope { + let scope: NamespaceScope | undefined; + if (this.namespaceScopes === undefined) { + this.namespaceScopes = new Map(); + } else { + scope = this.namespaceScopes.get(name); + } + if (scope === undefined) { + scope = new NamespaceScope(ambient, hasExportStatement, this); + this.namespaceScopes.set(name, scope); + } else { + scope.refresh(ambient, hasExportStatement); + } + return scope; + } + + createOrReuseEnumScope(name: string, _exported: boolean): EnumScope { + let scope: EnumScope | undefined; + if (this.#enumScopes === undefined) { + this.#enumScopes = new Map(); + } else { + scope = this.#enumScopes.get(name); + } + if (scope === undefined) { + scope = new EnumScope(this); + this.#enumScopes.set(name, scope); + } + return scope; + } + + protected applyUses(): void { + for (const use of this.uses) { + if (!this.applyUse(use)) { + this.addUseToParent(use); + } + } + this.uses = []; + } + + protected applyUse(use: Usage, variables = this.variables): boolean { + const variable = variables.get(use.location.text); + if (variable === undefined || (variable.domain & use.domain) === 0) + return false; + variable.uses.push(use); + return true; + } + + abstract getDestinationScope(selector: ScopeBoundarySelector): Scope; + + protected addUseToParent(_use: Usage): void {} +} + +export class NonRootScope extends AbstractScope { + constructor( + protected parent: Scope, + protected boundary: ScopeBoundary, + ) { + super(false); + } + + getDestinationScope(selector: ScopeBoundarySelector): Scope { + return this.boundary & selector + ? this + : this.parent.getDestinationScope(selector); + } + + protected addUseToParent(use: Usage): void { + return this.parent.addUse(use, this); + } +} + +export class EnumScope extends NonRootScope { + constructor(parent: Scope) { + super(parent, ScopeBoundary.Function); + } + + end(): void { + this.applyUses(); + } +} + +export class RootScope extends AbstractScope { + #exportAll: boolean; + #exports: string[] | undefined = undefined; + #innerScope = new NonRootScope(this, ScopeBoundary.Function); + + constructor(exportAll: boolean, global: boolean) { + super(global); + this.#exportAll = exportAll; + } + + addVariable( + identifier: string, + name: ts.PropertyName, + selector: ScopeBoundarySelector, + exported: boolean, + domain: DeclarationDomain, + ): void { + if (domain & DeclarationDomain.Import) + return super.addVariable(identifier, name, selector, exported, domain); + return this.#innerScope.addVariable( + identifier, + name, + selector, + exported, + domain, + ); + } + + addUse(use: Usage, origin?: Scope): void { + if (origin === this.#innerScope) return super.addUse(use); + return this.#innerScope.addUse(use); + } + + markExported(id: ts.Identifier): void { + if (this.#exports === undefined) { + this.#exports = [id.text]; + } else { + this.#exports.push(id.text); + } + } + + end(cb: UsageInfoCallback): void { + this.#innerScope.end((value, key) => { + value.exported = + value.exported || + this.#exportAll || + (this.#exports !== undefined && this.#exports.includes(key.text)); + value.inGlobalScope = this.global; + return cb(value, key, this); + }); + return super.end((value, key, scope) => { + value.exported = + value.exported || + (scope === this && + this.#exports !== undefined && + this.#exports.includes(key.text)); + return cb(value, key, scope); + }); + } + + getDestinationScope(): this { + return this; + } +} + +export class NamespaceScope extends NonRootScope { + #innerScope = new NonRootScope(this, ScopeBoundary.Function); + #exports: Set | undefined = undefined; + #ambient: boolean; + #hasExport: boolean; + + constructor(ambient: boolean, hasExport: boolean, parent: Scope) { + super(parent, ScopeBoundary.Function); + this.#ambient = ambient; + this.#hasExport = hasExport; + } + + finish(cb: UsageInfoCallback): void { + return super.end(cb); + } + + end(cb: UsageInfoCallback): void { + this.#innerScope.end((variable, key, scope) => { + if ( + scope !== this.#innerScope || + (!variable.exported && + (!this.#ambient || + (this.#exports !== undefined && !this.#exports.has(key.text)))) + ) + return cb(variable, key, scope); + const namespaceVar = this.variables.get(key.text); + if (namespaceVar === undefined) { + this.variables.set(key.text, { + declarations: variable.declarations.map(mapDeclaration), + domain: variable.domain, + uses: [...variable.uses], + }); + } else { + outer: for (const declaration of variable.declarations) { + for (const existing of namespaceVar.declarations) + if (existing.declaration === declaration) continue outer; + namespaceVar.declarations.push(mapDeclaration(declaration)); + } + namespaceVar.domain |= variable.domain; + for (const use of variable.uses) { + if (namespaceVar.uses.includes(use)) continue; + namespaceVar.uses.push(use); + } + } + }); + this.applyUses(); + this.#innerScope = new NonRootScope(this, ScopeBoundary.Function); + } + + createOrReuseNamespaceScope( + name: string, + exported: boolean, + ambient: boolean, + hasExportStatement: boolean, + ): NamespaceScope { + if (!exported && (!this.#ambient || this.#hasExport)) + return this.#innerScope.createOrReuseNamespaceScope( + name, + exported, + ambient || this.#ambient, + hasExportStatement, + ); + return super.createOrReuseNamespaceScope( + name, + exported, + ambient || this.#ambient, + hasExportStatement, + ); + } + + createOrReuseEnumScope(name: string, exported: boolean): EnumScope { + if (!exported && (!this.#ambient || this.#hasExport)) + return this.#innerScope.createOrReuseEnumScope(name, exported); + return super.createOrReuseEnumScope(name, exported); + } + + addUse(use: Usage, source?: Scope): void { + if (source !== this.#innerScope) return this.#innerScope.addUse(use); + this.uses.push(use); + } + + refresh(ambient: boolean, hasExport: boolean): void { + this.#ambient = ambient; + this.#hasExport = hasExport; + } + + markExported(name: ts.Identifier): void { + if (this.#exports === undefined) this.#exports = new Set(); + this.#exports.add(name.text); + } + + getDestinationScope(): Scope { + return this.#innerScope; + } +} + +function mapDeclaration(declaration: ts.Identifier): DeclarationInfo { + return { + declaration, + exported: true, + domain: getDeclarationDomain(declaration)!, + }; +} + +export class FunctionScope extends NonRootScope { + constructor(parent: Scope) { + super(parent, ScopeBoundary.Function); + } + + beginBody(): void { + this.applyUses(); + } +} + +abstract class AbstractNamedExpressionScope< + InnerScope extends NonRootScope, +> extends NonRootScope { + protected abstract get innerScope(): InnerScope; + + #name: ts.Identifier; + #domain: DeclarationDomain; + + constructor(name: ts.Identifier, domain: DeclarationDomain, parent: Scope) { + super(parent, ScopeBoundary.Function); + this.#name = name; + this.#domain = domain; + } + + end(cb: UsageInfoCallback): void { + this.innerScope.end(cb); + return cb( + { + declarations: [this.#name], + domain: this.#domain, + exported: false, + uses: this.uses, + inGlobalScope: false, + }, + this.#name, + this, + ); + } + + addUse(use: Usage, source?: Scope): void { + if (source !== this.innerScope) return this.innerScope.addUse(use); + if (use.domain & this.#domain && use.location.text === this.#name.text) { + this.uses.push(use); + } else { + return this.parent.addUse(use, this); + } + } + + getFunctionScope(): InnerScope { + return this.innerScope; + } + + getDestinationScope(): InnerScope { + return this.innerScope; + } +} + +export class FunctionExpressionScope extends AbstractNamedExpressionScope { + protected innerScope = new FunctionScope(this); + + constructor(name: ts.Identifier, parent: Scope) { + super(name, DeclarationDomain.Value, parent); + } + + beginBody(): void { + return this.innerScope.beginBody(); + } +} + +export class BlockScope extends NonRootScope { + #functionScope: Scope; + + constructor(functionScope: Scope, parent: Scope) { + super(parent, ScopeBoundary.Block); + this.#functionScope = functionScope; + } + + getFunctionScope(): Scope { + return this.#functionScope; + } +} + +export class ClassExpressionScope extends AbstractNamedExpressionScope { + protected innerScope = new NonRootScope(this, ScopeBoundary.Function); + + constructor(name: ts.Identifier, parent: Scope) { + super(name, DeclarationDomain.Value | DeclarationDomain.Type, parent); + } +} + +export enum ConditionalTypeScopeState { + Initial, + Extends, + TrueType, + FalseType, +} + +export class ConditionalTypeScope extends NonRootScope { + #state = ConditionalTypeScopeState.Initial; + + constructor(parent: Scope) { + super(parent, ScopeBoundary.ConditionalType); + } + + updateState(newState: ConditionalTypeScopeState): void { + this.#state = newState; + } + + addUse(use: Usage): void { + if (this.#state === ConditionalTypeScopeState.TrueType) + return void this.uses.push(use); + return this.parent.addUse(use, this); + } +} diff --git a/src/usage/usage.ts b/src/usage/usage.ts new file mode 100644 index 00000000..76b52b77 --- /dev/null +++ b/src/usage/usage.ts @@ -0,0 +1,64 @@ +// Code largely based on https://github.com/ajafff/tsutils +// Original license: https://github.com/ajafff/tsutils/blob/26b195358ec36d59f00333115aa3ffd9611ca78b/LICENSE + +import ts from "typescript"; + +import { DeclarationDomain, DeclarationInfo } from "./declarations"; +import { UsageDomain } from "./getUsageDomain"; +import { Scope } from "./Scope"; + +/** + * Registers usage information for an identifier in a scope. + */ +export type UsageInfoCallback = ( + usageInfo: UsageInfo, + key: ts.Identifier, + scope: Scope, +) => void; + +/** + * How an item (type or value) was declared and/or referenced. + */ +export interface UsageInfo { + /** + * Locations where the item was declared. + */ + declarations: ts.Identifier[]; + + /** + * Which space(s) the item is within. + */ + domain: DeclarationDomain; + + /** + * Whether the item was exported from its module or namespace scope. + */ + exported: boolean; + + /** + * Whether the item's declaration was in the global scope. + */ + inGlobalScope: boolean; + + /** + * Each reference to the item in the file. + */ + uses: Usage[]; +} + +/** + * An instance of an item (type or value) being used. + */ +export interface Usage { + /** + * Which space(s) the usage is within. + */ + domain: UsageDomain; + location: ts.Identifier; +} + +export interface InternalUsageInfo { + declarations: DeclarationInfo[]; + domain: DeclarationDomain; + uses: Usage[]; +} diff --git a/src/usage/utils.ts b/src/usage/utils.ts new file mode 100644 index 00000000..097d354e --- /dev/null +++ b/src/usage/utils.ts @@ -0,0 +1,37 @@ +import ts from "typescript"; + +/** + * Supports TypeScript<5 versions that don't have identifierToKeywordKind. + */ +export function identifierToKeywordKind( + node: ts.Identifier, +): ts.SyntaxKind | undefined { + return "identifierToKeywordKind" in ts + ? ts.identifierToKeywordKind(node) + : // eslint-disable-next-line deprecation/deprecation + node.originalKeywordKind; +} + +/** + * Supports TypeScript<4.8 versions that don't have canHaveDecorators. + */ +export function canHaveDecorators(node: ts.Node): node is ts.HasDecorators { + return "canHaveDecorators" in ts + ? ts.canHaveDecorators(node) + : "decorators" in node; +} + +type NodeWithDecorators = ts.HasDecorators & { + decorators: readonly ts.Decorator[] | undefined; +}; + +/** + * Supports TypeScript<4.8 versions that don't have getDecorators. + */ +export function getDecorators( + node: ts.HasDecorators, +): readonly ts.Decorator[] | undefined { + return "getDecorators" in ts + ? ts.getDecorators(node) + : (node as NodeWithDecorators).decorators; +}