diff --git a/README.md b/README.md index fb627bd..64177f1 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,14 @@ From 1.4.0 forward also Promises are supported. All other values (functions etc. ## Release Notes -## [Unreleased] +## [1.5.1] - 2021-05-18 ### Fixed +- Multiple typeholes can now exist with the same id. Each update from all of them updates all types attached to the holes. Useful, for example, when you want to have multiple typeholes update the same type. +- No duplicated interfaces anymore when the generated top-level type is a `ParenthesizedType` +- Interface not updating when it was in a different file than the typehole +- Types not updating when some other file was focused in the editor - `typehole.tNaN` [issue](https://github.com/rikukissa/typehole/issues/7) when there have been typeholes with a non `t` format ## [1.5.0] - 2021-05-15 diff --git a/packages/extension/package-lock.json b/packages/extension/package-lock.json index 5ed5aab..208205f 100644 --- a/packages/extension/package-lock.json +++ b/packages/extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "typehole", - "version": "1.4.3", + "version": "1.5.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "typehole", - "version": "1.4.3", + "version": "1.5.1", "dependencies": { "@phenomnomnominal/tsquery": "^4.1.1", "@types/esquery": "^1.0.1", @@ -823,6 +823,7 @@ "jest-resolve": "^26.6.2", "jest-util": "^26.6.2", "jest-worker": "^26.6.2", + "node-notifier": "^8.0.0", "slash": "^3.0.0", "source-map": "^0.6.0", "string-length": "^4.0.1", @@ -2665,7 +2666,8 @@ "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1" + "optionator": "^0.8.1", + "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", @@ -4588,6 +4590,7 @@ "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", + "fsevents": "^2.1.2", "graceful-fs": "^4.2.4", "jest-regex-util": "^26.0.0", "jest-serializer": "^26.6.2", @@ -5096,6 +5099,7 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dependencies": { + "graceful-fs": "^4.1.6", "universalify": "^2.0.0" }, "optionalDependencies": { @@ -5574,7 +5578,182 @@ "treeverse", "validate-npm-package-name", "which", - "write-file-atomic" + "write-file-atomic", + "@npmcli/disparity-colors", + "@npmcli/git", + "@npmcli/installed-package-contents", + "@npmcli/map-workspaces", + "@npmcli/metavuln-calculator", + "@npmcli/move-file", + "@npmcli/name-from-folder", + "@npmcli/node-gyp", + "@npmcli/promise-spawn", + "@tootallnate/once", + "agent-base", + "agentkeepalive", + "aggregate-error", + "ajv", + "ansi-regex", + "ansi-styles", + "aproba", + "are-we-there-yet", + "asap", + "asn1", + "assert-plus", + "asynckit", + "aws-sign2", + "aws4", + "balanced-match", + "bcrypt-pbkdf", + "bin-links", + "binary-extensions", + "brace-expansion", + "builtins", + "caseless", + "cidr-regex", + "clean-stack", + "clone", + "cmd-shim", + "code-point-at", + "color-convert", + "color-name", + "colors", + "combined-stream", + "common-ancestor-path", + "concat-map", + "console-control-strings", + "core-util-is", + "dashdash", + "debug", + "debuglog", + "defaults", + "delayed-stream", + "delegates", + "depd", + "dezalgo", + "diff", + "ecc-jsbn", + "emoji-regex", + "encoding", + "env-paths", + "err-code", + "extend", + "extsprintf", + "fast-deep-equal", + "fast-json-stable-stringify", + "forever-agent", + "form-data", + "fs-minipass", + "fs.realpath", + "function-bind", + "gauge", + "getpass", + "har-schema", + "har-validator", + "has", + "has-flag", + "has-unicode", + "http-cache-semantics", + "http-proxy-agent", + "http-signature", + "https-proxy-agent", + "humanize-ms", + "iconv-lite", + "ignore-walk", + "imurmurhash", + "indent-string", + "infer-owner", + "inflight", + "inherits", + "ip", + "ip-regex", + "is-core-module", + "is-fullwidth-code-point", + "is-lambda", + "is-typedarray", + "isarray", + "isexe", + "isstream", + "jsbn", + "json-schema", + "json-schema-traverse", + "json-stringify-nice", + "json-stringify-safe", + "jsonparse", + "jsprim", + "just-diff", + "just-diff-apply", + "lru-cache", + "mime-db", + "mime-types", + "minimatch", + "minipass-collect", + "minipass-fetch", + "minipass-flush", + "minipass-json-stream", + "minipass-sized", + "minizlib", + "mute-stream", + "normalize-package-data", + "npm-bundled", + "npm-install-checks", + "npm-normalize-package-bin", + "npm-packlist", + "number-is-nan", + "oauth-sign", + "object-assign", + "once", + "p-map", + "path-is-absolute", + "path-parse", + "performance-now", + "proc-log", + "process-nextick-args", + "promise-all-reject-late", + "promise-call-limit", + "promise-inflight", + "promise-retry", + "promzard", + "psl", + "punycode", + "qs", + "read-cmd-shim", + "readable-stream", + "request", + "resolve", + "retry", + "safe-buffer", + "safer-buffer", + "set-blocking", + "signal-exit", + "smart-buffer", + "socks", + "socks-proxy-agent", + "spdx-correct", + "spdx-exceptions", + "spdx-expression-parse", + "spdx-license-ids", + "sshpk", + "string_decoder", + "string-width", + "stringify-package", + "strip-ansi", + "supports-color", + "tunnel-agent", + "tweetnacl", + "typedarray-to-buffer", + "unique-filename", + "unique-slug", + "uri-js", + "util-deprecate", + "uuid", + "validate-npm-package-license", + "verror", + "walk-up-path", + "wcwidth", + "wide-align", + "wrappy", + "yallist" ], "dependencies": { "@npmcli/arborist": "^2.4.0", @@ -6132,6 +6311,7 @@ "inBundle": true, "license": "MIT", "dependencies": { + "colors": "^1.1.2", "object-assign": "^4.1.0", "string-width": "^4.2.0" }, @@ -7105,6 +7285,7 @@ "inBundle": true, "license": "MIT", "dependencies": { + "encoding": "^0.1.12", "minipass": "^3.1.0", "minipass-sized": "^1.0.3", "minizlib": "^2.0.0" diff --git a/packages/extension/package.json b/packages/extension/package.json index 714f491..29e6e2f 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,7 +4,7 @@ "publisher": "rikurouvila", "description": "🧪 Take samples of runtime values and turn them into type definitions automatically", "repository": "https://github.com/rikukissa/typehole", - "version": "1.5.0", + "version": "1.5.1", "private": true, "icon": "images/logo.png", "galleryBanner": { diff --git a/packages/extension/src/code-action.ts b/packages/extension/src/code-action.ts index f633639..88c76b4 100644 --- a/packages/extension/src/code-action.ts +++ b/packages/extension/src/code-action.ts @@ -1,5 +1,4 @@ import * as vscode from "vscode"; -import * as ts from "typescript"; import { isValidSelection } from "./parse/expression"; import { getAST } from "./parse/module"; import { diff --git a/packages/extension/src/commands/addATypehole.ts b/packages/extension/src/commands/addATypehole.ts index 449294e..eb9759b 100644 --- a/packages/extension/src/commands/addATypehole.ts +++ b/packages/extension/src/commands/addATypehole.ts @@ -1,17 +1,27 @@ -import * as ts from 'typescript'; -import * as vscode from 'vscode'; +import * as ts from "typescript"; +import * as vscode from "vscode"; -import { getEditorRange } from '../editor/utils'; +import { getEditorRange } from "../editor/utils"; import { getPlaceholderTypeName, insertRecorderToSelection, insertTypeholeImport, last, startRenamingPlaceholderType, -} from '../extension'; -import { findTypeholes, getAST, getNodeEndPosition, getNodeStartPosition, getParentOnRootLevel } from '../parse/module'; -import { getNextAvailableId } from '../state'; -import { getWrappingVariableDeclaration, insertGenericTypeParameter, insertTypeReference } from '../transforms/insertTypes'; +} from "../extension"; +import { + findTypeholes, + getAST, + getNodeEndPosition, + getNodeStartPosition, + getParentOnRootLevel, +} from "../parse/module"; +import { getNextAvailableId } from "../state"; +import { + getWrappingVariableDeclaration, + insertGenericTypeParameter, + insertTypeReference, +} from "../transforms/insertTypes"; export async function addATypehole() { const editor = vscode.window.activeTextEditor; @@ -22,10 +32,12 @@ export async function addATypehole() { const fullFile = document.getText(); const ast = getAST(fullFile); + const id = getNextAvailableId(); await editor.edit((editBuilder) => { insertTypeholeImport(ast, editBuilder); + insertRecorderToSelection(id, editor, editBuilder); }); @@ -39,6 +51,7 @@ export async function addATypehole() { getWrappingVariableDeclaration(newlyCreatedTypeHole); const typeName = getPlaceholderTypeName(updatedAST); + await editor.edit((editBuilder) => { if (variableDeclaration && !variableDeclaration.type) { insertTypeToVariableDeclaration( @@ -121,4 +134,3 @@ function insertTypeToVariableDeclaration( ); } } - diff --git a/packages/extension/src/commands/removeTypeholesFromAllFiles.ts b/packages/extension/src/commands/removeTypeholesFromAllFiles.ts index 432da8a..e021962 100644 --- a/packages/extension/src/commands/removeTypeholesFromAllFiles.ts +++ b/packages/extension/src/commands/removeTypeholesFromAllFiles.ts @@ -1,11 +1,11 @@ import * as vscode from "vscode"; import { unique } from "../parse/utils"; -import { getState } from "../state"; +import { getAllHoles } from "../state"; import { removeTypeholesFromFile } from "./removeTypeholesFromCurrentFile"; export async function removeTypeholesFromAllFiles() { - const holes = getState().holes; - const files = holes.map((h) => h.fileName).filter(unique); + const holes = getAllHoles(); + const files = holes.flatMap((h) => h.fileNames).filter(unique); for (const file of files) { let document: null | vscode.TextDocument = null; diff --git a/packages/extension/src/editor/utils.ts b/packages/extension/src/editor/utils.ts index b6564d7..4c310bb 100644 --- a/packages/extension/src/editor/utils.ts +++ b/packages/extension/src/editor/utils.ts @@ -1,5 +1,6 @@ import * as ts from "typescript"; import * as vscode from "vscode"; +import { getConfiguration } from "../config"; import { getNodeEndPosition, getNodeStartPosition } from "../parse/module"; export const getEditorRange = (node: ts.Node) => { @@ -20,3 +21,58 @@ export function getProjectURI() { export function getProjectPath() { return getProjectURI()?.path; } + +export async function getPackageJSONDirectories() { + const include = new vscode.RelativePattern( + getProjectURI()!, + "**/package.json" + ); + + const exclude = new vscode.RelativePattern( + getProjectURI()!, + "**/node_modules/**" + ); + + const files = await vscode.workspace.findFiles(include, exclude); + + // Done like this as findFiles didn't respect the exclude parameter + return files.filter((f) => !f.path.includes("node_modules")); +} + +export async function resolveProjectRoot( + document: vscode.TextDocument, + options: vscode.Uri[] +) { + const config = getConfiguration("", document.uri); + const answer = await vscode.window.showQuickPick( + options.map((o) => o.path.replace("/package.json", "")), + { + placeHolder: "Where should the runtime package be installed?", + } + ); + + if (answer) { + config.update( + "typehole.runtime.projectPath", + answer, + vscode.ConfigurationTarget.Workspace + ); + return answer; + } +} + +export async function getProjectRoot(document: vscode.TextDocument) { + const config = getConfiguration("", document.uri); + + const packageRoots = await getPackageJSONDirectories(); + + let projectPath = getProjectPath(); + + if (packageRoots.length > 1) { + return ( + config.projectPath || (await resolveProjectRoot(document, packageRoots)) + ); + } + + return projectPath; +} diff --git a/packages/extension/src/ensureRuntime.ts b/packages/extension/src/ensureRuntime.ts index 142ac54..262d61c 100644 --- a/packages/extension/src/ensureRuntime.ts +++ b/packages/extension/src/ensureRuntime.ts @@ -1,9 +1,9 @@ +import { install, setPackageManager, setRootDir } from "lmify"; import * as vscode from "vscode"; -import { install, setRootDir, setPackageManager } from "lmify"; -import { log, error } from "./logger"; -import { getProjectPath, getProjectURI } from "./editor/utils"; import { getConfiguration, PackageManager } from "./config"; +import { getProjectRoot } from "./editor/utils"; +import { error, log } from "./logger"; /* * A bit of a hack as require.resolve doesn't update it's cache @@ -12,19 +12,6 @@ import { getConfiguration, PackageManager } from "./config"; let runtimeWasInstalledWhileExtensionIsRunning = false; - -async function getPackageJSONDirectories() { - const include = new vscode.RelativePattern( - getProjectURI()!, - "**/package.json" - ); - - const files = await vscode.workspace.findFiles(include); - - // Done like this as findFiles didn't respect the exclude parameter - return files.filter((f) => !f.path.includes("node_modules")); -} - async function detectPackageManager(): Promise { const npmLocks = await vscode.workspace.findFiles("package-lock.json"); const yarnLocks = await vscode.workspace.findFiles("yarn.lock"); @@ -38,28 +25,6 @@ async function detectPackageManager(): Promise { } } -async function resolveProjectRoot( - document: vscode.TextDocument, - options: vscode.Uri[] -) { - const config = getConfiguration("", document.uri); - const answer = await vscode.window.showQuickPick( - options.map((o) => o.path.replace("/package.json", "")), - { - placeHolder: "Where should the runtime package be installed?", - } - ); - - if (answer) { - config.update( - "typehole.runtime.projectPath", - answer, - vscode.ConfigurationTarget.Workspace - ); - return answer; - } -} - async function getPackageManager(document: vscode.TextDocument) { const config = getConfiguration("", document.uri); if (config.packageManager) { @@ -90,7 +55,7 @@ function isRuntimeInstalled(projectRoot: string) { try { log("Searching for runtime library in", projectRoot); require.resolve("typehole", { - paths: [projectRoot] + paths: [projectRoot], }); return true; } catch (error) { @@ -121,19 +86,15 @@ export async function ensureRuntime() { } setPackageManager(packageManager); - const packageRoots = await getPackageJSONDirectories(); - let projectPath = getProjectPath(); - - if (packageRoots.length > 1) { - projectPath = - config.projectPath || (await resolveProjectRoot(document, packageRoots)); - } + const projectPath = await getProjectRoot(document); if (!projectPath) { return; } - const installed = isRuntimeInstalled(projectPath) || runtimeWasInstalledWhileExtensionIsRunning; + const installed = + isRuntimeInstalled(projectPath) || + runtimeWasInstalledWhileExtensionIsRunning; if (!installed && config.autoInstall) { installing = true; @@ -165,5 +126,4 @@ export async function ensureRuntime() { "yarn add typehole"`); return; } - -} \ No newline at end of file +} diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 8ac0ab4..5df0f68 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -26,6 +26,7 @@ import { TypeHoler } from "./code-action"; import { clearWarnings, events, + getAllHoles, getState, onFileChanged, onFileDeleted, @@ -45,27 +46,12 @@ export const last = (arr: T[]) => arr[arr.length - 1]; export function getPlaceholderTypeName(document: ts.SourceFile) { let n = 0; - let results = tsquery - .query(document, `TypeAliasDeclaration > Identifier[name="AutoDiscovered"]`) - .concat( - tsquery.query( - document, - `InterfaceDeclaration > Identifier[name="AutoDiscovered"]` - ) - ); + let results = tsquery.query(document, `Identifier[name="AutoDiscovered"]`); while (results.length > 0) { n++; - results = tsquery.query( - document, - `TypeAliasDeclaration > Identifier[name="AutoDiscovered${n}"]` - ).concat( - tsquery.query( - document, - `InterfaceDeclaration > Identifier[name="AutoDiscovered${n}"]` - ) - ); + results = tsquery.query(document, `Identifier[name="AutoDiscovered${n}"]`); } return "AutoDiscovered" + (n === 0 ? "" : n); @@ -150,10 +136,12 @@ export async function activate(context: vscode.ExtensionContext) { let previousState = getState(); events.on("change", async (newState: State) => { - const allHolesRemoved = - previousState.holes.length > 0 && newState.holes.length === 0; + const previousHoles = Object.values(previousState.holes); + const newHoles = Object.values(newState.holes); + const allHolesRemoved = previousHoles.length > 0 && newHoles.length === 0; - const shouldEnsureRuntime = previousState.holes.length !== newState.holes.length && newState.holes.length > 0 + const shouldEnsureRuntime = + previousHoles.length !== newHoles.length && newHoles.length > 0; previousState = newState; @@ -167,7 +155,7 @@ export async function activate(context: vscode.ExtensionContext) { await ensureRuntime(); } - if (newState.holes.length > 0 && !isServerRunning()) { + if (newHoles.length > 0 && !isServerRunning()) { try { vscode.window.showInformationMessage("Typehole: Starting server..."); await startListenerServer(); @@ -191,7 +179,7 @@ export async function activate(context: vscode.ExtensionContext) { ); await Promise.all(existingFiles.map(fileChanged)); - const holes = getState().holes; + const holes = getAllHoles(); log("Found", holes.length.toString(), "holes in the workspace"); /* diff --git a/packages/extension/src/listener.ts b/packages/extension/src/listener.ts index 8cfff11..d5ae65f 100644 --- a/packages/extension/src/listener.ts +++ b/packages/extension/src/listener.ts @@ -1,15 +1,18 @@ +import { tsquery } from "@phenomnomnominal/tsquery"; import f from "fastify"; import * as ts from "typescript"; import * as vscode from "vscode"; -import { getEditorRange } from "./editor/utils"; +import { getEditorRange, getProjectRoot } from "./editor/utils"; import { error, log } from "./logger"; -import { findTypeholes, getAST } from "./parse/module"; -import { addSample, addWarning } from "./state"; +import { findTypeholes, getAST, resolveImportPath } from "./parse/module"; +import { addSample, addWarning, getHole, Typehole } from "./state"; import { + findDeclarationWithName, getAllDependencyTypeDeclarations, getTypeAliasForId, + getTypeReferenceNameForId, } from "./transforms/insertTypes"; import { samplesToType } from "./transforms/samplesToType"; @@ -91,40 +94,112 @@ export async function stopListenerServer() { } async function onTypeExtracted(id: string, types: string) { - const editor = vscode.window.activeTextEditor; - const document = editor?.document; - if (!editor || !document) { + const hole = getHole(id); + + if (!hole) { + error("Hole", id, "was not found. This is not supposed to happen"); + return; + } + + for (const fileName of hole.fileNames) { + await updateTypes(hole, types, fileName); + } +} + +async function updateTypes(hole: Typehole, types: string, fileName: string) { + let document = await vscode.workspace.openTextDocument( + vscode.Uri.file(fileName) + ); + + if (!document) { + error( + "Document", + fileName, + "a typehole was referring to was not found. This is not supposed to happen" + ); return; } - const ast = getAST(editor.document.getText()); - const typeAliasNode = getTypeAliasForId(id, ast); + let ast = getAST(document.getText()); + + let typeAliasNode = getTypeAliasForId(hole.id, ast); + if (!typeAliasNode) { return; } - const typeName = typeAliasNode.getText(); + const typeName = getTypeReferenceNameForId(hole.id, ast)!; + + /* + * Type is imported from another file + */ + const typeIsImportedFromAnotherFile = ts.isImportDeclaration(typeAliasNode); + if (typeIsImportedFromAnotherFile) { + const relativePath = tsquery(typeAliasNode, "StringLiteral")[0] + ?.getText() + // "./types.ts" -> types.ts + .replace(/["']/g, ""); + + const projectRoot = await getProjectRoot(document); + if (!projectRoot) { + return error("No project root was found when resolving module import"); + } + + const absolutePath = resolveImportPath( + projectRoot, + relativePath, + document.uri.path + ); + + if (!absolutePath) { + return error("TS Compiler couldn't resolve the import path"); + } + + try { + document = await vscode.workspace.openTextDocument( + vscode.Uri.file(absolutePath) + ); + } catch (err) { + return error( + "Failed to open the document the imported type is referring to", + absolutePath, + err.message + ); + } + + ast = getAST(document.getText()); + typeAliasNode = findDeclarationWithName(typeName, ast); + if (!typeAliasNode) { + return; + } + } + + const exported = tsquery(typeAliasNode.parent, "ExportKeyword").length > 0; const existingDeclarations = getAllDependencyTypeDeclarations( typeAliasNode.parent ); - const typesToBeInserted = types.replace("TypeholeRoot", typeName).trim(); + const typesToBeInserted = + (exported ? "export " : "") + + types.replace("TypeholeRoot", typeName).trim(); - await editor.edit((editBuilder) => { - existingDeclarations.forEach((node) => { - const range = getEditorRange(node); - editBuilder.delete(range); - }); + const workEdits = new vscode.WorkspaceEdit(); - editBuilder.insert( - getEditorRange(typeAliasNode.parent).start, - typesToBeInserted - ); + existingDeclarations.forEach((node) => { + const range = getEditorRange(node); + workEdits.delete(document?.uri!, range); }); + workEdits.insert( + document.uri, + getEditorRange(typeAliasNode!.parent).start, + typesToBeInserted + ); + + vscode.workspace.applyEdit(workEdits); + try { - // TODO We could also only format the types we added await vscode.commands.executeCommand("editor.action.formatDocument"); } catch (err) { error("Formatting the document failed", err.message); diff --git a/packages/extension/src/parse/module.ts b/packages/extension/src/parse/module.ts index d6c6657..7e843fe 100644 --- a/packages/extension/src/parse/module.ts +++ b/packages/extension/src/parse/module.ts @@ -6,6 +6,53 @@ export function findTypeHoleImports(ast: ts.Node) { .query(ast, "ImportDeclaration > StringLiteral[text='typehole']") .map((s) => s.parent); } + +export function resolveImportPath( + projectRoot: string, + moduleName: string, + containingFile: string +) { + const configFileName = ts.findConfigFile( + projectRoot, + ts.sys.fileExists, + "tsconfig.json" + ); + + if (!configFileName) { + return null; + } + + const configFile = ts.readConfigFile(configFileName, ts.sys.readFile); + + const compilerOptions = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + "./", + undefined, + configFileName + ); + + function fileExists(fileName: string): boolean { + return ts.sys.fileExists(fileName); + } + + function readFile(fileName: string): string | undefined { + return ts.sys.readFile(fileName); + } + + const result = ts.resolveModuleName( + moduleName, + containingFile, + compilerOptions.options, + { + fileExists, + readFile, + } + ); + + return result.resolvedModule!.resolvedFileName; +} + export function findLastImport(ast: ts.Node) { const imports = tsquery.query(ast, "ImportDeclaration"); return imports[imports.length - 1]; @@ -34,7 +81,6 @@ export function getTypeHoleImport() { ); } - export function printAST(ast: ts.Node, sourceFile?: ts.SourceFile) { const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); @@ -83,3 +129,16 @@ export function someParentIs( } return someParentIs(node.parent, test); } + +export function getParentWithType( + node: ts.Node, + kind: ts.SyntaxKind +): T | null { + if (!node.parent) { + return null; + } + if (node.kind === kind) { + return node as unknown as T; + } + return getParentWithType(node.parent, kind); +} diff --git a/packages/extension/src/parse/utils.ts b/packages/extension/src/parse/utils.ts index 4d4b297..85c7da2 100644 --- a/packages/extension/src/parse/utils.ts +++ b/packages/extension/src/parse/utils.ts @@ -89,3 +89,8 @@ export function lineCharacterPositionInText( export function unique(value: T, index: number, self: T[]) { return self.indexOf(value) === index; } + +export function omit(original: T, key: keyof T) { + const { [key]: value, ...withoutKey } = original; + return withoutKey; +} diff --git a/packages/extension/src/state.ts b/packages/extension/src/state.ts index 4c63f16..407f6e9 100644 --- a/packages/extension/src/state.ts +++ b/packages/extension/src/state.ts @@ -3,15 +3,16 @@ import * as vscode from "vscode"; import { getId } from "./hole"; import { log } from "./logger"; import { findTypeholes, getAST } from "./parse/module"; +import { omit, unique } from "./parse/utils"; export const events = new EventEmitter(); -type TypeHole = { id: string; fileName: string }; +export type Typehole = { id: string; fileNames: string[] }; let state = { nextUniqueId: 0, warnings: {} as Record, - holes: [] as TypeHole[], + holes: {} as Record, samples: {} as Record, }; @@ -80,28 +81,50 @@ function clearSamples(id: string, currentState: typeof state) { } function createTypehole(id: string, fileName: string) { - const hole = { id, fileName }; + const existingHole = getHole(id); + const hole = existingHole + ? { id, fileNames: existingHole.fileNames.concat(fileName).filter(unique) } + : { id, fileNames: [fileName] }; const currentState = getState(); setState({ ...currentState, nextUniqueId: currentState.nextUniqueId + 1, - holes: [...currentState.holes, hole], + holes: { ...currentState.holes, [id]: hole }, }); } -function removeTypehole(id: string) { +function removeTypeholeFromFile(id: string, fileName: string) { const currentState = getState(); - setState( - clearSamples(id, { - ...currentState, - holes: currentState.holes.filter((h) => h.id !== id), - }) + const hole = getHole(id); + if (!hole) { + return; + } + const fileFilesWithoutFile = hole?.fileNames.filter( + (file) => file !== fileName ); + const wasOnlyFileWithTypehole = fileFilesWithoutFile.length === 0; + + if (wasOnlyFileWithTypehole) { + const newHoles = omit(currentState.holes, id); + setState( + clearSamples(id, { + ...currentState, + holes: newHoles, + }) + ); + } else { + const holeWithoutFile = { ...hole, fileNames: fileFilesWithoutFile }; + setState({ + ...currentState, + holes: { ...currentState.holes, [id]: holeWithoutFile }, + }); + } } function setState(newState: typeof state): void { state = newState; + events.emit("change", newState); } @@ -109,15 +132,19 @@ export function getState() { return state; } +export function getAllHoles() { + return Object.values(getState().holes); +} + export function onFileDeleted(fileName: string) { - getState() - .holes.filter((hole) => hole.fileName === fileName) - .forEach((h) => removeTypehole(h.id)); + getAllHoles() + .filter((hole) => hole.fileNames.includes(fileName)) + .forEach((h) => removeTypeholeFromFile(h.id, fileName)); } export function onFileChanged(fileName: string, content: string) { - const knownHolesInThisFile = state.holes.filter( - (hole) => hole.fileName === fileName + const knownHolesInThisFile = getAllHoles().filter((hole) => + hole.fileNames.includes(fileName) ); const knownIds = knownHolesInThisFile.map(({ id }) => id); @@ -135,11 +162,11 @@ export function onFileChanged(fileName: string, content: string) { knownIds.forEach((holeId) => { const holeHasBeenRemoved = !holesInDocument.includes(holeId); if (holeHasBeenRemoved) { - removeTypehole(holeId); + removeTypeholeFromFile(holeId, fileName); } }); } -export function getHole(id: string) { - return state.holes.find((hole) => hole.id === id); +export function getHole(id: string): Typehole | undefined { + return state.holes[id]; } diff --git a/packages/extension/src/transforms/insertTypes/index.test.ts b/packages/extension/src/transforms/insertTypes/index.test.ts index 5787030..472ba1d 100644 --- a/packages/extension/src/transforms/insertTypes/index.test.ts +++ b/packages/extension/src/transforms/insertTypes/index.test.ts @@ -1,6 +1,6 @@ import { tsquery } from "@phenomnomnominal/tsquery"; import { getAST } from "../../parse/module"; - +import * as ts from "typescript"; import { findAllDependencyTypeDeclarations, getAllDependencyTypeDeclarations, @@ -99,3 +99,17 @@ test("finds all dependency type declarations from an ast when there are array ty findAllDependencyTypeDeclarations(node.parent).map((n) => n.name.getText()) ).toEqual(["AutoDiscovered", "IRootObjectItem"]); }); + +test("finds dependent types from a ParenthesizedType", () => { + const ast = getAST(`export type Foo = (TypeholeRootWrapper | number); + interface TypeholeRootWrapper { + a: number; + } + `); + + const node = tsquery.query(ast, 'Identifier[name="Foo"]')[0]; + + expect( + findAllDependencyTypeDeclarations(node.parent).map((n) => n.name.getText()) + ).toEqual(["Foo", "TypeholeRootWrapper"]); +}); diff --git a/packages/extension/src/transforms/insertTypes/index.ts b/packages/extension/src/transforms/insertTypes/index.ts index 8f37608..d4d3fea 100644 --- a/packages/extension/src/transforms/insertTypes/index.ts +++ b/packages/extension/src/transforms/insertTypes/index.ts @@ -1,19 +1,48 @@ import * as ts from "typescript"; import { tsquery } from "@phenomnomnominal/tsquery"; -import { findTypeholes, printAST } from "../../parse/module"; +import { findTypeholes, getParentWithType, printAST } from "../../parse/module"; import { unique } from "../../parse/utils"; +function findDeclarationInImportedDeclarations( + name: string, + ast: ts.Node +): ts.ImportDeclaration | null { + return tsquery + .query(ast, `ImportSpecifier > Identifier[name="${name}"]`) + .concat(tsquery.query(ast, `ImportClause > Identifier[name="${name}"]`)) + .map((node) => + getParentWithType( + node, + ts.SyntaxKind.ImportDeclaration + ) + )[0]; +} + function findDeclarationsWithName(name: string, ast: ts.Node) { - return tsquery.query( + const res = tsquery.query( ast, `:declaration > Identifier[name="${name}"]` ); + + return res; } -function findDeclarationWithName(name: string, ast: ts.Node): ts.Node | null { +export function findDeclarationWithName( + name: string, + ast: ts.Node +): + | ts.TypeAliasDeclaration + | ts.InterfaceDeclaration + | ts.ImportDeclaration + | null { const results = findDeclarationsWithName(name, ast); if (results.length === 0) { - return null; + const importStatement = findDeclarationInImportedDeclarations(name, ast); + if (!importStatement) { + return null; + } + + return importStatement; } return results[0]; } @@ -113,6 +142,10 @@ export function findAllDependencyTypeDeclarations( ]; }); } + // (TypeholeRootWrapper | number) + if (ts.isParenthesizedTypeNode(node)) { + return findAllDependencyTypeDeclarations(node.type, [...found]); + } if ( ts.isArrayTypeNode(node) && ts.isParenthesizedTypeNode(node.elementType) @@ -126,7 +159,10 @@ export function findAllDependencyTypeDeclarations( return []; } -export function getTypeAliasForId(id: string, ast: ts.Node) { +export function getTypeReferenceNameForId( + id: string, + ast: ts.Node +): string | null { const holes = findTypeholes(ast); const hole = holes.find( @@ -136,7 +172,7 @@ export function getTypeAliasForId(id: string, ast: ts.Node) { ); if (!hole) { - return; + return null; } const holeHasTypeVariable = @@ -145,8 +181,7 @@ export function getTypeAliasForId(id: string, ast: ts.Node) { hole.typeArguments.length > 0; if (holeHasTypeVariable) { - const typeReference = hole.typeArguments![0].getText(); - return findDeclarationWithName(typeReference, ast); + return hole.typeArguments![0].getText(); } const variableDeclaration = getWrappingVariableDeclaration(hole); @@ -159,11 +194,25 @@ export function getTypeAliasForId(id: string, ast: ts.Node) { const typeReference = ( variableDeclaration as ts.VariableDeclaration ).type!.getText(); - return findDeclarationWithName(typeReference, ast); + return typeReference; } - return null; } +export function getTypeAliasForId( + id: string, + ast: ts.Node +): + | ts.ImportDeclaration + | ts.TypeAliasDeclaration + | ts.InterfaceDeclaration + | null { + const name = getTypeReferenceNameForId(id, ast); + if (!name) { + return null; + } + + return findDeclarationWithName(name, ast); +} export function getWrappingVariableDeclaration( node: ts.Node diff --git a/packages/runtime/package-lock.json b/packages/runtime/package-lock.json index 53888e7..844e04c 100644 --- a/packages/runtime/package-lock.json +++ b/packages/runtime/package-lock.json @@ -1,12 +1,12 @@ { "name": "typehole", - "version": "1.2.1", + "version": "1.5.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "typehole", - "version": "1.2.1", + "version": "1.5.1", "license": "MIT", "dependencies": { "@types/isomorphic-fetch": "0.0.35", @@ -365,6 +365,9 @@ "version": "4.0.0", "dev": true, "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.6" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -519,6 +522,9 @@ "version": "2.21.0", "dev": true, "license": "MIT", + "dependencies": { + "fsevents": "~2.1.2" + }, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 12fd4c1..4f9e818 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,5 +1,5 @@ { - "version": "1.2.1", + "version": "1.5.1", "name": "typehole", "repository": "rikukissa/typehole", "description": "Turn runtime types into static typescript types automatically", diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index cccf6cd..ec5af43 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -46,22 +46,6 @@ function sendSample(holeId: HoleId, input: any) { return sampleQueue; } -const debounce = any>( - func: F, - waitFor: number -) => { - let timeout: NodeJS.Timeout; - - return (...args: Parameters): Promise> => - new Promise((resolve) => { - if (timeout) { - clearTimeout(timeout); - } - - timeout = setTimeout(() => resolve(func(...args)), waitFor); - }); -}; - async function solveWrapperTypes(value: any) { if (typeof value?.then === "function") { return { @@ -73,7 +57,7 @@ async function solveWrapperTypes(value: any) { } function typeholeFactory(id: HoleId) { - const emitSample = debounce(sendSample, 300); + const emitSample = sendSample; let previousValue: string | null = null; return function typehole(input: T): T {