diff --git a/package.json b/package.json index 8d1f4a3a..1298473b 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ ] }, "devDependencies": { + "@0no-co/typescript.js": "5.3.2-2", "@changesets/cli": "^2.26.2", "@changesets/get-github-info": "^0.5.2", "@rollup/plugin-buble": "^1.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d2077f9..ae4fbcd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -10,6 +10,9 @@ dependencies: version: 1.0.4(graphql@16.6.0) devDependencies: + '@0no-co/typescript.js': + specifier: 5.3.2-2 + version: 5.3.2-2 '@changesets/cli': specifier: ^2.26.2 version: 2.26.2 @@ -102,6 +105,10 @@ packages: graphql: 16.6.0 dev: false + /@0no-co/typescript.js@5.3.2-2: + resolution: {integrity: sha512-IGQZZ7vcVD/GOUKLJckpQiq8F5raQZLR7kOVhxN5nHOywl1dB9EFMA7FJkyNoCEig7EkCb291X8FswMwwrz9yg==} + dev: true + /@aashutoshrathi/word-wrap@1.2.6: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} @@ -117,11 +124,13 @@ packages: /@babel/helper-validator-identifier@7.22.5: resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} engines: {node: '>=6.9.0'} + requiresBuild: true dev: true /@babel/highlight@7.22.5: resolution: {integrity: sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==} engines: {node: '>=6.9.0'} + requiresBuild: true dependencies: '@babel/helper-validator-identifier': 7.22.5 chalk: 2.4.2 @@ -2500,6 +2509,7 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + requiresBuild: true dev: true /js-yaml@3.14.1: diff --git a/src/__tests__/parser.bench.ts b/src/__tests__/parser.bench.ts new file mode 100644 index 00000000..5eb3f7ec --- /dev/null +++ b/src/__tests__/parser.bench.ts @@ -0,0 +1,20 @@ +import { describe, bench } from 'vitest'; +import * as ts from './tsHarness'; + +describe('Document', () => { + const virtualHost = ts.createVirtualHost({ + ...ts.readVirtualModule('@0no-co/graphql.web'), + 'parser.ts': ts.readFileFromRoot('src/parser.ts'), + 'index.ts': ` + import type { Document } from './parser'; + type document = Document<'{ test }'>; + type operation = document['definitions'][0]['operation']; + `, + }); + + const typeHost = ts.createTypeHost(['index.ts'], virtualHost); + + bench('parse document', () => { + ts.createTypeChecker(typeHost).getDiagnostics(); + }); +}); diff --git a/src/__tests__/tsHarness/compilerOptions.ts b/src/__tests__/tsHarness/compilerOptions.ts new file mode 100644 index 00000000..82371fdb --- /dev/null +++ b/src/__tests__/tsHarness/compilerOptions.ts @@ -0,0 +1,28 @@ +import { + CompilerOptions, + ModuleResolutionKind, + ScriptTarget, + JsxEmit, +} from '@0no-co/typescript.js'; + +export const compilerOptions: CompilerOptions = { + rootDir: '/', + moduleResolution: ModuleResolutionKind.Bundler, + skipLibCheck: true, + skipDefaultLibCheck: true, + allowImportingTsExtensions: true, + allowSyntheticDefaultImports: true, + resolvePackageJsonExports: true, + resolvePackageJsonImports: true, + resolveJsonModule: true, + esModuleInterop: true, + jsx: 1 satisfies JsxEmit.Preserve, + target: 99 satisfies ScriptTarget.Latest, + checkJs: false, + allowJs: true, + strict: false, + noEmit: true, + noLib: false, + disableSizeLimit: true, + disableSolutionSearching: true, +}; diff --git a/src/__tests__/tsHarness/index.ts b/src/__tests__/tsHarness/index.ts new file mode 100644 index 00000000..554b698b --- /dev/null +++ b/src/__tests__/tsHarness/index.ts @@ -0,0 +1,7 @@ +export type { FileData, Files, VirtualHost } from './virtualHost'; +export type { TypeHost } from './typeCheckerHost'; + +export { createTypeChecker } from '@0no-co/typescript.js'; +export { createTypeHost } from './typeCheckerHost'; +export { createVirtualHost } from './virtualHost'; +export { readFileFromRoot, readVirtualModule } from './virtualModules'; diff --git a/src/__tests__/tsHarness/resolution.ts b/src/__tests__/tsHarness/resolution.ts new file mode 100644 index 00000000..858a5dee --- /dev/null +++ b/src/__tests__/tsHarness/resolution.ts @@ -0,0 +1,261 @@ +import { + StringLiteralLike, + ModuleDeclaration, + ModifierFlags, + StringLiteral, + ModuleBlock, + Identifier, + SourceFile, + SyntaxKind, + Statement, + ModuleKind, + CompilerHost, + CompilerOptions, + ScriptTarget, + AssertClause, + Node, + toPath, + forEach, + forEachChild, + setResolvedModule, + getNormalizedAbsolutePath, + isExternalModuleNameRelative, + getTextOfIdentifierOrLiteral, + hasSyntacticModifier, + setParentRecursive, + getExternalModuleName, + isAnyImportOrReExport, + isStringLiteralLike, + isLiteralImportTypeNode, + isModuleDeclaration, + isExportDeclaration, + isImportDeclaration, + isImportEqualsDeclaration, + isImportTypeNode, + isExclusivelyTypeOnlyImportOrExport, + walkUpParenthesizedExpressions, + isExternalModule, + isAmbientModule, + isStringLiteral, + isSourceFileJS, + isRequireCall, + isImportCall, + hasJSDocNodes, +} from '@0no-co/typescript.js'; + +function _collectExternalModuleReferences(file: SourceFile) { + if (file.imports) { + return; + } + + const isJavaScriptFile = isSourceFileJS(file); + const isExternalModuleFile = isExternalModule(file); + const imports: StringLiteralLike[] = []; + const moduleAugmentations: (StringLiteral | Identifier)[] = []; + const ambientModules: string[] = []; + + for (const node of file.statements) { + _collectModuleReferences(node, false); + } + + if (isJavaScriptFile) { + _collectDynamicImportOrRequireCalls(file); + } + + file.imports = imports; + file.moduleAugmentations = moduleAugmentations; + file.ambientModuleNames = ambientModules; + + function _collectModuleReferences(node: Statement, inAmbientModule: boolean) { + if (isAnyImportOrReExport(node)) { + const moduleNameExpr = getExternalModuleName(node); + if (moduleNameExpr && isStringLiteral(moduleNameExpr) && moduleNameExpr.text) { + setParentRecursive(node, /*incremental*/ false); + imports.push(moduleNameExpr); + } + } else if ( + isModuleDeclaration(node) && + isAmbientModule(node) && + (inAmbientModule || + hasSyntacticModifier(node, ModifierFlags.Ambient) || + file.isDeclarationFile) + ) { + const moduleName = getTextOfIdentifierOrLiteral(node.name); + if (isExternalModuleFile || (inAmbientModule && !isExternalModuleNameRelative(moduleName))) { + moduleAugmentations.push(node.name); + } else if (!inAmbientModule) { + if (file.isDeclarationFile) ambientModules.push(moduleName); + const body = (node as ModuleDeclaration).body as ModuleBlock; + for (const statement of body?.statements || []) { + _collectModuleReferences(statement, true); + } + } + } + } + + function _collectDynamicImportOrRequireCalls(file: SourceFile) { + const r = /import|require/g; + while (r.exec(file.text) !== null) { + const node = getNodeAtPosition(file, r.lastIndex); + if (isJavaScriptFile && isRequireCall(node, true)) { + setParentRecursive(node, false); + imports.push(node.arguments[0]); + } else if ( + isImportCall(node) && + node.arguments.length >= 1 && + isStringLiteralLike(node.arguments[0]) + ) { + setParentRecursive(node, false); + imports.push(node.arguments[0]); + } else if (isLiteralImportTypeNode(node)) { + setParentRecursive(node, false); + imports.push(node.argument.literal); + } + } + } + + function getNodeAtPosition(sourceFile: SourceFile, position: number): Node { + let current: Node = sourceFile; + const getContainingChild = (child: Node) => { + if ( + child.pos <= position && + (position < child.end || + (position === child.end && child.kind === SyntaxKind.EndOfFileToken)) + ) { + return child; + } + }; + while (true) { + const child = + (isJavaScriptFile && + hasJSDocNodes(current) && + forEach(current.jsDoc, getContainingChild)) || + forEachChild(current, getContainingChild); + if (!child) { + return current; + } else { + current = child; + } + } + } +} + +function _getModuleResolutionName(literal: StringLiteralLike) { + return literal.text; +} + +function _getModuleNames({ imports, moduleAugmentations }: SourceFile): StringLiteralLike[] { + const res = [...imports]; + for (const aug of moduleAugmentations) if (aug.kind === SyntaxKind.StringLiteral) res.push(aug); + return res; +} + +const emptyResolution = { + resolvedModule: undefined, + resolvedTypeReferenceDirective: undefined, +}; + +function getResolutionModeOverrideForClause(clause: AssertClause | undefined) { + if (!clause || clause.elements.length !== 1) return undefined; + const elem = clause.elements[0]; + if ( + !isStringLiteralLike(elem.name) || + elem.name.text !== 'resolution-mode' || + !isStringLiteralLike(elem.value) + ) { + return undefined; + } else if (elem.value.text === 'import') { + return 99 as ModuleKind.ESNext; + } else if (elem.value.text === 'require') { + return 1 as ModuleKind.CommonJS; + } +} + +function _getModeForUsageLocation(file: SourceFile, usage: StringLiteralLike) { + if (file.impliedNodeFormat === undefined) return undefined; + + if ( + (isImportDeclaration(usage.parent) || isExportDeclaration(usage.parent)) && + isExclusivelyTypeOnlyImportOrExport(usage.parent) + ) { + const override = getResolutionModeOverrideForClause(usage.parent.assertClause); + if (override) return override; + } + + if (usage.parent.parent && isImportTypeNode(usage.parent.parent)) { + const override = getResolutionModeOverrideForClause( + usage.parent.parent.assertions?.assertClause + ); + if (override) return override; + } + + if (file.impliedNodeFormat !== (99 as ModuleKind.ESNext)) { + return isImportCall(walkUpParenthesizedExpressions(usage.parent)) + ? ModuleKind.ESNext + : ModuleKind.CommonJS; + } else { + const exprParentParent = walkUpParenthesizedExpressions(usage.parent)?.parent; + return exprParentParent && isImportEqualsDeclaration(exprParentParent) + ? ModuleKind.CommonJS + : ModuleKind.ESNext; + } +} + +export function* processImportedModules( + file: SourceFile | undefined, + host: CompilerHost, + options: CompilerOptions +): IterableIterator { + if (!file) return; + _collectExternalModuleReferences(file); + if (!resolvedModules.has(file.path) && (file.imports.length || file.moduleAugmentations.length)) { + const moduleNames = _getModuleNames(file); + const resolutions = _resolveModuleNames(moduleNames, file); + if (resolutions.length === moduleNames.length) { + for (let index = 0; index < moduleNames.length; index++) { + const moduleName = moduleNames[index]; + const resolvedModule = resolutions[index]; + const resolution = resolvedModule ? { resolvedModule } : emptyResolution; + const mode = _getModeForUsageLocation(file, moduleNames[index]); + const resolutionsInFile = createModeAwareCache(); + resolvedModules.set(file.path, resolutionsInFile); + if (resolvedModule) { + const file = findSourceFile(resolvedModule.resolvedFileName, host); + if (file) { + yield* processImportedModules(file, host, options); + yield file; + } + } + } + } + } + + function _resolveModuleNames( + moduleNames: readonly StringLiteralLike[], + containingFile: SourceFile + ) { + if (!moduleNames.length) return []; + const currentDirectory = host.getCurrentDirectory(); + const containingFileName = getNormalizedAbsolutePath( + containingFile.originalFileName, + currentDirectory + ); + return host.resolveModuleNames!( + moduleNames.map(_getModuleResolutionName), + containingFileName, + undefined, + undefined, + options + ); + } +} + +export function findSourceFile(fileName: string, host: CompilerHost): SourceFile | undefined { + const file = host.getSourceFile(fileName, 99 as ScriptTarget.ESNext); + if (file) { + const path = toPath(fileName, host.getCurrentDirectory(), host.getCanonicalFileName); + file.fileName = file.originalFileName = fileName; + file.path = file.resolvedPath = path; + } + return file; +} diff --git a/src/__tests__/tsHarness/resolutionUtils.ts b/src/__tests__/tsHarness/resolutionUtils.ts new file mode 100644 index 00000000..65bce91c --- /dev/null +++ b/src/__tests__/tsHarness/resolutionUtils.ts @@ -0,0 +1,227 @@ +import { + CompilerHost, + StringLiteralLike, + StringLiteral, + ScriptTarget, + Identifier, + SourceFile, + Statement, + NodeFlags, + ModifierFlags, + SyntaxKind, + ModuleDeclaration, + ModuleBlock, + ModuleKind, + Mutable, + Node, + ResolutionMode, + walkUpParenthesizedExpressions, + isImportEqualsDeclaration, + toPath, + isSourceFileJS, + isExternalModule, + isStringLiteral, + isStringLiteralLike, + isImportDeclaration, + isExportDeclaration, + isExclusivelyTypeOnlyImportOrExport, + getResolutionModeOverride, + isImportTypeNode, + isExternalModuleNameRelative, + isRequireCall, + isImportCall, + forEach, + forEachChild, + hasJSDocNodes, + isLiteralImportTypeNode, + isAnyImportOrReExport, + getExternalModuleName, + setParentRecursive, + isModuleDeclaration, + isAmbientModule, + getTextOfIdentifierOrLiteral, + hasSyntacticModifier, +} from '@0no-co/typescript.js'; + +export function getModuleNames({ imports, moduleAugmentations }: SourceFile): StringLiteralLike[] { + const res = [...imports]; + for (const aug of moduleAugmentations) + if (aug.kind === (11 satisfies SyntaxKind.StringLiteral)) res.push(aug); + return res; +} + +export function findSourceFile(fileName: string, host: CompilerHost): SourceFile | undefined { + const file = host.getSourceFile(fileName, 99 satisfies ScriptTarget.ESNext); + if (file) { + const path = toPath(fileName, host.getCurrentDirectory(), host.getCanonicalFileName); + file.fileName = file.originalFileName = fileName; + file.path = file.resolvedPath = path; + } + return file; +} + +export function getModeForUsageLocation( + file: { impliedNodeFormat?: ResolutionMode }, + usage: StringLiteralLike +) { + if (isImportDeclaration(usage.parent) || isExportDeclaration(usage.parent)) { + const isTypeOnly = isExclusivelyTypeOnlyImportOrExport(usage.parent); + if (isTypeOnly) { + const override = getResolutionModeOverride(usage.parent.attributes); + if (override) { + return override; + } + } + } + if (usage.parent.parent && isImportTypeNode(usage.parent.parent)) { + const override = getResolutionModeOverride(usage.parent.parent.attributes); + if (override) { + return override; + } + } + if (file.impliedNodeFormat === undefined) return undefined; + if (file.impliedNodeFormat !== ModuleKind.ESNext) { + // in cjs files, import call expressions are esm format, otherwise everything is cjs + return isImportCall(walkUpParenthesizedExpressions(usage.parent)) + ? ModuleKind.ESNext + : ModuleKind.CommonJS; + } + // in esm files, import=require statements are cjs format, otherwise everything is esm + // imports are only parent'd up to their containing declaration/expression, so access farther parents with care + const exprParentParent = walkUpParenthesizedExpressions(usage.parent)?.parent; + return exprParentParent && isImportEqualsDeclaration(exprParentParent) + ? ModuleKind.CommonJS + : ModuleKind.ESNext; +} + +export function collectExternalModuleReferences(file: SourceFile): void { + if (file.imports) { + return; + } + + const isJavaScriptFile = isSourceFileJS(file); + const isExternalModuleFile = isExternalModule(file); + + // file.imports may not be undefined if there exists dynamic import + let imports: StringLiteralLike[] | undefined; + let moduleAugmentations: (StringLiteral | Identifier)[] | undefined; + let ambientModules: string[] | undefined; + for (const node of file.statements) { + collectModuleReferences(node, /*inAmbientModule*/ false); + } + + const shouldProcessRequires = isJavaScriptFile; // && shouldResolveJsRequire(compilerOptions); + if ( + file.flags & (4194304 satisfies NodeFlags.PossiblyContainsDynamicImport) || + shouldProcessRequires + ) { + collectDynamicImportOrRequireCalls(file); + } + + file.imports = imports || []; + file.moduleAugmentations = moduleAugmentations || []; + file.ambientModuleNames = ambientModules || []; + + return; + + function collectModuleReferences(node: Statement, inAmbientModule: boolean): void { + if (isAnyImportOrReExport(node)) { + const moduleNameExpr = getExternalModuleName(node); + // TypeScript 1.0 spec (April 2014): 12.1.6 + // An ExternalImportDeclaration in an AmbientExternalModuleDeclaration may reference other external modules + // only through top - level external module names. Relative external module names are not permitted. + if ( + moduleNameExpr && + isStringLiteral(moduleNameExpr) && + moduleNameExpr.text && + (!inAmbientModule || !isExternalModuleNameRelative(moduleNameExpr.text)) + ) { + setParentRecursive(node, /*incremental*/ false); // we need parent data on imports before the program is fully bound, so we ensure it's set here + (imports ??= []).push(moduleNameExpr); + } + } else if (isModuleDeclaration(node)) { + if ( + isAmbientModule(node) && + (inAmbientModule || + hasSyntacticModifier(node, 128 satisfies ModifierFlags.Ambient) || + file.isDeclarationFile) + ) { + (node.name as Mutable).parent = node; + const nameText = getTextOfIdentifierOrLiteral(node.name); + // Ambient module declarations can be interpreted as augmentations for some existing external modules. + // This will happen in two cases: + // - if current file is external module then module augmentation is a ambient module declaration defined in the top level scope + // - if current file is not external module then module augmentation is an ambient module declaration with non-relative module name + // immediately nested in top level ambient module declaration . + if (isExternalModuleFile || (inAmbientModule && !isExternalModuleNameRelative(nameText))) { + (moduleAugmentations || (moduleAugmentations = [])).push(node.name); + } else if (!inAmbientModule) { + if (file.isDeclarationFile) { + // for global .d.ts files record name of ambient module + (ambientModules || (ambientModules = [])).push(nameText); + } + // An AmbientExternalModuleDeclaration declares an external module. + // This type of declaration is permitted only in the global module. + // The StringLiteral must specify a top - level external module name. + // Relative external module names are not permitted + + // NOTE: body of ambient module is always a module block, if it exists + const body = (node as ModuleDeclaration).body as ModuleBlock; + if (body) { + for (const statement of body.statements) { + collectModuleReferences(statement, /*inAmbientModule*/ true); + } + } + } + } + } + } + + function collectDynamicImportOrRequireCalls(file: SourceFile) { + const r = /import|require/g; + while (r.exec(file.text) !== null) { + const node = getNodeAtPosition(file, r.lastIndex); + if (shouldProcessRequires && isRequireCall(node, /*requireStringLiteralLikeArgument*/ true)) { + setParentRecursive(node, /*incremental*/ false); // we need parent data on imports before the program is fully bound, so we ensure it's set here + (imports ??= []).push(node.arguments[0]); + } + // we have to check the argument list has length of at least 1. We will still have to process these even though we have parsing error. + else if ( + isImportCall(node) && + node.arguments.length >= 1 && + isStringLiteralLike(node.arguments[0]) + ) { + setParentRecursive(node, /*incremental*/ false); // we need parent data on imports before the program is fully bound, so we ensure it's set here + (imports ??= []).push(node.arguments[0]); + } else if (isLiteralImportTypeNode(node)) { + setParentRecursive(node, /*incremental*/ false); // we need parent data on imports before the program is fully bound, so we ensure it's set here + (imports ??= []).push(node.argument.literal); + } + } + } + + /** Returns a token if position is in [start-of-leading-trivia, end), includes JSDoc only in JS files */ + function getNodeAtPosition(sourceFile: SourceFile, position: number): Node { + let current: Node = sourceFile; + const getContainingChild = (child: Node) => { + if ( + child.pos <= position && + (position < child.end || + (position === child.end && child.kind === (1 satisfies SyntaxKind.EndOfFileToken))) + ) { + return child; + } + }; + while (true) { + const child = + (isJavaScriptFile && + hasJSDocNodes(current) && + forEach(current.jsDoc, getContainingChild)) || + forEachChild(current, getContainingChild); + if (!child) { + return current; + } + current = child; + } + } +} diff --git a/src/__tests__/tsHarness/typeCheckerHost.ts b/src/__tests__/tsHarness/typeCheckerHost.ts new file mode 100644 index 00000000..aecef0c0 --- /dev/null +++ b/src/__tests__/tsHarness/typeCheckerHost.ts @@ -0,0 +1,152 @@ +import { + CompilerHost, + CompilerOptions, + SourceFile, + TypeCheckerHost, + FileIncludeKind, + RedirectTargetsMap, + ModeAwareCache, + ResolvedModuleWithFailedLookupLocations, + StringLiteralLike, + Path, + getNormalizedAbsolutePath, + createModeAwareCache, + arrayToMultiMap, +} from '@0no-co/typescript.js'; + +import { compilerOptions } from './compilerOptions'; + +import { + findSourceFile, + getModuleNames, + getModeForUsageLocation, + collectExternalModuleReferences, +} from './resolutionUtils'; + +export interface TypeHost extends TypeCheckerHost { + getRootSourceFiles(): readonly SourceFile[]; +} + +export function createTypeHost(rootFileNames: readonly string[], host: CompilerHost): TypeHost { + const fileIncludeReasons = arrayToMultiMap([], () => FileIncludeKind.Import); + const resolvedTypeReferenceDirectives = createModeAwareCache(); + const resolvedModules = new Map>(); + const rootFiles: SourceFile[] = []; + const importedFiles: SourceFile[] = []; + + function getSourceFile(filename: string) { + const file = findSourceFile(filename, host); + for (const importedFile of processImportedModules(file, host, compilerOptions)) + importedFiles.push(importedFile); + return file; + } + + function* processImportedModules( + file: SourceFile | undefined, + host: CompilerHost, + options: CompilerOptions + ): IterableIterator { + if (!file) return; + collectExternalModuleReferences(file); + if ( + !resolvedModules.has(file.path) && + (file.imports.length || file.moduleAugmentations.length) + ) { + const moduleNames = getModuleNames(file); + const resolutions = resolveModuleNames(moduleNames, file); + if (resolutions.length === moduleNames.length) { + const resolutionsInFile = createModeAwareCache(); + resolvedModules.set(file.path, resolutionsInFile); + for (let index = 0; index < moduleNames.length; index++) { + const moduleName = moduleNames[index].text; + const resolvedModule = resolutions[index]; + const mode = getModeForUsageLocation(file, moduleNames[index]); + resolutionsInFile.set(moduleName, mode, { + resolvedModule, + } as ResolvedModuleWithFailedLookupLocations); + if (resolvedModule) { + const file = findSourceFile(resolvedModule.resolvedFileName, host); + if (file) { + yield* processImportedModules(file, host, options); + yield file; + } + } + } + } + } + + function resolveModuleNames( + moduleNames: readonly StringLiteralLike[], + containingFile: SourceFile + ) { + if (!moduleNames.length) return []; + const currentDirectory = host.getCurrentDirectory(); + const containingFileName = getNormalizedAbsolutePath( + containingFile.originalFileName, + currentDirectory + ); + return host.resolveModuleNames!( + moduleNames.map(literal => literal.text), + containingFileName, + undefined, + undefined, + options + ); + } + } + + for (const rootFileName of rootFileNames) { + const rootFile = getSourceFile(rootFileName); + if (rootFile) rootFiles.push(rootFile); + } + + if (!compilerOptions.noLib) { + const libFile = getSourceFile(host.getDefaultLibFileName(compilerOptions)); + if (libFile) rootFiles.push(libFile); + } + + const files = [...importedFiles, ...rootFiles]; + + return { + useCaseSensitiveFileNames: host.useCaseSensitiveFileNames, + getCurrentDirectory: host.getCurrentDirectory, + directoryExists: host.directoryExists, + fileExists: host.fileExists, + readFile: host.readFile, + realpath: host.realpath, + + getSourceFile, + + typesPackageExists(_packageName) { + return false; + }, + packageBundlesTypes(_packageName) { + return false; + }, + getResolvedModule(file, moduleName, mode) { + return resolvedModules?.get(file.path)?.get(moduleName, mode); + }, + getFileIncludeReasons() { + return fileIncludeReasons; + }, + getCompilerOptions() { + return compilerOptions; + }, + getSourceFiles() { + return files; + }, + getRootSourceFiles() { + return rootFiles; + }, + getResolvedTypeReferenceDirectives() { + return resolvedTypeReferenceDirectives; + }, + getProjectReferenceRedirect(_filename: string) { + return undefined; + }, + isSourceOfProjectReferenceRedirect(_filename: string) { + return false; + }, + redirectTargetsMap: new Map() as RedirectTargetsMap, + }; +} diff --git a/src/__tests__/tsHarness/virtualHost.ts b/src/__tests__/tsHarness/virtualHost.ts new file mode 100644 index 00000000..12ef08aa --- /dev/null +++ b/src/__tests__/tsHarness/virtualHost.ts @@ -0,0 +1,170 @@ +import { + ResolvedModule, + CompilerHost, + ScriptTarget, + SourceFile, + createModuleResolutionCache, + resolveModuleName, + createSourceFile, +} from '@0no-co/typescript.js'; + +import path from 'node:path/posix'; +import { compilerOptions } from './compilerOptions'; +import { readVirtualModule } from './virtualModules'; + +export type FileData = Uint8Array | string; +export type Files = Record; + +class File { + cache: Record = Object.create(null); + name: string; + data: Uint8Array | null; + text: string | null; + + constructor(name: string, data: Uint8Array | string) { + this.name = name; + if (typeof data === 'string') { + this.text = data; + this.data = null; + } else { + this.text = null; + this.data = data; + } + } + + toSourceFile(target: ScriptTarget) { + return ( + this.cache[target] || + (this.cache[target] = createSourceFile(this.name, this.toString(), target)) + ); + } + + toBuffer(): Uint8Array { + return this.data || (this.data = new TextEncoder().encode(this.text!)); + } + + toString() { + return this.text || (this.text = new TextDecoder().decode(this.data!)); + } +} + +class Directory { + children: Record = Object.create(null); + + get(name: string): Directory | File | undefined { + return this.children[name]; + } + + dir(name: string): Directory { + const entry = this.children[name]; + return entry instanceof Directory ? entry : (this.children[name] = new Directory()); + } + + set(name: string, file: File) { + this.children[name] = file; + } +} + +export type VirtualHost = ReturnType extends infer U + ? U extends CompilerHost + ? U + : never + : never; + +export function createVirtualHost(files: Files) { + files = { ...files, ...readVirtualModule('@0no-co/typescript.js') }; + + const cache = createModuleResolutionCache(path.sep, normalize, compilerOptions); + const root = new Directory(); + + function normalize(filename: string) { + return path.normalize(!filename.startsWith(path.sep) ? path.sep + filename : filename); + } + + function split(filename: string): string[] { + return filename.split(path.sep).slice(1); + } + + function lookup(filename: string): File | Directory | undefined { + const parts = split(normalize(filename)); + let directory = root; + for (let i = 0; i < parts.length - 1; i++) directory = directory.dir(parts[i]); + return directory.get(parts[parts.length - 1]); + } + + for (const key in files) { + const name = normalize(key); + const data = files[key]; + const parts = split(name); + let directory = root; + for (let i = 0; i < parts.length - 1; i++) directory = directory.dir(parts[i]); + directory.set(parts[parts.length - 1], new File(name, data)); + } + + return { + getCanonicalFileName: normalize, + getDefaultLibFileName() { + // TODO: When another lib with references is selected, the resolution mode doesn't adapt + return '/node_modules/@0no-co/typescript.js/lib/lib.es5.d.ts'; + }, + getCurrentDirectory() { + return path.sep; + }, + getNewLine() { + return '\n'; + }, + useCaseSensitiveFileNames() { + return true; + }, + fileExists(filename: string) { + return lookup(filename) instanceof File; + }, + + writeFile(filename: string, content: Uint8Array | string) { + const name = normalize(filename); + const parts = split(name); + let directory = root; + for (let i = 0; i < parts.length - 1; i++) directory = directory.dir(parts[i]); + directory.set(parts[parts.length - 1], new File(name, content)); + }, + + getDirectories(dir: string) { + const entry = lookup(dir); + const result: string[] = []; + if (entry instanceof Directory) { + for (const key in entry.children) { + if (entry.children[key] instanceof Directory) result.push(key); + } + } + return result; + }, + + readFile(filename: string) { + const entry = lookup(filename); + if (entry instanceof File) { + return entry.toString(); + } + }, + + getSourceFile(filename: string, target: ScriptTarget) { + const entry = lookup(filename); + if (entry instanceof File) { + return entry.toSourceFile(target); + } + }, + + resolveModuleNames(moduleNames: string[], containingFile: string) { + const resolvedModules: (ResolvedModule | undefined)[] = []; + for (const moduleName of moduleNames) { + const result = resolveModuleName(moduleName, containingFile, compilerOptions, this, cache); + resolvedModules.push(result.resolvedModule); + } + return resolvedModules; + }, + + resolveRootModule(moduleName: string) { + return resolveModuleName(moduleName, '/', compilerOptions, this)?.resolvedModule + ?.resolvedFileName; + }, + }; +} diff --git a/src/__tests__/tsHarness/virtualModules.ts b/src/__tests__/tsHarness/virtualModules.ts new file mode 100644 index 00000000..eab25672 --- /dev/null +++ b/src/__tests__/tsHarness/virtualModules.ts @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import type { Files, FileData } from './virtualHost'; +import path from 'node:path/posix'; + +const virtualRoot = path.resolve(__dirname, '../../../'); + +export function readFileFromRoot(name: string): FileData { + return fs.readFileSync(path.join(virtualRoot, name)); +} + +export function readVirtualModule(moduleName: string): Files { + const files: Files = {}; + + function walk(directory: string) { + for (const entry of fs.readdirSync(path.resolve(virtualRoot, directory))) { + const file = path.join(directory, entry); + const target = path.resolve(virtualRoot, file); + const stat = fs.statSync(target); + if (stat.isDirectory()) { + walk(file); + } else { + files[file] = fs.readFileSync(target).toString(); + } + } + } + + walk(path.join('node_modules', moduleName)); + return files; +}