diff --git a/.changeset/hot-mice-argue.md b/.changeset/hot-mice-argue.md new file mode 100644 index 00000000..09f65459 --- /dev/null +++ b/.changeset/hot-mice-argue.md @@ -0,0 +1,5 @@ +--- +"@ec-ts/twoslash": major +--- + +Init ESM-only fork of Twoslash for the expressive-code-twoslash project diff --git a/.github/labeler.yml b/.github/labeler.yml index 6c8a13f1..186bb2de 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,21 +1,26 @@ # See https://github.com/actions/labeler/tree/main for more information about the labeler action -twoslash: - - changed-files: - - any-glob-to-any-file: - - packages/expressive-code-twoslash/* - css-js-gen: - - changed-files: - - any-glob-to-any-file: - - packages/css-js-gen/* + - changed-files: + - any-glob-to-any-file: + - packages/css-js-gen/* + +docs: + - changed-files: + - any-glob-to-any-file: + - docs/* + +ec-ts:twoslash: + - changed-files: + - any-glob-to-any-file: + - packages/@ec-ts/twoslash/* ec-ts:vfs: - - changed-files: - - any-glob-to-any-file: - - packages/@ec-ts/vfs/* + - changed-files: + - any-glob-to-any-file: + - packages/@ec-ts/vfs/* -docs: - - changed-files: - - any-glob-to-any-file: - - docs/* +twoslash: + - changed-files: + - any-glob-to-any-file: + - packages/expressive-code-twoslash/* diff --git a/.vscode/settings.json b/.vscode/settings.json index 030331d9..c74a892d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -46,6 +46,7 @@ "mondeja", "nightlies", "noline", + "ohash", "omglookatthis", "peaceiris", "recompiles", diff --git a/allurerc.mjs b/allurerc.mjs index dd4b73f5..f09852b1 100644 --- a/allurerc.mjs +++ b/allurerc.mjs @@ -1,5 +1,29 @@ import { defineConfig } from "allure"; +// These are the packages that have tests and should be included in the Allure report. The report will be grouped by parentSuite, suite, and subSuite, and will only include tests that have a label with name "parentSuite" and value `${pkg} Tests`. +const packagesWithTests = ["css-js-gen", "@ec-ts/twoslash", "@ec-ts/vfs"]; + +// Create a plugins configuration object for each package with tests, using the @allurereport/plugin-awesome plugin. The report will be named `${pkg} Tests`, and will be published to the Allure server. +const pluginsConfig = Object.fromEntries( + packagesWithTests.map((pkg) => [ + pkg, + { + import: "@allurereport/plugin-awesome", + options: { + reportName: `${pkg} Tests`, + singleFile: false, + reportLanguage: "en", + open: false, + publish: true, + groupBy: ["parentSuite", "suite", "subSuite"], + filter: ({ labels }) => + labels.find(({ name, value }) => name === "parentSuite" && value === `${pkg} Tests`), + }, + }, + ]), +); + +// Export the Allure configuration object, which includes the plugins configuration for each package with tests, as well as some general settings for the Allure report. export default defineConfig({ name: "Allure Report", output: "./allure-report", @@ -15,32 +39,7 @@ export default defineConfig({ ], }, plugins: { - "css-js-gen": { - import: "@allurereport/plugin-awesome", - options: { - reportName: "css-js-gen Tests", - singleFile: false, - reportLanguage: "en", - open: false, - publish: true, - groupBy: ["parentSuite", "suite", "subSuite"], - filter: ({ labels }) => - labels.find(({ name, value }) => name === "parentSuite" && value === "css-js-gen Tests"), - }, - }, - "@ec-ts/vfs": { - import: "@allurereport/plugin-awesome", - options: { - reportName: "@ec-ts/vfs Tests", - singleFile: false, - reportLanguage: "en", - open: false, - publish: true, - groupBy: ["parentSuite", "suite", "subSuite"], - filter: ({ labels }) => - labels.find(({ name, value }) => name === "parentSuite" && value === "@ec-ts/vfs Tests"), - }, - }, + ...pluginsConfig, log: { options: { groupBy: "none", diff --git a/biome.json b/biome.json index f3ed3328..58af5fea 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/package.json b/package.json index 9416a631..ad7c9827 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "docs:build": "pnpm build && pnpm --filter docs-twoslash build", "docs:dev": "pnpm build && pnpm --filter docs-twoslash dev", - "build": "pnpm --filter './packages/*' build", - "dev": "pnpm --filter './packages/*' --filter playground -r --parallel dev", + "build": "pnpm --filter './packages/**' build", + "dev": "pnpm --filter './packages/**' --filter playground -r --parallel dev", "test": "vitest", "ci:install": "pnpm install --frozen-lockfile", diff --git a/packages/@ec-ts/twoslash/LICENSE b/packages/@ec-ts/twoslash/LICENSE new file mode 100644 index 00000000..5e648f2c --- /dev/null +++ b/packages/@ec-ts/twoslash/LICENSE @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Copyright (c) Microsoft Corporation +Copyright (c) 2023-PRESENT Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/@ec-ts/twoslash/README.md b/packages/@ec-ts/twoslash/README.md new file mode 100755 index 00000000..09b9db1d --- /dev/null +++ b/packages/@ec-ts/twoslash/README.md @@ -0,0 +1,34 @@ +Twoslash Logo + +# Twoslash + +[![npm version][npm-version-src]][npm-version-href] +[![npm downloads][npm-downloads-src]][npm-downloads-href] +[![bundle][bundle-src]][bundle-href] +[![JSDocs][jsdocs-src]][jsdocs-href] +[![License][license-src]][license-href] + +> This is an ESM-only fork of Twoslash for [Expressive Code Twoslash](https://github.com/withstudiocms/expressive-code-twoslash) + +[📚 Documentation](https://twoslash.netlify.app/) | [⚙️ Migration Guide](https://twoslash.netlify.app/guide/migrate) + +A markup format for TypeScript code, ideal for creating self-contained code samples which let the TypeScript compiler do the extra leg-work. Inspired by the [fourslash test system](https://github.com/orta/typescript-notes/blob/master/systems/testing/fourslash.md). + +This project is the successor of [`@typescript/twoslash`](https://github.com/microsoft/TypeScript-Website/tree/v2/packages/ts-twoslasher). + +## License + +MIT License © [Orta Therox](https://github.com/orta), [Anthony Fu](https://github.com/antfu), Microsoft Corporation + + + +[npm-version-src]: https://img.shields.io/npm/v/twoslash?style=flat&colorA=161514&colorB=EAB836 +[npm-version-href]: https://npmjs.com/package/twoslash +[npm-downloads-src]: https://img.shields.io/npm/dm/twoslash?style=flat&colorA=161514&colorB=E66041 +[npm-downloads-href]: https://npmjs.com/package/twoslash +[bundle-src]: https://img.shields.io/bundlephobia/minzip/twoslash?style=flat&colorA=161514&colorB=45627B&label=minzip +[bundle-href]: https://bundlephobia.com/result?p=twoslash +[license-src]: https://img.shields.io/github/license/twoslashes/twoslash.svg?style=flat&colorA=161514&colorB=45627B +[license-href]: https://github.com/twoslashes/twoslash/blob/main/LICENSE +[jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-161514?style=flat&colorA=161514&colorB=EAB836 +[jsdocs-href]: https://www.jsdocs.io/package/twoslash diff --git a/packages/@ec-ts/twoslash/package.json b/packages/@ec-ts/twoslash/package.json new file mode 100755 index 00000000..62317849 --- /dev/null +++ b/packages/@ec-ts/twoslash/package.json @@ -0,0 +1,62 @@ +{ + "name": "@ec-ts/twoslash", + "type": "module", + "version": "0.0.0", + "description": "Markup for generating rich type information in your documentation ahead of time", + "author": "TypeScript team", + "license": "MIT", + "homepage": "https://github.com/withstudiocms/expressive-code-twoslash", + "repository": { + "url": "https://github.com/withstudiocms/expressive-code-twoslash", + "type": "git", + "directory": "packages/@ec-ts/twoslash" + }, + "bugs": { + "url": "https://github.com/withstudiocms/expressive-code-twoslash/issues" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./core": { + "types": "./dist/core.d.ts", + "import": "./dist/core.js" + }, + "./fallback": { + "types": "./dist/fallback.d.ts", + "import": "./dist/fallback.js" + } + }, + "typesVersions": { + "*": { + "./core": [ + "./dist/core.d.ts" + ], + "./fallback": [ + "./dist/fallback.d.ts" + ], + "*": [ + "./dist/index.d.ts" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown --config ./tsdown.config.ts", + "dev": "tsdown --config ./tsdown.config.ts --watch --no-clean", + "test": "vitest" + }, + "peerDependencies": { + "typescript": "^5.5.0" + }, + "dependencies": { + "@ec-ts/vfs": "workspace:^", + "twoslash-protocol": "catalog:twoslash" + }, + "devDependencies": { + "ohash": "^2.0.11" + } +} diff --git a/packages/@ec-ts/twoslash/scripts/flag-keys.ts b/packages/@ec-ts/twoslash/scripts/flag-keys.ts new file mode 100644 index 00000000..84dbdaa7 --- /dev/null +++ b/packages/@ec-ts/twoslash/scripts/flag-keys.ts @@ -0,0 +1,22 @@ +import fs from "node:fs/promises"; +import ts from "typescript"; +import { defaultHandbookOptions } from "../src/defaults"; +import type { CompilerOptionDeclaration } from "../src/types/options"; + +async function generateFlagKeys() { + // biome-ignore lint/suspicious/noExplicitAny: This is the design of the Source API + const tsOptionDeclarations = (ts as any).optionDeclarations as CompilerOptionDeclaration[]; + + const keys = [ + ...tsOptionDeclarations.map((i) => i.name), + ...Object.keys(defaultHandbookOptions), + ].sort(); + + await fs.writeFile( + "src/flag-keys.ts", + `// Generated by scripts/flag-keys.ts\nexport const flagKeys = ${JSON.stringify(keys, null, 2)}`, + "utf-8", + ); +} + +generateFlagKeys(); diff --git a/packages/@ec-ts/twoslash/src/core.ts b/packages/@ec-ts/twoslash/src/core.ts new file mode 100644 index 00000000..9ea7725d --- /dev/null +++ b/packages/@ec-ts/twoslash/src/core.ts @@ -0,0 +1,637 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: This is the design of the Source API */ +/** biome-ignore-all lint/suspicious/noExplicitAny: This is the design of the Source API */ +import { createFSBackedSystem, createSystem, createVirtualTypeScriptEnvironment } from "@ec-ts/vfs"; +import type { + ErrorLevel, + NodeError, + NodeWithoutPosition, + Position, + Range, +} from "twoslash-protocol"; +import { + createPositionConverter, + isInRange, + isInRanges, + removeCodeRanges, + resolveNodePositions, +} from "twoslash-protocol"; +import type { + CompilerOptions, + CompletionEntry, + CompletionTriggerKind, + DiagnosticCategory, + JsxEmit, + System, +} from "typescript"; +import { defaultCompilerOptions, defaultHandbookOptions } from "./defaults.ts"; +import { TwoslashError } from "./error.ts"; +import type { + CompilerOptionDeclaration, + CreateTwoslashOptions, + TwoslashExecuteOptions, + TwoslashInstance, + TwoslashOptions, + TwoslashReturn, + TwoslashReturnMeta, + VirtualFile, +} from "./types/index.ts"; +import { + findCutNotations, + findFlagNotations, + findQueryMarkers, + getExtension, + getIdentifierTextSpans, + getObjectHash, + removeTsExtension, + splitFiles, + typesToExtension, +} from "./utils.ts"; + +import { validateCodeForErrors } from "./validation.ts"; + +export * from "./public.ts"; + +type TS = typeof import("typescript"); + +/** + * Create a Twoslash instance with cached TS environments + */ +export function createTwoslasher(createOptions: CreateTwoslashOptions = {}): TwoslashInstance { + const ts: TS = createOptions.tsModule!; + const tsOptionDeclarations = (ts as any).optionDeclarations as CompilerOptionDeclaration[]; + + // In a browser we want to DI everything, in node we can use local infra + const useFS = !!createOptions.fsMap; + const _root = createOptions.vfsRoot!.replace(/\\/g, "/"); // Normalize slashes + const vfs = createOptions.fsMap || new Map(); + const system = useFS + ? createSystem(vfs) + : createCacheableFSBackedSystem( + vfs, + _root, + ts, + createOptions.tsLibDirectory, + createOptions.fsCache, + ); + const fsRoot = useFS ? "/" : `${_root}/`; + + const cache = + createOptions.cache === false + ? undefined + : createOptions.cache instanceof Map + ? createOptions.cache + : new Map>(); + + function getEnv(compilerOptions: CompilerOptions) { + if (!cache) + return createVirtualTypeScriptEnvironment( + system, + [], + ts as any, + compilerOptions, + createOptions.customTransformers, + ); + const key = getObjectHash(compilerOptions); + if (!cache?.has(key)) { + const env = createVirtualTypeScriptEnvironment( + system, + [], + ts as any, + compilerOptions, + createOptions.customTransformers, + ); + cache?.set(key, env); + return env; + } + return cache.get(key)!; + } + + function twoslasher( + code: string, + extension = "ts", + options: TwoslashExecuteOptions = {}, + ): TwoslashReturn { + const meta: TwoslashReturnMeta = { + extension: typesToExtension(extension), + compilerOptions: { + ...defaultCompilerOptions, + baseUrl: fsRoot, + ...createOptions.compilerOptions, + ...options.compilerOptions, + }, + handbookOptions: { + ...defaultHandbookOptions, + ...createOptions.handbookOptions, + ...options.handbookOptions, + }, + removals: [], + flagNotations: [], + virtualFiles: [], + positionQueries: options.positionQueries || [], + positionCompletions: options.positionCompletions || [], + positionHighlights: options.positionHighlights || [], + }; + const { + customTags = createOptions.customTags || [], + shouldGetHoverInfo = createOptions.shouldGetHoverInfo || (() => true), + filterNode = createOptions.filterNode, + extraFiles = createOptions.extraFiles || {}, + } = options; + + const defaultFilename = `index.${meta.extension}`; + let nodes: NodeWithoutPosition[] = []; + const isInRemoval = (index: number) => + index >= code.length || index < 0 || isInRanges(index, meta.removals, false); + + meta.flagNotations = findFlagNotations(code, customTags, tsOptionDeclarations); + + // #region apply flags + for (const flag of meta.flagNotations) { + switch (flag.type) { + case "unknown": + continue; + + case "compilerOptions": + meta.compilerOptions[flag.name] = flag.value; + break; + case "handbookOptions": + // @ts-expect-error -- this is fine + meta.handbookOptions[flag.name] = flag.value; + break; + case "tag": + nodes.push({ + type: "tag", + name: flag.name, + start: flag.end, + length: 0, + text: flag.value, + }); + break; + } + meta.removals.push([flag.start, flag.end]); + } + + if (!meta.handbookOptions.noErrorValidation) { + const unknownFlags = meta.flagNotations.filter((i) => i.type === "unknown"); + if (unknownFlags.length) { + throw new TwoslashError( + `Unknown inline compiler flags`, + `The following flags are either valid TSConfig nor handbook options:\n${unknownFlags.map((i) => `@${i.name}`).join(", ")}`, + `This is likely a typo, you can check all the compiler flags in the TSConfig reference, or check the additional Twoslash flags in the npm page for @typescript/twoslash.`, + ); + } + } + // #endregion + + const env = getEnv(meta.compilerOptions); + const ls = env.languageService; + const pc = createPositionConverter(code); + + // extract cuts + findCutNotations(code, meta); + // extract markers + findQueryMarkers(code, meta, pc); + + const supportedFileTyes = ["js", "jsx", "ts", "tsx"]; + meta.virtualFiles = splitFiles(code, defaultFilename, fsRoot); + const identifiersMap = new Map>(); + + function getIdentifiersOfFile(file: VirtualFile) { + if (!identifiersMap.has(file.filename)) { + const source = env.getSourceFile(file.filepath)!; + identifiersMap.set( + file.filename, + getIdentifierTextSpans(ts, source, file.offset - (file.prepend?.length || 0)), + ); + } + return identifiersMap.get(file.filename)!; + } + + function getFileAtPosition(pos: number) { + return meta.virtualFiles.find((i) => isInRange(pos, [i.offset, i.offset + i.content.length])); + } + + function getQuickInfo( + file: VirtualFile, + start: number, + target: string, + ): NodeWithoutPosition | undefined { + const quickInfo = ls.getQuickInfoAtPosition(file.filepath, getOffsetInFile(start, file)); + + if (quickInfo?.displayParts) { + const text = quickInfo.displayParts.map((dp) => dp.text).join(""); + + const docs = quickInfo.documentation?.map((d) => d.text).join("\n") || undefined; + const tags = quickInfo.tags?.map( + (t) => [t.name, t.text?.map((i) => i.text).join("")] as [string, string | undefined], + ); + + return { + type: "hover", + text, + ...(docs ? { docs } : {}), + ...(tags ? { tags } : {}), + start, + length: target.length, + target, + }; + } + } + + Object.entries(extraFiles).forEach(([filename, content]) => { + if (!meta.virtualFiles.find((i) => i.filename === filename)) { + env.createFile( + fsRoot + filename, + typeof content === "string" ? content : (content.prepend || "") + (content.append || ""), + ); + } + }); + + // # region write files into the FS + for (const file of meta.virtualFiles) { + // Only run the LSP-y things on source files + if ( + supportedFileTyes.includes(file.extension) || + (file.extension === "json" && meta.compilerOptions.resolveJsonModule) + ) { + file.supportLsp = true; + const extra = extraFiles[file.filename]; + if (extra && typeof extra !== "string") { + // @ts-expect-error -- this is fine + file.append = extra.append; + // @ts-expect-error -- this is fine + file.prepend = extra.prepend; + } + env.createFile(file.filepath, getFileContent(file)); + getIdentifiersOfFile(file); + } + } + // #endregion + + function getOffsetInFile(offset: number, file: VirtualFile) { + return offset - file.offset + (file.prepend?.length || 0); + } + + function getFileContent(file: VirtualFile) { + return (file.prepend || "") + file.content + (file.append || ""); + } + + if (!meta.handbookOptions.showEmit) { + for (const file of meta.virtualFiles) { + if (!file.supportLsp) continue; + + // #region get ts info for quick info + if (!meta.handbookOptions.noStaticSemanticInfo) { + const identifiers = getIdentifiersOfFile(file); + for (const [start, _end, target] of identifiers) { + if (isInRemoval(start)) continue; + if (!shouldGetHoverInfo(target, start, file.filename)) continue; + const node = getQuickInfo(file, start, target); + if (node) nodes.push(node); + } + } + } + // #endregion + + // #region get query + for (const query of meta.positionQueries) { + if (isInRemoval(query)) { + throw new TwoslashError( + `Invalid quick info query`, + `The request on line ${pc.indexToPos(query).line + 2} for quickinfo via ^? is in a removal range.`, + `This is likely that the positioning is off.`, + ); + } + + const file = getFileAtPosition(query)!; + const identifiers = getIdentifiersOfFile(file); + + const id = identifiers.find((i) => isInRange(query, i as unknown as Range)); + let node: NodeWithoutPosition | undefined; + if (id) node = getQuickInfo(file, id[0], id[2]); + + if (node) { + node.type = "query"; + nodes.push(node); + } else { + const pos = pc.indexToPos(query); + throw new TwoslashError( + `Invalid quick info query`, + `The request on line ${pos.line + 2} in ${file.filename} for quickinfo via ^? returned nothing from the compiler.`, + `This is likely that the positioning is off.`, + ); + } + } + // #endregion + + // #region get highlights + for (const highlight of meta.positionHighlights) { + // @ts-expect-error -- this is fine + nodes.push({ + type: "highlight", + start: highlight[0], + length: highlight[1] - highlight[0], + text: highlight[2], + }); + } + // #endregion + + // #region get completions + for (const target of meta.positionCompletions) { + const file = getFileAtPosition(target)!; + if (isInRemoval(target) || !file) { + throw new TwoslashError( + `Invalid completion query`, + `The request on line ${pc.indexToPos(target).line + 2} for completions via ^| is in a removal range.`, + `This is likely that the positioning is off.`, + ); + } + + let prefix = code.slice(0, target).match(/[$\w]+$/)?.[0] || ""; + prefix = prefix.split(".").pop()!; + + let completions: CompletionEntry[] = []; + + // If matched with an identifier prefix + if (prefix) { + const result = ls.getCompletionsAtPosition( + file.filepath, + getOffsetInFile(target, file) - 1, + { + triggerKind: 1 satisfies CompletionTriggerKind.Invoked, + includeCompletionsForModuleExports: false, + }, + ); + completions = result?.entries ?? []; + prefix = + (completions[0]?.replacementSpan && + code.slice(completions[0].replacementSpan.start, target)) || + prefix; + completions = completions.filter((i) => i.name.startsWith(prefix)); + } + // If not, we try to trigger with character (e.g. `.`, `'`, `"`) + else { + prefix = code[target - 1]; + if (prefix) { + const result = ls.getCompletionsAtPosition( + file.filepath, + getOffsetInFile(target, file), + { + triggerKind: 2 satisfies CompletionTriggerKind.TriggerCharacter, + triggerCharacter: prefix as any, + includeCompletionsForModuleExports: false, + }, + ); + completions = result?.entries ?? []; + if (completions[0]?.replacementSpan?.length) { + prefix = code.slice(completions[0].replacementSpan.start, target) || prefix; + const newCompletions = completions.filter((i) => i.name.startsWith(prefix)); + if (newCompletions.length) completions = newCompletions; + } + } + } + + if (!completions?.length && !meta.handbookOptions.noErrorValidation) { + const pos = pc.indexToPos(target); + throw new TwoslashError( + `Invalid completion query`, + `The request on line ${pos.line} in ${file.filename} for completions via ^| returned no completions from the compiler. (prefix: ${prefix})`, + `This is likely that the positioning is off.`, + ); + } + + nodes.push({ + type: "completion", + start: target, + length: 0, + completions, + completionsPrefix: prefix, + }); + } + // #endregion + } + + let errorNodes: Omit[] = []; + + // #region get diagnostics, after all files are mounted + for (const file of meta.virtualFiles) { + if (!file.supportLsp) continue; + + if (meta.handbookOptions.noErrors !== true) { + env.updateFile(file.filepath, getFileContent(file)); + const diagnostics = [ + ...ls.getSemanticDiagnostics(file.filepath), + ...ls.getSyntacticDiagnostics(file.filepath), + ]; + const ignores = Array.isArray(meta.handbookOptions.noErrors) + ? meta.handbookOptions.noErrors + : []; + for (const diagnostic of diagnostics) { + if (diagnostic.file?.fileName !== file.filepath) continue; + if (ignores.includes(diagnostic.code)) continue; + const start = diagnostic.start! + file.offset - (file.prepend?.length || 0); + if (meta.handbookOptions.noErrorsCutted && isInRemoval(start)) continue; + // @ts-expect-error -- this is fine + errorNodes.push({ + type: "error", + start, + length: diagnostic.length!, + code: diagnostic.code, + filename: file.filename, + id: `err-${diagnostic.code}-${start}-${diagnostic.length}`, + text: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + level: diagnosticCategoryToErrorLevel(diagnostic.category), + }); + } + } + } + // #endregion + + if (filterNode) { + nodes = nodes.filter(filterNode); + errorNodes = errorNodes.filter(filterNode); + } + nodes.push(...errorNodes); + + // A validator that error codes are mentioned, so we can know if something has broken in the future + if (!meta.handbookOptions.noErrorValidation && errorNodes.length) + validateCodeForErrors(errorNodes as NodeError[], meta.handbookOptions, fsRoot); + + let outputCode = code; + if (meta.handbookOptions.showEmit) { + if (meta.handbookOptions.keepNotations) { + throw new TwoslashError( + `Option 'showEmit' cannot be used with 'keepNotations'`, + "With `showEmit` enabled, the output will always be the emitted code", + "Remove either option to continue", + ); + } + if (!meta.handbookOptions.keepNotations) { + const { code: removedCode } = removeCodeRanges(outputCode, meta.removals); + const files = splitFiles(removedCode, defaultFilename, fsRoot); + for (const file of files) env.updateFile(file.filepath, getFileContent(file)); + } + + const emitFilename = meta.handbookOptions.showEmittedFile + ? meta.handbookOptions.showEmittedFile + : meta.compilerOptions.jsx === (1 satisfies JsxEmit.Preserve) + ? "index.jsx" + : "index.js"; + + let emitSource = meta.virtualFiles.find( + (i) => removeTsExtension(i.filename) === removeTsExtension(emitFilename), + )?.filename; + + if (!emitSource && !meta.compilerOptions.outFile) { + const allFiles = meta.virtualFiles.map((i) => i.filename).join(", "); + throw new TwoslashError( + `Could not find source file to show the emit for`, + `Cannot find the corresponding **source** file: '${emitFilename}'`, + `Looked for: ${emitSource} in the vfs - which contains: ${allFiles}`, + ); + } + + // Allow outfile, in which case you need any file. + if (meta.compilerOptions.outFile) emitSource = meta.virtualFiles[0].filename; + + const output = ls.getEmitOutput(fsRoot + emitSource); + const outfile = output.outputFiles.find( + (o) => o.name === fsRoot + emitFilename || o.name === emitFilename, + ); + + if (!outfile) { + const allFiles = output.outputFiles.map((o) => o.name).join(", "); + throw new TwoslashError( + `Cannot find the output file in the Twoslash VFS`, + `Looking for ${emitFilename} in the Twoslash vfs after compiling`, + `Looked for" ${fsRoot + emitFilename} in the vfs - which contains ${allFiles}.`, + ); + } + + outputCode = outfile.text; + meta.extension = typesToExtension(getExtension(outfile.name)); + meta.removals.length = 0; + nodes.length = 0; + } + + if (!meta.handbookOptions.keepNotations) { + const removed = removeCodeRanges(outputCode, meta.removals, nodes); + outputCode = removed.code; + nodes = removed.nodes; + meta.removals = removed.removals; + } + + const indexToPos = + outputCode === code ? pc.indexToPos : createPositionConverter(outputCode).indexToPos; + + const resolvedNodes = resolveNodePositions(nodes, indexToPos); + + // cleanup + for (const file of meta.virtualFiles) env.createFile(file.filepath, ""); + for (const file of Object.keys(extraFiles)) env.createFile(fsRoot + file, ""); + + return { + code: outputCode, + nodes: resolvedNodes, + meta, + + get queries() { + return this.nodes.filter((i) => i.type === "query") as any; + }, + get completions() { + return this.nodes.filter((i) => i.type === "completion") as any; + }, + get errors() { + return this.nodes.filter((i) => i.type === "error") as any; + }, + get highlights() { + return this.nodes.filter((i) => i.type === "highlight") as any; + }, + get hovers() { + return this.nodes.filter((i) => i.type === "hover") as any; + }, + get tags() { + return this.nodes.filter((i) => i.type === "tag") as any; + }, + }; + } + + twoslasher.getCacheMap = () => { + return cache; + }; + + return twoslasher; +} + +function createCacheableFSBackedSystem( + vfs: Map, + root: string, + ts: TS, + tsLibDirectory?: string, + enableFsCache = true, +): System { + function withCache(fn: (key: string) => T) { + const cache = new Map(); + return (key: string) => { + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const result = fn(key); + cache.set(key, result); + return result; + }; + } + const cachedReadFile = withCache(ts.sys.readFile); + + const cachedTs = enableFsCache + ? { + ...ts, + sys: { + ...ts.sys, + directoryExists: withCache(ts.sys.directoryExists), + fileExists: withCache(ts.sys.fileExists), + ...(ts.sys.realpath ? { realpath: withCache(ts.sys.realpath) } : {}), + readFile(path, encoding) { + if (encoding === undefined) return cachedReadFile(path); + return ts.sys.readFile(path, encoding); + }, + } satisfies System, + } + : ts; + + return { + ...createFSBackedSystem(vfs, root, cachedTs as any, tsLibDirectory), + // To work with non-hoisted packages structure + realpath(path: string) { + if (vfs.has(path)) return path; + return cachedTs.sys.realpath?.(path) || path; + }, + }; +} + +/** + * Run Twoslash on a string of code + * + * It's recommended to use `createTwoslash` for better performance on multiple runs + */ +export function twoslasher(code: string, lang?: string, opts?: Partial) { + return createTwoslasher({ + ...opts, + cache: false, + })(code, lang); +} + +function diagnosticCategoryToErrorLevel(e: DiagnosticCategory): ErrorLevel | undefined { + switch (e) { + case 0: + return "warning"; + case 1: + return "error"; + case 2: + return "suggestion"; + case 3: + return "message"; + default: + return undefined; + } +} diff --git a/packages/@ec-ts/twoslash/src/defaults.ts b/packages/@ec-ts/twoslash/src/defaults.ts new file mode 100644 index 00000000..80645c55 --- /dev/null +++ b/packages/@ec-ts/twoslash/src/defaults.ts @@ -0,0 +1,22 @@ +import type { CompilerOptions, ModuleDetectionKind, ModuleKind, ScriptTarget } from "typescript"; +import type { HandbookOptions } from "./types/index.ts"; + +export const defaultCompilerOptions: CompilerOptions = { + strict: true, + module: 99 satisfies ModuleKind.ESNext, + target: 99 satisfies ScriptTarget.ESNext, + allowJs: true, + skipDefaultLibCheck: true, + skipLibCheck: true, + moduleDetection: 3 satisfies ModuleDetectionKind.Force, +}; + +export const defaultHandbookOptions: HandbookOptions = { + errors: [], + noErrors: false, + noErrorsCutted: false, + noErrorValidation: false, + noStaticSemanticInfo: false, + showEmit: false, + keepNotations: false, +}; diff --git a/packages/@ec-ts/twoslash/src/error.ts b/packages/@ec-ts/twoslash/src/error.ts new file mode 100644 index 00000000..1026b4af --- /dev/null +++ b/packages/@ec-ts/twoslash/src/error.ts @@ -0,0 +1,28 @@ +export class TwoslashError extends Error { + public title: string; + public description: string; + public recommendation: string; + public code: string | undefined; + + constructor( + title: string, + description: string, + recommendation: string, + code?: string | undefined, + ) { + let message = ` +## ${title} + +${description} +`; + if (recommendation) message += `\n${recommendation}`; + + if (code) message += `\n${code}`; + + super(message); + this.title = title; + this.description = description; + this.recommendation = recommendation; + this.code = code; + } +} diff --git a/packages/@ec-ts/twoslash/src/fallback.ts b/packages/@ec-ts/twoslash/src/fallback.ts new file mode 100644 index 00000000..ff8725ad --- /dev/null +++ b/packages/@ec-ts/twoslash/src/fallback.ts @@ -0,0 +1,36 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: This is the design of the Source API */ +import { removeCodeRanges } from "twoslash-protocol"; +import { flagKeys } from "./flag-keys.ts"; +import { reAnnonateMarkers, reConfigBoolean, reConfigValue } from "./regexp.ts"; +import type { TwoslashReturnMeta } from "./types/index.ts"; +import { findCutNotations } from "./utils.ts"; + +/** + * A fallback function to strip out twoslash annotations from a string and does nothing else. + * + * This function does not returns the meta information about the removals. + * It's designed to be used as a fallback when Twoslash fails. + */ +export function removeTwoslashNotations(code: string, customTags?: string[]): string { + const meta: Pick = { + removals: [], + }; + const tags = [...(customTags ?? []), ...flagKeys]; + + Array.from(code.matchAll(reConfigBoolean)).forEach((match) => { + if (!tags.includes(match[1])) return; + meta.removals.push([match.index!, match.index! + match[0].length + 1]); + }); + Array.from(code.matchAll(reConfigValue)).forEach((match) => { + if (!tags.includes(match[1])) return; + meta.removals.push([match.index!, match.index! + match[0].length + 1]); + }); + + findCutNotations(code, meta); + Array.from(code.matchAll(reAnnonateMarkers)).forEach((match) => { + const index = match.index!; + meta.removals.push([index, index + match[0].length + 1]); + }); + + return removeCodeRanges(code, meta.removals).code; +} diff --git a/packages/@ec-ts/twoslash/src/flag-keys.ts b/packages/@ec-ts/twoslash/src/flag-keys.ts new file mode 100644 index 00000000..9d18b57e --- /dev/null +++ b/packages/@ec-ts/twoslash/src/flag-keys.ts @@ -0,0 +1,132 @@ +// Generated by scripts/flag-keys.ts +export const flagKeys = [ + "all", + "allowArbitraryExtensions", + "allowImportingTsExtensions", + "allowJs", + "allowSyntheticDefaultImports", + "allowUmdGlobalAccess", + "allowUnreachableCode", + "allowUnusedLabels", + "alwaysStrict", + "assumeChangesOnlyAffectDirectDependencies", + "baseUrl", + "build", + "charset", + "checkJs", + "composite", + "customConditions", + "declaration", + "declarationDir", + "declarationMap", + "diagnostics", + "disableReferencedProjectLoad", + "disableSizeLimit", + "disableSolutionSearching", + "disableSourceOfProjectReferenceRedirect", + "downlevelIteration", + "emitBOM", + "emitDeclarationOnly", + "emitDecoratorMetadata", + "errors", + "esModuleInterop", + "exactOptionalPropertyTypes", + "experimentalDecorators", + "explainFiles", + "extendedDiagnostics", + "forceConsistentCasingInFileNames", + "generateCpuProfile", + "generateTrace", + "help", + "help", + "ignoreDeprecations", + "importHelpers", + "importsNotUsedAsValues", + "incremental", + "init", + "inlineSourceMap", + "inlineSources", + "isolatedModules", + "jsx", + "jsxFactory", + "jsxFragmentFactory", + "jsxImportSource", + "keepNotations", + "keyofStringsOnly", + "lib", + "listEmittedFiles", + "listFiles", + "listFilesOnly", + "locale", + "mapRoot", + "maxNodeModuleJsDepth", + "module", + "moduleDetection", + "moduleResolution", + "moduleSuffixes", + "newLine", + "noEmit", + "noEmitHelpers", + "noEmitOnError", + "noErrorTruncation", + "noErrorValidation", + "noErrors", + "noErrorsCutted", + "noFallthroughCasesInSwitch", + "noImplicitAny", + "noImplicitOverride", + "noImplicitReturns", + "noImplicitThis", + "noImplicitUseStrict", + "noLib", + "noPropertyAccessFromIndexSignature", + "noResolve", + "noStaticSemanticInfo", + "noStrictGenericChecks", + "noUncheckedIndexedAccess", + "noUnusedLocals", + "noUnusedParameters", + "out", + "outDir", + "outFile", + "paths", + "plugins", + "preserveConstEnums", + "preserveSymlinks", + "preserveValueImports", + "preserveWatchOutput", + "pretty", + "project", + "reactNamespace", + "removeComments", + "resolveJsonModule", + "resolvePackageJsonExports", + "resolvePackageJsonImports", + "rootDir", + "rootDirs", + "showConfig", + "showEmit", + "showEmittedFile", + "skipDefaultLibCheck", + "skipLibCheck", + "sourceMap", + "sourceRoot", + "strict", + "strictBindCallApply", + "strictFunctionTypes", + "strictNullChecks", + "strictPropertyInitialization", + "stripInternal", + "suppressExcessPropertyErrors", + "suppressImplicitAnyIndexErrors", + "target", + "traceResolution", + "tsBuildInfoFile", + "typeRoots", + "types", + "useDefineForClassFields", + "useUnknownInCatchVariables", + "verbatimModuleSyntax", + "version", + "watch", +]; diff --git a/packages/@ec-ts/twoslash/src/index.ts b/packages/@ec-ts/twoslash/src/index.ts new file mode 100644 index 00000000..fa701d7d --- /dev/null +++ b/packages/@ec-ts/twoslash/src/index.ts @@ -0,0 +1,59 @@ +import ts from "typescript"; +import type { TwoslashOptions } from "./core.ts"; +import { createTwoslasher as _createTwoslasher, twoslasher as _twoslasher } from "./core.ts"; +import type { TwoslashOptionsLegacy, TwoslashReturnLegacy } from "./legacy.ts"; +import { convertLegacyOptions, convertLegacyReturn } from "./legacy.ts"; + +export * from "./legacy.ts"; +export * from "./public.ts"; + +// eslint-disable-next-line node/prefer-global/process +const cwd = + /* @__PURE__ */ typeof process !== "undefined" && typeof process.cwd === "function" + ? process.cwd() + : ""; + +/** + * Create a Twoslash instance with cached TS environments + */ +export function createTwoslasher(opts?: TwoslashOptions) { + return _createTwoslasher({ + vfsRoot: cwd, + tsModule: ts, + ...opts, + }); +} + +/** + * Get type results from a code sample + */ +export function twoslasher(code: string, lang: string, opts?: TwoslashOptions) { + return _twoslasher(code, lang, { + vfsRoot: cwd, + tsModule: ts, + ...opts, + }); +} + +/** + * Compatability wrapper to align with `@typescript/twoslash`'s input/output + * + * @deprecated migrate to `twoslasher` instead + */ +export function twoslasherLegacy( + code: string, + lang: string, + opts?: TwoslashOptionsLegacy, +): TwoslashReturnLegacy { + return convertLegacyReturn( + _twoslasher( + code, + lang, + convertLegacyOptions({ + vfsRoot: cwd, + tsModule: ts, + ...opts, + }), + ), + ); +} diff --git a/packages/@ec-ts/twoslash/src/legacy.ts b/packages/@ec-ts/twoslash/src/legacy.ts new file mode 100644 index 00000000..e102e4d7 --- /dev/null +++ b/packages/@ec-ts/twoslash/src/legacy.ts @@ -0,0 +1,198 @@ +import type { ErrorLevel } from "twoslash-protocol"; +import type { CompilerOptions } from "typescript"; +import type { HandbookOptions, TwoslashExecuteOptions, TwoslashReturn } from "./types/index.ts"; + +export interface TwoslashOptionsLegacy extends TwoslashExecuteOptions { + /** + * @deprecated, use `handbookOptions` instead + */ + defaultOptions?: Partial; + /** + * @deprecated, use `compilerOptions` instead + */ + defaultCompilerOptions?: CompilerOptions; +} + +export interface TwoslashReturnLegacy { + /** The output code, could be TypeScript, but could also be a JS/JSON/d.ts */ + code: string; + + /** The new extension type for the code, potentially changed if they've requested emitted results */ + extension: string; + + /** Requests to highlight a particular part of the code */ + highlights: { + kind: "highlight"; + /** The index of the text in the file */ + start: number; + /** What line is the highlighted identifier on? */ + line: number; + /** At what index in the line does the caret represent */ + offset: number; + /** The text of the token which is highlighted */ + text?: string; + /** The length of the token */ + length: number; + }[]; + + /** An array of LSP responses identifiers in the sample */ + staticQuickInfos: { + /** The string content of the node this represents (mainly for debugging) */ + targetString: string; + /** The base LSP response (the type) */ + text: string; + /** Attached JSDoc info */ + docs: string | undefined; + /** The index of the text in the file */ + start: number; + /** how long the identifier */ + length: number; + /** line number where this is found */ + line: number; + /** The character on the line */ + character: number; + }[]; + + /** Requests to use the LSP to get info for a particular symbol in the source */ + queries: { + kind: "query" | "completions"; + /** What line is the highlighted identifier on? */ + line: number; + /** At what index in the line does the caret represent */ + offset: number; + /** The text of the node which is highlighted */ + text?: string; + /** Any attached JSDocs */ + docs?: string | undefined; + /** The node start which the query indicates */ + start: number; + /** The length of the node */ + length: number; + /** Results for completions at a particular point */ + completions?: import("typescript").CompletionEntry[]; + /* Completion prefix e.g. the letters before the cursor in the word so you can filter */ + completionsPrefix?: string; + }[]; + + /** The extracted twoslash commands for any custom tags passed in via customTags */ + tags: { + /** What was the name of the tag */ + name: string; + /** Where was it located in the original source file */ + line: number; + /** What was the text after the `// @tag: ` string (optional because you could do // @tag on it's own line without the ':') */ + annotation?: string; + }[]; + + /** Diagnostic error messages which came up when creating the program */ + errors: { + renderedMessage: string; + id: string; + category: 0 | 1 | 2 | 3; + code: number; + start: number | undefined; + length: number | undefined; + line: number | undefined; + character: number | undefined; + }[]; + + /** The URL for this sample in the playground */ + playgroundURL: string; +} + +export function convertLegacyOptions( + opts: T, +): Omit { + return { + ...opts, + handbookOptions: opts.handbookOptions || opts.defaultOptions, + compilerOptions: opts.compilerOptions || opts.defaultCompilerOptions, + }; +} + +/** + * Covert the new return type to the old one + */ +export function convertLegacyReturn(result: TwoslashReturn): TwoslashReturnLegacy { + return { + code: result.code, + extension: result.meta.extension, + + staticQuickInfos: result.hovers.map((i): TwoslashReturnLegacy["staticQuickInfos"][0] => ({ + text: i.text, + docs: i.docs || "", + start: i.start, + length: i.length, + line: i.line, + character: i.character, + targetString: i.target, + })), + + tags: result.tags.map((t): TwoslashReturnLegacy["tags"][0] => ({ + name: t.name, + line: t.line, + ...(t.text ? { annotation: t.text } : {}), + })), + + highlights: result.highlights.map((h): TwoslashReturnLegacy["highlights"][0] => ({ + kind: "highlight", + // it's a bit confusing that `offset` and `start` are flipped + offset: h.start, + start: h.character, + length: h.length, + line: h.line, + text: h.text || "", + })), + + queries: ( + [ + ...result.queries.map((q): TwoslashReturnLegacy["queries"][0] => ({ + kind: "query", + docs: q.docs || "", + offset: q.character, + start: q.start, + length: q.length, + line: q.line + 1, + text: q.text, + })), + ...result.completions.map((q): TwoslashReturnLegacy["queries"][0] => ({ + kind: "completions", + offset: q.character, + start: q.start, + length: q.length, + line: q.line + 1, + // biome-ignore lint/suspicious/noExplicitAny: This is the design of the Source API + completions: q.completions as any, + completionsPrefix: q.completionsPrefix, + })), + ] as TwoslashReturnLegacy["queries"] + ).sort((a, b) => a.start - b.start), + + errors: result.errors.map((e): TwoslashReturnLegacy["errors"][0] => ({ + id: e.id ?? "", + code: e.code as number, + start: e.start, + length: e.length, + line: e.line, + character: e.character, + renderedMessage: e.text, + category: errorLevelToCategory(e.level), + })), + + playgroundURL: "", + }; +} + +function errorLevelToCategory(level?: ErrorLevel) { + switch (level) { + case "warning": + return 0; + case "suggestion": + return 2; + case "message": + return 3; + case "error": + return 1; + } + return 1; +} diff --git a/packages/@ec-ts/twoslash/src/public.ts b/packages/@ec-ts/twoslash/src/public.ts new file mode 100644 index 00000000..55c7022e --- /dev/null +++ b/packages/@ec-ts/twoslash/src/public.ts @@ -0,0 +1,16 @@ +// Public Utilities + +export * from "./defaults.ts"; +export * from "./error.ts"; +export { removeTwoslashNotations } from "./fallback.ts"; + +export * from "./types/index.ts"; + +export { + findCutNotations, + findFlagNotations, + findQueryMarkers, + getObjectHash, +} from "./utils.ts"; + +export { validateCodeForErrors } from "./validation.ts"; diff --git a/packages/@ec-ts/twoslash/src/regexp.ts b/packages/@ec-ts/twoslash/src/regexp.ts new file mode 100644 index 00000000..3ecdec0e --- /dev/null +++ b/packages/@ec-ts/twoslash/src/regexp.ts @@ -0,0 +1,9 @@ +export const reConfigBoolean = /^\/\/\s?@(\w+)$/gm; +export const reConfigValue = /^\/\/\s?@(\w+):\s?(.+)$/gm; +export const reAnnonateMarkers = /^\s*\/\/\s*\^(\?|\||\^+)( .*)?$/gm; + +export const reCutBefore = /^\/\/\s?---cut(-before)?---$/; +export const reCutAfter = /^\/\/\s?---cut-after---$/; +export const reCutStart = /^\/\/\s?---cut-start---$/; +export const reCutEnd = /^\/\/\s?---cut-end---$/; +export const reFilenamesMakers = /^[\t\v\f ]*\/\/\s?@filename: (.+)$/gm; diff --git a/packages/@ec-ts/twoslash/src/types/handbook-options.ts b/packages/@ec-ts/twoslash/src/types/handbook-options.ts new file mode 100644 index 00000000..3bc3c2af --- /dev/null +++ b/packages/@ec-ts/twoslash/src/types/handbook-options.ts @@ -0,0 +1,44 @@ +/** + * Available inline flags which are not compiler flags + */ +export interface HandbookOptions { + /** + * An array of TS error codes, which you write as space separated - this is so the tool can know about unexpected errors + */ + errors: number[]; + /** + * Suppress errors for diagnostics and display + * + * Setting true to suppress all errors, or an array of error codes to suppress + */ + noErrors: boolean | number[]; + /** + * Declare that you don't need to validate that errors have corresponding annotations, defaults to false + */ + noErrorValidation: boolean; + /** + * Whether to disable the pre-cache of LSP calls for interesting identifiers, defaults to false + */ + noStaticSemanticInfo: boolean; + + /** + * Shows the JS equivalent of the TypeScript code instead + */ + showEmit: boolean; + /** + * Must be used with showEmit, lets you choose the file to present instead of the source - defaults to index.js which + * means when you just use `showEmit` above it shows the transpiled JS. + */ + showEmittedFile?: string; + + /** + * Do not remove twoslash notations from output code, the nodes will have the position of the input code. + * @default false + */ + keepNotations: boolean; + /** + * Do not check errors in the cutted code. + * @default false + */ + noErrorsCutted: boolean; +} diff --git a/packages/@ec-ts/twoslash/src/types/index.ts b/packages/@ec-ts/twoslash/src/types/index.ts new file mode 100644 index 00000000..cd2d9b83 --- /dev/null +++ b/packages/@ec-ts/twoslash/src/types/index.ts @@ -0,0 +1,5 @@ +export * from "twoslash-protocol/types"; +export * from "./handbook-options.ts"; +export * from "./instance.ts"; +export * from "./options.ts"; +export * from "./returns.ts"; diff --git a/packages/@ec-ts/twoslash/src/types/instance.ts b/packages/@ec-ts/twoslash/src/types/instance.ts new file mode 100644 index 00000000..18c37cbf --- /dev/null +++ b/packages/@ec-ts/twoslash/src/types/instance.ts @@ -0,0 +1,20 @@ +import type { VirtualTypeScriptEnvironment } from "@ec-ts/vfs"; +import type { TwoslashExecuteOptions } from "./options.ts"; +import type { TwoslashReturn } from "./returns.ts"; + +export type TwoslashFunction = ( + code: string, + extension?: string, + options?: TwoslashExecuteOptions, +) => TwoslashReturn; + +export interface TwoslashInstance { + /** + * Run Twoslash on a string of code, with a particular extension + */ + (code: string, extension?: string, options?: TwoslashExecuteOptions): TwoslashReturn; + /** + * Get the internal cache map + */ + getCacheMap: () => Map | undefined; +} diff --git a/packages/@ec-ts/twoslash/src/types/options.ts b/packages/@ec-ts/twoslash/src/types/options.ts new file mode 100644 index 00000000..d04bf284 --- /dev/null +++ b/packages/@ec-ts/twoslash/src/types/options.ts @@ -0,0 +1,99 @@ +import type { VirtualTypeScriptEnvironment } from "@ec-ts/vfs"; +import type { NodeWithoutPosition } from "twoslash-protocol"; +import type { CompilerOptions, CustomTransformers } from "typescript"; +import type { HandbookOptions } from "./handbook-options.ts"; +import type { TwoslashReturnMeta } from "./returns.ts"; + +export type TS = typeof import("typescript"); + +export interface CompilerOptionDeclaration { + name: string; + // biome-ignore lint/suspicious/noExplicitAny: This is the design of the Source API + type: "list" | "boolean" | "number" | "string" | "object" | Map; + element?: CompilerOptionDeclaration; +} + +/** + * Options for the `twoslasher` function + */ +export interface TwoslashOptions extends CreateTwoslashOptions, TwoslashExecuteOptions {} + +/** + * Options for twoslash instance + */ +export interface TwoslashExecuteOptions + extends Partial< + Pick + > { + /** + * Allows setting any of the handbook options from outside the function, useful if you don't want LSP identifiers + */ + handbookOptions?: Partial; + + /** + * Allows setting any of the compiler options from outside the function + */ + compilerOptions?: CompilerOptions; + + /** + * A set of known `// @[tags]` tags to extract and not treat as a comment + */ + customTags?: string[]; + + /** + * A custom hook to filter out hover info for certain identifiers + */ + shouldGetHoverInfo?: (identifier: string, start: number, filename: string) => boolean; + + /** + * A custom predicate to filter out nodes for further processing + */ + filterNode?: (node: NodeWithoutPosition) => boolean; + + /** + * Extra files to to added to the virtual file system, or prepended/appended to existing files + */ + extraFiles?: ExtraFiles; +} + +export type ExtraFiles = Record; + +export interface CreateTwoslashOptions extends TwoslashExecuteOptions { + /** + * Allows applying custom transformers to the emit result, only useful with the showEmit output + */ + customTransformers?: CustomTransformers; + + /** + * An optional copy of the TypeScript import, if missing it will be require'd. + */ + tsModule?: TS; + + /** + * Absolute path to the directory to look up built-in TypeScript .d.ts files. + */ + tsLibDirectory?: string; + + /** + * An optional Map object which is passed into @typescript/vfs - if you are using twoslash on the + * web then you'll need this to set up your lib *.d.ts files. If missing, it will use your fs. + */ + fsMap?: Map; + + /** + * The cwd for the folder which the virtual fs should be overlaid on top of when using local fs, opts to process.cwd() if not present + */ + vfsRoot?: string; + + /** + * Cache the ts envs based on compiler options, defaults to true + */ + cache?: boolean | Map; + + /** + * Cache file system requests + * + * @default true + */ + fsCache?: boolean; +} diff --git a/packages/@ec-ts/twoslash/src/types/returns.ts b/packages/@ec-ts/twoslash/src/types/returns.ts new file mode 100644 index 00000000..fd6fb64b --- /dev/null +++ b/packages/@ec-ts/twoslash/src/types/returns.ts @@ -0,0 +1,85 @@ +import type { + NodeCompletion, + NodeError, + NodeHighlight, + NodeHover, + NodeQuery, + NodeTag, + Range, + TwoslashGenericResult, +} from "twoslash-protocol"; +import type { CompilerOptions } from "typescript"; +import type { HandbookOptions } from "./handbook-options.ts"; + +export interface TwoslashReturn extends TwoslashGenericResult { + /** + * The meta information the twoslash run + */ + meta: TwoslashReturnMeta; + + get queries(): NodeQuery[]; + get completions(): NodeCompletion[]; + get errors(): NodeError[]; + get highlights(): NodeHighlight[]; + get hovers(): NodeHover[]; + get tags(): NodeTag[]; +} + +export interface TwoslashReturnMeta { + /** + * The new extension type for the code, potentially changed if they've requested emitted results + */ + extension: string; + /** + * Ranges of text which should be removed from the output + */ + removals: Range[]; + /** + * Resolved compiler options + */ + compilerOptions: CompilerOptions; + /** + * Resolved handbook options + */ + handbookOptions: HandbookOptions; + /** + * Flags which were parsed from the code + */ + flagNotations: ParsedFlagNotation[]; + /** + * The virtual files which were created + */ + virtualFiles: VirtualFile[]; + /** + * Positions of queries in the code + */ + positionQueries: number[]; + /** + * Positions of completions in the code + */ + positionCompletions: number[]; + /** + * Positions of errors in the code + */ + positionHighlights: [start: number, end: number, text?: string][]; +} + +export interface ParsedFlagNotation { + type: "compilerOptions" | "handbookOptions" | "tag" | "unknown"; + name: string; + // biome-ignore lint/suspicious/noExplicitAny: This is the design of the Source API + value: any; + start: number; + end: number; +} + +export interface VirtualFile { + offset: number; + filename: string; + filepath: string; + content: string; + extension: string; + supportLsp?: boolean; + prepend?: string; + append?: string; +} diff --git a/packages/@ec-ts/twoslash/src/utils.ts b/packages/@ec-ts/twoslash/src/utils.ts new file mode 100644 index 00000000..9e47664b --- /dev/null +++ b/packages/@ec-ts/twoslash/src/utils.ts @@ -0,0 +1,386 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny: This is the design of the Source API */ +/** biome-ignore-all lint/style/noNonNullAssertion: This is the design of the Source API */ +import { hash as objectHash } from "ohash"; +import type { createPositionConverter, Range } from "twoslash-protocol"; +import type { SourceFile } from "typescript"; +import { defaultHandbookOptions } from "./defaults.ts"; +import { TwoslashError } from "./error.ts"; +import { + reAnnonateMarkers, + reConfigBoolean, + reConfigValue, + reCutAfter, + reCutBefore, + reCutEnd, + reCutStart, + reFilenamesMakers, +} from "./regexp.ts"; +import type { + CompilerOptionDeclaration, + ParsedFlagNotation, + TwoslashReturnMeta, + VirtualFile, +} from "./types/index.ts"; + +export function getObjectHash(obj: any): string { + return objectHash(obj); +} + +export function parsePrimitive(value: string, type: string): any { + // eslint-disable-next-line valid-typeof + if (typeof value === type) return value; + switch (type) { + case "number": + return +value; + case "string": + return value; + case "boolean": + return value.toLowerCase() === "true" || value.length === 0; + } + + throw new TwoslashError( + `Unknown primitive value in compiler flag`, + `The only recognized primitives are number, string and boolean. Got ${type} with ${value}.`, + `This is likely a typo.`, + ); +} + +export function typesToExtension(types: string) { + const map: Record = { + js: "js", + javascript: "js", + ts: "ts", + typescript: "ts", + tsx: "tsx", + jsx: "jsx", + json: "json", + jsn: "json", + map: "json", + mts: "ts", + cts: "ts", + mjs: "js", + cjs: "js", + }; + + if (map[types]) return map[types]; + + throw new TwoslashError( + `Unknown TypeScript extension given to Twoslash`, + `Received ${types} but Twoslash only accepts: ${Object.keys(map)} `, + ``, + ); +} + +export function getIdentifierTextSpans( + ts: typeof import("typescript"), + sourceFile: SourceFile, + fileOffset: number, +) { + const textSpans: [start: number, end: number, text: string][] = []; + checkChildren(sourceFile); + return textSpans; + + function checkChildren(node: import("typescript").Node) { + ts.forEachChild(node, (child) => { + if (ts.isIdentifier(child)) { + const text = child.getText(sourceFile); + const start = child.getStart(sourceFile, false) + fileOffset; + const end = start + text.length; + textSpans.push([start, end, text]); + } + + checkChildren(child); + }); + } +} + +export function getOptionValueFromMap(name: string, key: string, optMap: Map) { + const result = optMap.get(key.toLowerCase()); + if (result === undefined) { + const keys = Array.from(optMap.keys() as any); + + throw new TwoslashError( + `Invalid inline compiler value`, + `Got ${key} for ${name} but it is not a supported value by the TS compiler.`, + `Allowed values: ${keys.join(",")}`, + ); + } + return result; +} + +export function splitFiles(code: string, defaultFileName: string, root: string) { + const matches = Array.from(code.matchAll(reFilenamesMakers)); + const allFilenames = matches.map((match) => match[1].trimEnd()); + let currentFileName = allFilenames.includes(defaultFileName) ? "__index__.ts" : defaultFileName; + const files: VirtualFile[] = []; + + let index = 0; + for (const match of matches) { + const offset = match.index!; + const content = code.slice(index, offset); + if (content) { + files.push({ + offset: index, + filename: currentFileName, + filepath: root + currentFileName, + content, + extension: getExtension(currentFileName), + }); + } + currentFileName = match[1].trimEnd(); + index = offset; + } + + if (index < code.length) { + const content = code.slice(index); + files.push({ + offset: index, + filename: currentFileName, + filepath: root + currentFileName, + content, + extension: getExtension(currentFileName), + }); + } + + return files; +} + +export function getExtension(fileName: string) { + return fileName.split(".").pop()!; +} + +export function parseFlag( + name: string, + value: any, + start: number, + end: number, + customTags: string[], + tsOptionDeclarations: CompilerOptionDeclaration[], +): ParsedFlagNotation { + if (customTags.includes(name)) { + return { + type: "tag", + name, + value, + start, + end, + }; + } + + const compilerDecl = tsOptionDeclarations.find( + (d) => d.name.toLocaleLowerCase() === name.toLocaleLowerCase(), + ); + // if it's compilerOptions + if (compilerDecl) { + switch (compilerDecl.type) { + case "number": + case "string": + case "boolean": + return { + type: "compilerOptions", + name: compilerDecl.name, + value: parsePrimitive(value, compilerDecl.type), + start, + end, + }; + case "list": { + const elementType = compilerDecl.element!.type; + const strings = value.split(",") as string[]; + const resolved = + typeof elementType === "string" + ? strings.map((v) => parsePrimitive(v, elementType)) + : strings.map((v) => + getOptionValueFromMap(compilerDecl.name, v, elementType as Map), + ); + return { + type: "compilerOptions", + name: compilerDecl.name, + value: resolved, + start, + end, + }; + } + case "object": + return { + type: "compilerOptions", + name: compilerDecl.name, + value: JSON.parse(value), + start, + end, + }; + default: { + // It's a map + return { + type: "compilerOptions", + name: compilerDecl.name, + value: getOptionValueFromMap(compilerDecl.name, value, compilerDecl.type), + start, + end, + }; + } + } + } + + // if it's handbookOptions + if (Object.keys(defaultHandbookOptions).includes(name)) { + // "errors" is a list of numbers + if (name === "errors" && typeof value === "string") value = value.split(" ").map(Number); + + // "noErrors" can be a boolean or a list of numbers + if (name === "noErrors" && typeof value === "string") { + if (value === "true") value = true; + else if (value === "false") value = false; + else value = value.split(" ").map(Number); + } + + return { + type: "handbookOptions", + name, + value, + start, + end, + }; + } + + // unknown compiler flag + return { + type: "unknown", + name, + value, + start, + end, + }; +} + +export function findFlagNotations( + code: string, + customTags: string[], + tsOptionDeclarations: CompilerOptionDeclaration[], +) { + const flagNotations: ParsedFlagNotation[] = []; + + // #extract compiler options + Array.from(code.matchAll(reConfigBoolean)).forEach((match) => { + const index = match.index!; + const name = match[1]; + flagNotations.push( + parseFlag(name, true, index, index + match[0].length + 1, customTags, tsOptionDeclarations), + ); + }); + Array.from(code.matchAll(reConfigValue)).forEach((match) => { + const name = match[1]; + if (name === "filename") return; + const index = match.index!; + const value = match[2]; + flagNotations.push( + parseFlag(name, value, index, index + match[0].length + 1, customTags, tsOptionDeclarations), + ); + }); + return flagNotations; +} + +export function findCutNotations(code: string, meta: Pick) { + let removals: Range[] = []; + const lines = code.split("\n"); + const cutStarts: number[] = []; + // start character after \n + let idx = 0; + let lineIndex = 0; + + for (const line of lines) { + const comment = line.trim(); + + if (comment.match(reCutBefore)) { + removals = [[0, idx + line.length + 1]]; + } else if (comment.match(reCutAfter)) { + removals.push([idx, code.length]); + break; + } else if (comment.match(reCutStart)) { + cutStarts.push(idx); + } else if (comment.match(reCutEnd)) { + const startIdx = cutStarts.pop(); + + if (startIdx === undefined) { + const startLine = lines.findIndex( + (line, i) => i > lineIndex && line.trim().match(reCutStart), + ); + + if (startLine === -1) { + throw new TwoslashError( + `Mismatched cut markers`, + `You have an unclosed the cut-end at line ${lineIndex + 1}`, + `Make sure you have a matching pair for each.`, + ); + } + + throw new TwoslashError( + `Mismatched cut markers`, + `You have a cut-start at line ${startLine + 1} which is after the cut-end at line ${lineIndex + 1}`, + `Make sure you have a matching pair for each.`, + ); + } + + removals.push([startIdx, idx + line.length + 1]); + } + + lineIndex++; + idx += line.length + 1; + } + + if (cutStarts.length > 0) { + throw new TwoslashError( + `Mismatched cut markers`, + `You have unclosed cut-starts at lines ${cutStarts.join(", ")}`, + `Make sure you have a matching pair for each.`, + ); + } + + if (meta) meta.removals.push(...removals); + + return removals; +} + +export function findQueryMarkers( + code: string, + meta: Pick< + TwoslashReturnMeta, + "positionQueries" | "positionCompletions" | "positionHighlights" | "removals" + >, + pc: ReturnType, +) { + if (code.includes("//")) { + const linesQuery = new Set(); + Array.from(code.matchAll(reAnnonateMarkers)).forEach((match) => { + const type = match[1] as "?" | "|" | "^^"; + const index = match.index!; + meta.removals.push([index, index + match[0].length + 1]); + const markerIndex = match[0].indexOf("^"); + + const pos = pc.indexToPos(index + markerIndex); + let targetLine = pos.line - 1; + while (linesQuery.has(targetLine) && targetLine >= 0) targetLine -= 1; + + const targetIndex = pc.posToIndex(targetLine, pos.character); + if (type === "?") { + meta.positionQueries.push(targetIndex); + } else if (type === "|") { + meta.positionCompletions.push(targetIndex); + } else { + const markerLength = match[0].lastIndexOf("^") - markerIndex + 1; + meta.positionHighlights.push([targetIndex, targetIndex + markerLength, match[2]?.trim()]); + } + linesQuery.add(pos.line); + }); + } + return meta; +} + +/** De-extension a filename, used for going from an output file to the source */ +export function removeTsExtension(filename: string) { + // originally, .replace(".jsx", "").replace(".js", "").replace(".d.ts", "").replace(".map", "") + const sansMapOrDTS = filename + .replace(/\.map$/, "") + .replace(/\.d\.ts$/, ".ts") + .replace(/\.map$/, ""); + return sansMapOrDTS.replace(/\.[^/.]+$/, ""); +} diff --git a/packages/@ec-ts/twoslash/src/validation.ts b/packages/@ec-ts/twoslash/src/validation.ts new file mode 100644 index 00000000..106aab96 --- /dev/null +++ b/packages/@ec-ts/twoslash/src/validation.ts @@ -0,0 +1,78 @@ +import type { NodeErrorWithoutPosition } from "twoslash-protocol"; +import { TwoslashError } from "./error.ts"; + +/** To ensure that errors are matched up right */ +export function validateCodeForErrors( + relevantErrors: NodeErrorWithoutPosition[], + handbookOptions: { errors: number[] }, + vfsRoot: string, +) { + const unspecifiedErrors = relevantErrors.filter( + (e) => e.code && !handbookOptions.errors.includes(e.code as number), + ); + const errorsFound = Array.from(new Set(unspecifiedErrors.map((e) => e.code))).join(" "); + + if (unspecifiedErrors.length) { + const errorsToShow = new Set(relevantErrors.map((e) => e.code)); + const codeToAdd = `// @errors: ${Array.from(errorsToShow).join(" ")}`; + + const missing = handbookOptions.errors.length + ? `\nThe existing annotation specified ${handbookOptions.errors.join(" ")}` + : `\nExpected: ${codeToAdd}`; + + // These get filled by below + const filesToErrors: Record = {}; + const noFiles: NodeErrorWithoutPosition[] = []; + + unspecifiedErrors.forEach((d) => { + const fileRef = d.filename?.replace(vfsRoot, ""); + if (!fileRef) { + noFiles.push(d); + } else { + const existing = filesToErrors[fileRef]; + if (existing) existing.push(d); + else filesToErrors[fileRef] = [d]; + } + }); + + const showDiagnostics = (title: string, diags: NodeErrorWithoutPosition[]) => { + return `${title}\n ${diags.map((e) => `[${e.code}] ${e.start} - ${e.text}`).join("\n ")}`; + }; + + const innerDiags: string[] = []; + if (noFiles.length) innerDiags.push(showDiagnostics("Ambient Errors", noFiles)); + + Object.keys(filesToErrors).forEach((filepath) => { + innerDiags.push(showDiagnostics(filepath, filesToErrors[filepath])); + }); + + const allMessages = innerDiags.join("\n\n"); + + const newErr = new TwoslashError( + `Errors were thrown in the sample, but not included in an error tag`, + `These errors were not marked as being expected: ${errorsFound}. ${missing}`, + `Compiler Errors:\n\n${allMessages}`, + ); + + throw newErr; + } +} + +/** Mainly to warn myself, I've lost a good few minutes to this before */ +export function validateInput(code: string) { + if (code.includes("// @errors ")) { + throw new TwoslashError( + `You have '// @errors ' (with a space)`, + `You want '// @errors: ' (with a colon)`, + `This is a pretty common typo`, + ); + } + + if (code.includes("// @filename ")) { + throw new TwoslashError( + `You have '// @filename ' (with a space)`, + `You want '// @filename: ' (with a colon)`, + `This is a pretty common typo`, + ); + } +} diff --git a/packages/@ec-ts/twoslash/test/compiler_options.test.ts b/packages/@ec-ts/twoslash/test/compiler_options.test.ts new file mode 100755 index 00000000..a3e1bf7a --- /dev/null +++ b/packages/@ec-ts/twoslash/test/compiler_options.test.ts @@ -0,0 +1,51 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: This is fine for tests */ + +import * as allure from "allure-js-commons"; +import { ModuleKind } from "typescript"; +import { describe, expect, it } from "vitest"; +import { twoslasher } from "../src/index.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +describe(parentSuiteName, () => { + it("emits CommonJS", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("compiler options"); + await allure.subSuite("module options"); + + const files = ` +// @filename: file-with-export.ts +export const helloWorld = "Example string"; + +// @filename: index.ts +import {helloWorld} from "./file-with-export" +console.log(helloWorld) +`; + const result = twoslasher(files, "ts", { + handbookOptions: { showEmit: true }, + compilerOptions: { module: ModuleKind.CommonJS }, + }); + expect(result.errors).toEqual([]); + expect(result.code!).toContain('require("./file-with-export")'); + }); + + it("supports space before @filename", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("compiler options"); + await allure.subSuite("module options"); + + const files = ` + // @filename: file-with-export.ts +export const helloWorld = "Example string"; + + // @filename: index.ts +import {helloWorld} from "./file-with-export" +console.log(helloWorld) +`; + const result = twoslasher(files, "ts", { + handbookOptions: { showEmit: true }, + compilerOptions: { module: ModuleKind.CommonJS }, + }); + expect(result.errors).toEqual([]); + expect(result.code!).toContain('require("./file-with-export")'); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/custom_transformers.test.ts b/packages/@ec-ts/twoslash/test/custom_transformers.test.ts new file mode 100755 index 00000000..21bfaa83 --- /dev/null +++ b/packages/@ec-ts/twoslash/test/custom_transformers.test.ts @@ -0,0 +1,34 @@ +import * as allure from "allure-js-commons"; +import type { Node, SourceFile, TransformationContext, TransformerFactory } from "typescript"; +import { isSourceFile, isStringLiteral, visitEachChild, visitNode } from "typescript"; +import { describe, expect, it } from "vitest"; +import { twoslasher } from "../src/index.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +describe(parentSuiteName, () => { + it("applies custom transformers", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("custom transformers"); + await allure.subSuite("basic transformer"); + + const code = "console.log('Hello World!')"; + // A simple transformer that uppercases all string literals + + const transformer: TransformerFactory = (ctx: TransformationContext) => { + const visitor = (node: Node): Node => { + if (isStringLiteral(node)) return ctx.factory.createStringLiteral(node.text.toUpperCase()); + return visitEachChild(node, visitor, ctx); + }; + return (node) => visitNode(node, visitor, isSourceFile); + }; + + const result = twoslasher(code, "ts", { + handbookOptions: { showEmit: true }, + customTransformers: { + before: [transformer], + }, + }); + expect(result.errors).toEqual([]); + expect(result.code).toContain('console.log("HELLO WORLD!")'); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/cutting.test.ts b/packages/@ec-ts/twoslash/test/cutting.test.ts new file mode 100644 index 00000000..223b7cca --- /dev/null +++ b/packages/@ec-ts/twoslash/test/cutting.test.ts @@ -0,0 +1,230 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: This is fine for tests */ + +import * as allure from "allure-js-commons"; +import { describe, expect, it } from "vitest"; +import { twoslasher } from "../src/index.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +describe(parentSuiteName, () => { + [ + { + name: "supports hiding the example code", + file: ` +const a = "123" +// ---cut--- +const b = "345" +`, + lang: "ts", + cases: [ + { + name: "hides the right code", + test: (result: ReturnType) => { + // Has the right code shipped + expect(result.code).not.toContain("const a"); + expect(result.code).toContain("const b"); + }, + }, + { + name: "shows the right LSP results", + test: (result: ReturnType) => { + expect(result.hovers.find((info) => info.text.includes("const a"))).toBeUndefined(); + + const bLSPResult = result.hovers.find((info) => info.text.includes("const b")); + expect(bLSPResult).toBeTruthy(); + + // b is one char long + expect(bLSPResult!.length).toEqual(1); + // Should be at char 6 + expect(bLSPResult!.start).toEqual(6); + }, + }, + ], + }, + { + name: "supports hiding the example code with multi-files", + file: ` +// @filename: main-file.ts +const a = "123" +// @filename: file-with-export.ts +// ---cut--- +const b = "345" +`, + lang: "ts", + cases: [ + { + name: "shows the right LSP results", + test: (result: ReturnType) => { + expect(result.hovers.find((info) => info.text.includes("const a"))).toBeUndefined(); + + const bLSPResult = result.hovers.find((info) => info.text.includes("const b")); + expect(bLSPResult).toBeTruthy(); + + // b is one char long + expect(bLSPResult!.length).toEqual(1); + // Should be at char 6 + expect(bLSPResult!.start).toEqual(6); + }, + }, + ], + }, + { + name: "supports handling queries in cut code", + file: ` +const a = "123" +// ---cut--- +const b = "345" +// ^? +`, + lang: "ts", + cases: [ + { + name: "shows the right query results", + test: (result: ReturnType) => { + const bLSPResult = result.queries.find((info) => info.line === 0); + expect(bLSPResult).toBeTruthy(); + expect(bLSPResult!.text).toContain("const b:"); + }, + }, + ], + }, + { + name: "supports handling queries in cut multi-file code", + file: ` +// @filename: index.ts +const a = "123" +// @filename: main-file-queries.ts +const b = "345" +// ---cut--- +const c = "678" +// ^? +`, + lang: "ts", + cases: [ + { + name: "shows the right query results", + test: (result: ReturnType) => { + const bQueryResult = result.queries.find((info) => info.line === 0); + expect(bQueryResult).toBeTruthy(); + expect(bQueryResult!.text).toContain("const c:"); + }, + }, + ], + }, + { + name: "supports hiding after a line", + file: ` +const a = "123" +// ---cut-after--- +const b = "345" +`, + lang: "ts", + cases: [ + { + name: "hides the right code", + test: (result: ReturnType) => { + // Has the right code shipped + expect(result.code).toContain("const a"); + expect(result.code).not.toContain("const b"); + }, + }, + { + name: "shows the right LSP results", + test: (result: ReturnType) => { + expect(result.hovers.find((info) => info.text.includes("const b"))).toBeUndefined(); + + const bLSPResult = result.hovers.find((info) => info.text.includes("const a")); + expect(bLSPResult).toBeTruthy(); + + // b is one char long + expect(bLSPResult!.length).toEqual(1); + // Should be at char 7 + expect(bLSPResult!.start).toEqual(7); + }, + }, + ], + }, + { + name: "supports carriage return (1)", + file: `const x = "123"\n\n// ---cut---\nconst b = "345"`, + lang: "ts", + cases: [ + { + name: "hover is on the same line", + test: (result: ReturnType) => { + const hover = result.hovers.find((info) => info.text.includes("const b")); + expect(hover?.line).toEqual(0); + }, + }, + ], + }, + { + name: "supports carriage return (2)", + file: `const x = "123"\r\n\r\n// ---cut---\r\nconst b = "345"`, + lang: "ts", + cases: [ + { + name: "hover is on the same line", + test: (result: ReturnType) => { + const hover = result.hovers.find((info) => info.text.includes("const b")); + expect(hover?.line).toEqual(0); + }, + }, + ], + }, + { + name: "supports space before cut comments (1)", + file: `function foo() {\n const x = "123"\n// ---cut-start---\n /** @type {"345"} */\n// ---cut-end---\n const b = "345"\n}`, + lang: "ts", + cases: [ + { + name: "hover is on the same line", + test: (result: ReturnType) => { + const hover = result.hovers.find((info) => info.text.includes("const b")); + expect(hover?.line).toEqual(2); + }, + }, + ], + }, + { + name: "supports space before cut comments (2)", + file: `function foo() {\n const x = "123"\n // ---cut-start---\n /** @type {"345"} */\n // ---cut-end---\n const b = "345"\n}`, + lang: "ts", + cases: [ + { + name: "hover is on the same line", + test: (result: ReturnType) => { + const hover = result.hovers.find((info) => info.text.includes("const b")); + expect(hover?.line).toEqual(2); + }, + }, + ], + }, + { + name: "supports cut comments at end of file", + file: `const x = "123"\n// ---cut-start---\n /** @type {"345"} */\n// ---cut-end---`, + lang: "ts", + cases: [ + { + name: "works without error", + test: (result: ReturnType) => { + expect(result.errors).toEqual([]); + }, + }, + ], + }, + ].forEach(({ name, file, lang, cases }) => { + it(name, async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("cutting"); + await allure.subSuite(name); + + const result = twoslasher(file, lang); + + for (const { name, test } of cases) { + await allure.step(name, async () => { + test(result); + }); + } + }); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/extra-files.test.ts b/packages/@ec-ts/twoslash/test/extra-files.test.ts new file mode 100644 index 00000000..0631e8b4 --- /dev/null +++ b/packages/@ec-ts/twoslash/test/extra-files.test.ts @@ -0,0 +1,78 @@ +import * as allure from "allure-js-commons"; +import { describe, expect, it } from "vitest"; +import { twoslasher } from "../src/index.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +describe(parentSuiteName, () => { + [ + { + name: "prepends and appends extra files correctly", + file: ` +const a = ref(1) + `.trim(), + lang: "ts", + extraFiles: { + "index.ts": { + prepend: "function ref(value: T): Ref { return { value } }\n", + append: "\ninterface Ref { value: T }", + }, + }, + test: (result: ReturnType) => { + expect( + result.nodes.find((n) => n.type === "hover" && n.target === "ref"), + ).toMatchInlineSnapshot(` + { + "character": 10, + "length": 3, + "line": 0, + "start": 10, + "target": "ref", + "text": "function ref(value: number): Ref", + "type": "hover", + } + `); + }, + }, + { + name: "supports extra files", + file: ` +import { ref } from './foo' +const a = ref(1) +a.value = 'foo' + `.trim(), + lang: "ts", + extraFiles: { + "foo.ts": + "export function ref(value: T): Ref { return { value } }\ninterface Ref { value: string }", + }, + test: (result: ReturnType) => { + expect( + result.nodes + .slice() + .reverse() + .find((n) => n.type === "hover" && n.target === "ref"), + ).toMatchInlineSnapshot(` + { + "character": 10, + "length": 3, + "line": 1, + "start": 38, + "target": "ref", + "text": "(alias) ref(value: number): Ref + import ref", + "type": "hover", + } + `); + }, + }, + ].forEach(({ name, file, lang, extraFiles, test }) => { + it(name, async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("extra files"); + await allure.subSuite(name); + const result = twoslasher(file, lang, { extraFiles }); + expect(result.code).toEqual(file); + test(result); + }); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/highlights.test.ts b/packages/@ec-ts/twoslash/test/highlights.test.ts new file mode 100644 index 00000000..b2b4f4ad --- /dev/null +++ b/packages/@ec-ts/twoslash/test/highlights.test.ts @@ -0,0 +1,19 @@ +import * as allure from "allure-js-commons"; +import { describe, expect, it } from "vitest"; +import { twoslasher } from "../src/index.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +describe(parentSuiteName, () => { + it("supports highlighting something", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("highlights"); + await allure.subSuite("supports highlighting something"); + const file = ` +const a = "123" +// ^^^^^^^^^ +const b = "345" +`; + const result = twoslasher(file, "ts"); + expect(result.highlights.length).toEqual(1); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/modules.test.ts b/packages/@ec-ts/twoslash/test/modules.test.ts new file mode 100755 index 00000000..747af4b8 --- /dev/null +++ b/packages/@ec-ts/twoslash/test/modules.test.ts @@ -0,0 +1,33 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: This is fine for tests */ +import { createDefaultMapFromNodeModules } from "@ec-ts/vfs"; +import * as allure from "allure-js-commons"; +import { describe, expect, it } from "vitest"; +import { twoslasher } from "../src/index.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +const dt = ` +declare namespace G { + function hasMagic(pattern: string, options?: IOptions): boolean; +} +export = G; +`; + +describe(parentSuiteName, () => { + it("works with a dependency in @types for the project", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("queries"); + await allure.subSuite("works with a dependency in @types for the project"); + + const fsMap = createDefaultMapFromNodeModules({}); + fsMap.set("/node_modules/@types/glob/index.d.ts", dt); + + const file = ` +import glob from "glob" +glob.hasMagic("OK") +// ^? + `; + const result = twoslasher(file, "ts", { fsMap }); + expect(result.errors).toEqual([]); + expect(result.queries[0].text!.includes("hasMagic")).toBeTruthy(); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/new.test.ts b/packages/@ec-ts/twoslash/test/new.test.ts new file mode 100644 index 00000000..d4f7806c --- /dev/null +++ b/packages/@ec-ts/twoslash/test/new.test.ts @@ -0,0 +1,117 @@ +import * as allure from "allure-js-commons"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { twoslasher } from "../src/index.ts"; +import type { TwoslashReturn } from "../src/types/index.ts"; +import { splitFiles } from "../src/utils.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +export type TS = typeof import("typescript"); + +const code = ` +// @errors: 6133 + +interface IdLabel { id: number, /* some fields */ } +interface NameLabel { name: string, /* other fields */ } +type NameOrId = T extends number ? IdLabel : NameLabel; +// This comment should not be included + +// ---cut--- +function createLabel(idOrName: T): NameOrId { + throw "unimplemented" +} + +let a = createLabel("typescript"); +// ^? + +let b = createLabel(2.8); +// ^^^^^^^ + +let c = createLabel(Math.random() ? "hello" : 42); +// ^| +// ---cut-after--- +console.log(a.name); +`; + +function verifyResult(result: TwoslashReturn) { + for (const node of result.nodes) { + if ("target" in node) + expect.soft(result.code.slice(node.start, node.start + node.length)).toBe(node.target); + } +} + +describe(parentSuiteName, () => { + it("split files", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("new tests"); + await allure.subSuite("split files"); + + const files = splitFiles( + ` +// @module: esnext +// @filename: maths.ts +export function absolute(num: number) { + if (num < 0) return num * -1; + return num; +} +// @filename: index.ts +// ---cut--- +import {absolute} from "./maths" +const value = absolute(-1) +// ^? +`, + "test.ts", + "", + ); + expect(files).toMatchInlineSnapshot(` + [ + { + "content": " + // @module: esnext + ", + "extension": "ts", + "filename": "test.ts", + "filepath": "test.ts", + "offset": 0, + }, + { + "content": "// @filename: maths.ts + export function absolute(num: number) { + if (num < 0) return num * -1; + return num; + } + ", + "extension": "ts", + "filename": "maths.ts", + "filepath": "maths.ts", + "offset": 20, + }, + { + "content": "// @filename: index.ts + // ---cut--- + import {absolute} from "./maths" + const value = absolute(-1) + // ^? + ", + "extension": "ts", + "filename": "index.ts", + "filepath": "index.ts", + "offset": 131, + }, + ] + `); + }); + + it("should pass", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("new tests"); + await allure.subSuite("should pass"); + + const result = twoslasher(code, "ts", { + tsModule: ts, + vfsRoot: process.cwd(), + }); + + verifyResult(result); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/queries.test.ts b/packages/@ec-ts/twoslash/test/queries.test.ts new file mode 100644 index 00000000..7c136c1e --- /dev/null +++ b/packages/@ec-ts/twoslash/test/queries.test.ts @@ -0,0 +1,128 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: This is fine for tests */ + +import * as allure from "allure-js-commons"; +import { describe, expect, it } from "vitest"; +import { createTwoslasher } from "../src/index.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +const twoslasher = createTwoslasher(); + +describe(parentSuiteName, () => { + it("works in a trivial case", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("queries"); + await allure.subSuite("works in a trivial case"); + + const file = ` +const a = "123" +// ^? + `; + const result = twoslasher(file, "ts"); + const bQueryResult = result.queries.find((info) => info.line === 1); + + expect(bQueryResult).toBeTruthy(); + expect(bQueryResult!.text).toContain("const a"); + }); + + it("supports carets in the middle of an identifier", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("queries"); + await allure.subSuite("supports carets in the middle of an identifier"); + + const file = ` +const abc = "123" +// ^? + `; + const result = twoslasher(file, "ts"); + const bQueryResult = result.queries.find((info) => info.line === 1); + expect(bQueryResult!.text).toContain("const abc"); + }); + + it("supports two queries", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("queries"); + await allure.subSuite("supports two queries"); + + const file = ` +const a = "123" +// ^? +const b = "345" +// ^? + `; + const result = twoslasher(file, "ts"); + + const aQueryResult = result.queries.find((info) => info.line === 1); + expect(aQueryResult!.text).toContain("const a:"); + + const bQueryResult = result.queries.find((info) => info.line === 2); + expect(bQueryResult!.text).toContain("const b:"); + }); + + it("supports many queries", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("queries"); + await allure.subSuite("supports many queries"); + + const file = ` +const a = "123" +// ^? +const b = "345" +// ^? +// A comment to throw things off +let c = "789" +// ^? + `; + const result = twoslasher(file, "ts"); + expect(result.queries.length).toEqual(3); + + const aQueryResult = result.queries.find((info) => info.line === 1); + expect(aQueryResult!.text).toContain("const a:"); + + const bQueryResult = result.queries.find((info) => info.line === 2); + expect(bQueryResult!.text).toContain("const b:"); + + const cQueryResult = result.queries.find((info) => info.line === 4); + expect(cQueryResult!.text).toContain("let c:"); + }); + + it("supports queries across many files", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("queries"); + await allure.subSuite("supports queries across many files"); + + const file = ` +// @filename: index.ts +const a = "123" +// ^? +// @filename: main-file-queries.ts +const b = "345" +// ^? + `; + const result = twoslasher(file, "ts"); + + const aQueryResult = result.queries.find((info) => info.line === 2); + expect(aQueryResult!.text).toContain("const a:"); + + const bQueryResult = result.queries.find((info) => info.line === 4); + expect(bQueryResult!.text).toContain("const b:"); + }); + + it("supports carets should be relative to token", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("queries"); + await allure.subSuite("supports carets should be relative to token"); + + const file1 = ` +const abc = "123" +// ^? + `; + const file2 = ` +const abc = "123" +// ^? + `; + + const result1 = twoslasher(file1, "ts"); + const result2 = twoslasher(file2, "ts"); + expect(result1.queries).toEqual(result2.queries); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/tags.test.ts b/packages/@ec-ts/twoslash/test/tags.test.ts new file mode 100644 index 00000000..2a9a4e3a --- /dev/null +++ b/packages/@ec-ts/twoslash/test/tags.test.ts @@ -0,0 +1,76 @@ +import * as allure from "allure-js-commons"; +import { describe, expect, it } from "vitest"; +import { twoslasher } from "../src/index.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +describe(parentSuiteName, () => { + it("extracts custom tags", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("tags"); + await allure.subSuite("extracts custom tags"); + + const file = ` +// @thing: OK, sure +const a = "123" +// @thingTwo - This should stay (note the no ':') +const b = 12331234 + `; + const result = twoslasher(file, "ts", { customTags: ["thing"] }); + expect(result.tags.length).toEqual(1); + + expect(result.code).toMatchInlineSnapshot(` + " + const a = "123" + // @thingTwo - This should stay (note the no ':') + const b = 12331234 + " + `); + + const tag = result.tags[0]; + expect(tag).toMatchInlineSnapshot(` + { + "character": 0, + "length": 0, + "line": 1, + "name": "thing", + "start": 1, + "text": "OK, sure", + "type": "tag", + } + `); + }); + + it("removes tags which are cut", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("tags"); + await allure.subSuite("removes tags which are cut"); + + const file = ` +// @thing: OK, sure +const a = "123" +// ---cut--- +// @thing: This one only +const another = '' + `; + const result = twoslasher(file, "ts", { customTags: ["thing"] }); + expect(result.tags.length).toEqual(1); + + expect(result.code).toMatchInlineSnapshot(` + "const another = '' + " + `); + + const tag = result.tags[0]; + expect(tag).toMatchInlineSnapshot(` + { + "character": 0, + "length": 0, + "line": 0, + "name": "thing", + "start": 0, + "text": "This one only", + "type": "tag", + } + `); + }); +}); diff --git a/packages/@ec-ts/twoslash/test/test-utils.ts b/packages/@ec-ts/twoslash/test/test-utils.ts new file mode 100644 index 00000000..a70e02da --- /dev/null +++ b/packages/@ec-ts/twoslash/test/test-utils.ts @@ -0,0 +1 @@ +export const parentSuiteName = "@ec-ts/twoslash Tests"; diff --git a/packages/@ec-ts/twoslash/test/utils.test.ts b/packages/@ec-ts/twoslash/test/utils.test.ts new file mode 100644 index 00000000..c5e2cafb --- /dev/null +++ b/packages/@ec-ts/twoslash/test/utils.test.ts @@ -0,0 +1,54 @@ +import * as allure from "allure-js-commons"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { getIdentifierTextSpans, removeTsExtension } from "../src/utils.ts"; +import { parentSuiteName } from "./test-utils.ts"; + +describe(parentSuiteName, () => { + it("gets the expected identifiers", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("utils"); + await allure.subSuite("getIdentifierTextSpans"); + + const file = ts.createSourceFile( + "anything.ts", + ` +readdirSync(fixturesFolder).forEach(fixtureName => { + const fixture = join(fixturesFolder, fixtureName) + if (lstatSync(fixture).isDirectory()) { + return + } + + // if(!fixtureName.includes("compiler_fl")) return + it('Fixture: ' + fixtureName, () => { + const resultName = parse(fixtureName).name + '.json' + const result = join(resultsFolder, resultName) + + const file = readFileSync(fixture, 'utf8') + + const fourslashed = twoslasher(file, extname(fixtureName).substr(1)) + const jsonString = format(JSON.stringify(fourslashed), { parser: 'json' }) + expect(jsonString).toMatchFile(result) + }) +}) + `, + ts.ScriptTarget.ES2015, + ); + + const allIdentifiers = getIdentifierTextSpans(ts, file, 0); + expect(allIdentifiers.length).toEqual(40); + }); + + it("reduces filenames down", async () => { + await allure.parentSuite(parentSuiteName); + await allure.suite("utils"); + await allure.subSuite("removeTsExtension"); + + expect(removeTsExtension("foo.ts")).toEqual("foo"); + expect(removeTsExtension("foo.tsx")).toEqual("foo"); + expect(removeTsExtension("foo.d.ts")).toEqual("foo"); + expect(removeTsExtension("foo.js.map")).toEqual("foo"); + expect(removeTsExtension("foo")).toEqual("foo"); + expect(removeTsExtension("foo.vue")).toEqual("foo"); + }); +}); diff --git a/packages/@ec-ts/twoslash/tsconfig.json b/packages/@ec-ts/twoslash/tsconfig.json new file mode 100644 index 00000000..8ffd303c --- /dev/null +++ b/packages/@ec-ts/twoslash/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": [], + "references": [ + { + "path": "tsconfig.src.json" + }, + { + "path": "tsconfig.test.json" + } + ] +} diff --git a/packages/@ec-ts/twoslash/tsconfig.src.json b/packages/@ec-ts/twoslash/tsconfig.src.json new file mode 100644 index 00000000..1327f6bb --- /dev/null +++ b/packages/@ec-ts/twoslash/tsconfig.src.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "types": ["node"], + "outDir": "build/src", + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src" + }, + "references": [] +} diff --git a/packages/@ec-ts/twoslash/tsconfig.test.json b/packages/@ec-ts/twoslash/tsconfig.test.json new file mode 100644 index 00000000..ccb07166 --- /dev/null +++ b/packages/@ec-ts/twoslash/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["test"], + "references": [ + { + "path": "tsconfig.src.json" + } + ], + "compilerOptions": { + "types": ["node"], + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "outDir": "build/test" + } +} diff --git a/packages/@ec-ts/twoslash/tsdown.config.ts b/packages/@ec-ts/twoslash/tsdown.config.ts new file mode 100644 index 00000000..6fd43596 --- /dev/null +++ b/packages/@ec-ts/twoslash/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; +import { sharedConfig } from "../../../tsdown.shared.ts"; + +export default defineConfig({ + ...sharedConfig, + entry: ["./src/index.ts", "./src/core.ts", "./src/fallback.ts"], + inlineOnly: ["ohash"], +}); diff --git a/packages/@ec-ts/twoslash/vitest.config.ts b/packages/@ec-ts/twoslash/vitest.config.ts new file mode 100644 index 00000000..fd62b9a6 --- /dev/null +++ b/packages/@ec-ts/twoslash/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import { configShared } from "../../../vitest.shared.js"; + +export default mergeConfig( + configShared, + defineProject({ + test: { + name: "@ec-ts/twoslash", + include: ["**/*.test.ts"], + }, + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e38eb4b1..8e1d890b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ catalogs: twoslash-eslint: specifier: ^0.3.6 version: 0.3.6 + twoslash-protocol: + specifier: ^0.3.6 + version: 0.3.6 twoslash-vue: specifier: ^0.3.6 version: 0.3.6 @@ -172,6 +175,22 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/@ec-ts/twoslash: + dependencies: + '@ec-ts/vfs': + specifier: workspace:^ + version: link:../vfs + twoslash-protocol: + specifier: catalog:twoslash + version: 0.3.6 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + devDependencies: + ohash: + specifier: ^2.0.11 + version: 2.0.11 + packages/@ec-ts/vfs: devDependencies: '@types/jest': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 937fb252..c964ed40 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,8 +18,13 @@ catalogs: expressive-code: "^0.41.6" typescript: ^5.7.3 twoslash: - twoslash: ^0.3.6 + twoslash-protocol: ^0.3.6 twoslash-eslint: ^0.3.6 + + # The following 2 packages are being forked and migrated to this repo, and will be published under the @ec-ts scope. + # Once the migration is complete, these packages should be removed from the catalog and the references to them should + # be updated to point to the workspace:^ version of the packages. + twoslash: ^0.3.6 twoslash-vue: ^0.3.6 packages: diff --git a/tsconfig.json b/tsconfig.json index 5a340d30..031a542a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,12 @@ "extends": "./tsconfig.base.json", "include": [], "references": [ + { + "path": "packages/@ec-ts/twoslash" + }, + { + "path": "packages/@ec-ts/vfs" + }, { "path": "packages/css-js-gen" }, diff --git a/tsdown.shared.ts b/tsdown.shared.ts index a7533430..06f0e3fd 100644 --- a/tsdown.shared.ts +++ b/tsdown.shared.ts @@ -11,6 +11,9 @@ export const sharedConfig: UserConfig = { publint: { level: "error", }, + checks: { + pluginTimings: false, + }, outExtensions: () => ({ js: `.js`, dts: `.d.ts`, diff --git a/vitest.config.ts b/vitest.config.ts index 8148a41a..53c1eb60 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,7 +33,7 @@ const projectsWithTests: { scope?: string; names: string[] }[] = [ }, { scope: "ec-ts", - names: ["vfs"], + names: ["twoslash", "vfs"], }, ];