diff --git a/packages/typecheck/package.json b/packages/typecheck/package.json index 4f62a1ca..2cb00b2d 100644 --- a/packages/typecheck/package.json +++ b/packages/typecheck/package.json @@ -33,6 +33,7 @@ "chalk": "^4.1.0", "fast-glob": "^3.2.4", "minimist": "^1.2.5", + "resolve-from": "^5.0.0", "typescript": "^4.0.3" } } diff --git a/packages/typecheck/src/TypeScriptServerHost.ts b/packages/typecheck/src/TypeScriptServerHost.ts index 031c5e92..d174dad1 100644 --- a/packages/typecheck/src/TypeScriptServerHost.ts +++ b/packages/typecheck/src/TypeScriptServerHost.ts @@ -3,6 +3,15 @@ import Path from 'path' import { createInterface, Interface } from 'readline' import { Readable, Writable } from 'stream' import Proto from 'typescript/lib/protocol' +import resolveFrom from 'resolve-from' + +function resolve(moduleId: string, directory: string): string { + try { + return resolveFrom(directory, moduleId) + } catch { + return require.resolve(moduleId) + } +} const isDebugMode = process.env.DEBUG != null function debug(...args: any[]): void { @@ -18,7 +27,7 @@ export class TypeScriptServerHost { Proto.CommandTypes.GeterrForProject, ] - public readonly serverPath = require.resolve('typescript/lib/tsserver') + public readonly serverPath = resolve('typescript/lib/tsserver', process.cwd()) public readonly pluginPath = Path.dirname( require.resolve('@vuedx/typescript-plugin-vue/package.json'), ) @@ -44,9 +53,13 @@ export class TypeScriptServerHost { } constructor() { + // prettier-ignore const debugArgs = process.env.DEBUG_TS_SERVER != null - ? ['--logVerbosity', 'verbose', '--logFile', 'tsserver.log'] + ? [ + '--logVerbosity', 'verbose', + '--logFile', process.env.TS_SERVER_LOG_FILE ?? 'tsserver.log', + ] : [] // prettier-ignore this.server = fork(this.serverPath, [ diff --git a/packages/typecheck/src/cli.ts b/packages/typecheck/src/cli.ts index 0a666985..f5a2b9b0 100644 --- a/packages/typecheck/src/cli.ts +++ b/packages/typecheck/src/cli.ts @@ -53,7 +53,7 @@ async function createTextDocument(file: string): Promise { } function toNormalizedPath(fileName: string): string { - return fileName.replace(/\\/g, '/') + return TS.server.toNormalizedPath(fileName) } function formatLocation( diff --git a/packages/typecheck/src/diagnostics.ts b/packages/typecheck/src/diagnostics.ts index 640e9894..277f25e9 100644 --- a/packages/typecheck/src/diagnostics.ts +++ b/packages/typecheck/src/diagnostics.ts @@ -1,8 +1,13 @@ import glob from 'fast-glob' -import { getContainingFile } from '@vuedx/vue-virtual-textdocument' -import ts from 'typescript/lib/tsserverlibrary' // TODO: Load from current directory. +import Path from 'path' +import FS from 'fs' +import type ts from 'typescript/lib/tsserverlibrary' +import TS from 'typescript/lib/tsserverlibrary' import { TypeScriptServerHost } from './TypeScriptServerHost' +function toNormalizedPath(fileName: string): string { + return TS.server.toNormalizedPath(fileName) +} export type Diagnostics = Array<{ fileName: string diagnostics: ts.server.protocol.Diagnostic[] @@ -44,7 +49,7 @@ export async function* getDiagnostics( cancellationToken.onabort = async () => { await host.close() } - + const projectRootPath = toNormalizedPath(directory) const diagnosticsPerFile = new Map< string, { @@ -55,13 +60,12 @@ export async function* getDiagnostics( >() function setDiagnostics( - file: string, + fileName: string, kind: 'semantic' | 'syntax' | 'suggestion', diagnostics: ts.server.protocol.Diagnostic[], ): void { - if (file.includes('/node_modules/')) return + if (fileName.includes('/node_modules/')) return if (diagnostics.length > 0) { - const fileName = getContainingFile(file) const current = diagnosticsPerFile.get(fileName) ?? {} diagnosticsPerFile.set(fileName, { ...current, @@ -81,14 +85,14 @@ export async function* getDiagnostics( })) .filter((item) => item.diagnostics.length > 0) + let useProject: boolean = true const refresh = async (files: string[]): Promise => { diagnosticsPerFile.clear() const start = Date.now() if (logging) console.log(`Checking...`) - const id = await host.sendCommand('geterrForProject', { - file: files[0], - delay: 0, - }) + const id = useProject + ? await host.sendCommand('geterrForProject', { file: files[0], delay: 1 }) + : await host.sendCommand('geterr', { files, delay: 1 }) return await new Promise((resolve) => { const off = host.on('requestCompleted', async (event) => { @@ -104,47 +108,73 @@ export async function* getDiagnostics( }) }) } - - const files = await glob( - ['**/*.vue', '**/*.ts', '**/*.js', '**/*.jsx', '**/*.tsx'], - { - cwd: directory, - absolute: true, - ignore: ['node_modules', 'node_modules/**/*', '**/node_modules'], - }, - ) await host.sendCommand('configure', { hostInfo: '@vuedx/typecheck', preferences: { disableSuggestions: false }, }) - await host.sendCommand('compilerOptionsForInferredProjects', { - options: { - allowJs: true, - checkJs: true, - strict: true, - alwaysStrict: true, - allowNonTsExtensions: true, - jsx: 'preserve' as any, - }, - }) - if (files.length === 0) { - throw new Error('No ts/js/vue files found in current directory.') - } - const checkFile = files.find((file) => /\.(ts|js)x?/.test(file)) ?? files[0] - await host.sendCommand('updateOpen', { - openFiles: [{ file: checkFile, projectRootPath: directory }], - }) + let files: string[] + const jsConfig = Path.resolve(directory, 'jsconfig.json') + const tsConfig = Path.resolve(directory, 'tsconfig.json') + if (FS.existsSync(tsConfig) || FS.existsSync(jsConfig)) { + useProject = true + const configFile = FS.existsSync(tsConfig) ? tsConfig : jsConfig + await host.sendCommand('updateOpen', { + openFiles: [ + { + file: toNormalizedPath(configFile), + projectRootPath, + }, + ], + }) - const { body: project } = await host.sendCommand('projectInfo', { - file: checkFile, - needFileNameList: false, - }) + const { body } = await host.sendCommand('projectInfo', { + file: toNormalizedPath(configFile), + projectFileName: toNormalizedPath(configFile), + needFileNameList: true, + }) + + files = + body?.fileNames?.filter( + (fileName) => + !fileName.includes('/node_modules/') && !fileName.endsWith('.json'), + ) ?? [] + + if (files.length > 0) { + await host.sendCommand('updateOpen', { + closedFiles: [toNormalizedPath(configFile)], + }) + await host.sendCommand('updateOpen', { + openFiles: [ + { + file: toNormalizedPath(files[0]), + projectFileName: toNormalizedPath(configFile), + }, + ], + }) + } + } else { + await host.sendCommand('compilerOptionsForInferredProjects', { + options: { + allowJs: true, + checkJs: true, + strict: true, + alwaysStrict: true, + allowNonTsExtensions: true, + jsx: 'preserve' as any, + }, + }) + + files = ( + await glob(['**/*.vue', '**/*.ts', '**/*.js', '**/*.jsx', '**/*.tsx'], { + cwd: directory, + absolute: true, + ignore: ['node_modules', 'dist'], + }) + ).map((fileName) => toNormalizedPath(fileName)) - if (project?.configFileName?.endsWith('inferredProject1*') === true) { - // Inferred project open all files. await host.sendCommand('updateOpen', { - openFiles: files.map((file) => ({ file, projectRootPath: directory })), + openFiles: files.map((file) => ({ file, projectRootPath })), }) } diff --git a/packages/typecheck/test/typecheck.spec.ts b/packages/typecheck/test/typecheck.spec.ts index bf02d5eb..5973bebb 100644 --- a/packages/typecheck/test/typecheck.spec.ts +++ b/packages/typecheck/test/typecheck.spec.ts @@ -11,7 +11,13 @@ describe('typecheck', () => { const p = fork( bin, [...options, Path.resolve(__dirname, '../../../samples', directory)], - { stdio: 'pipe' }, + { + stdio: 'pipe', + env: { + DEBUG_TS_SERVER: 'yes', + TS_SERVER_LOG_FILE: Path.resolve(__dirname, '../../../test/output/tsserver.log') + }, + }, ) let output = '' diff --git a/packages/typescript-plugin-vue/src/context.ts b/packages/typescript-plugin-vue/src/context.ts index b0ee1a9e..3405f6ca 100644 --- a/packages/typescript-plugin-vue/src/context.ts +++ b/packages/typescript-plugin-vue/src/context.ts @@ -5,6 +5,8 @@ import { } from '@vuedx/analyze' import { first } from '@vuedx/shared' import { + asFsPath, + asFsUri, asUri, DocumentStore, getContainingFile, @@ -64,6 +66,11 @@ function getConfig(config: Partial = {}): PluginConfig { } } +const RE_URI_PREFIX = /^[^:\\\/]+:\/\// +function isUri(uriLike: string): boolean { + return RE_URI_PREFIX.test(uriLike) +} + export class PluginContext { public readonly store: DocumentStore private _config: PluginConfig = getConfig() @@ -76,18 +83,13 @@ export class PluginContext { dispose(): void }> = [] - public constructor(public readonly typescript: typeof TS) { + public constructor (public readonly typescript: typeof TS) { this.store = new ProxyDocumentStore( (uri) => { - const fileName = URI.parse(uri).fsPath + const fileName = asFsPath(uri) const content = this.typescript.sys.readFile(fileName) ?? '' return this.createVueDocument(fileName, content) - }, - (uri) => { - const fileName = URI.parse(uri).fsPath - - return URI.file(this.projectService.toPath(fileName)).toString() - }, + } ) } @@ -216,17 +218,17 @@ export class PluginContext { const newProject = configFile != null ? new ConfiguredVueProject( - rootDir, - packageFile, - packageFile != null ? tryRequire(packageFile) : {}, - configFile, - tryRequire(configFile), - ) + rootDir, + packageFile, + packageFile != null ? tryRequire(packageFile) : {}, + configFile, + tryRequire(configFile), + ) : new InferredVueProject( - rootDir, - packageFile, - packageFile != null ? tryRequire(packageFile) : {}, - ) + rootDir, + packageFile, + packageFile != null ? tryRequire(packageFile) : {}, + ) newProject.setFileNames(fileNames) @@ -307,7 +309,7 @@ export class PluginContext { if (event === this.typescript.FileWatcherEventKind.Deleted) { dispose() } else { - ;(newProject as ConfiguredVueProject).setConfig( + ; (newProject as ConfiguredVueProject).setConfig( tryRequire(configFile), ) newProject.markDirty() @@ -368,7 +370,7 @@ export class PluginContext { } public createVueDocument(fileName: string, content: string): VueTextDocument { - const uri = URI.file(fileName).toString() + const uri = asFsUri(fileName).toString() const document = VueTextDocument.create(uri, 'vue', 0, content, { vueVersion: this.getVueProjectForFile(fileName, true).version, getGlobalComponents: wrapFn('getGlobalComponents', () => { @@ -407,7 +409,7 @@ export class PluginContext { patchScriptInfo(this, scriptInfo) } } - } catch {} + } catch { } } public load(info: TS.server.PluginCreateInfo): void { @@ -456,13 +458,45 @@ function patchProject( ), ) fileNames.delete(vue2supportFile) - context.debug( - `FileNames: ${JSON.stringify(Array.from(fileNames), null, 2)}`, - ) + fileNames.forEach((fileName) => { + if (isVueFile(fileName)) { + const scriptInfo = + context.projectService.getScriptInfo(fileName) ?? + context.projectService.getOrCreateScriptInfoForNormalizedPath( + context.typescript.server.toNormalizedPath(fileName), + false, + ) + + try { + scriptInfo?.getDefaultProject() + } catch { + scriptInfo?.attachToProject(project) + } + } + }) return Array.from(fileNames) as TS.server.NormalizedPath[] }, ) + + tryPatchMethod( + project, + 'containsScriptInfo', + (containsScriptInfo) => (info) => { + if (isVueFile(info.path)) { + const moduleFileName = context.store + .get(info.path) + ?.getDocumentFileName(MODULE_SELECTOR) + + if (moduleFileName != null) { + const info = context.projectService.getScriptInfo(moduleFileName) + if (info != null) return containsScriptInfo(info) + } + } + + return containsScriptInfo(info) + }, + ) } function patchProjectService(context: PluginContext): void { @@ -513,8 +547,8 @@ function patchExtraFileExtensions(context: PluginContext): void { if ( ((context.projectService as any) .hostConfiguration as TS.server.HostConfiguration).extraFileExtensions?.some( - (ext) => ext.extension === 'vue', - ) === true + (ext) => ext.extension === 'vue', + ) === true ) { return } @@ -644,6 +678,8 @@ function patchFileExists(context: PluginContext): void { const document = context.store.get(getContainingFile(fileName)) const result = parseVirtualFileName(fileName) + context.debug(`As per .vue file: "${document?.getDocumentFileName(result!.selector)}"`) + return ( document != null && result != null && @@ -667,7 +703,6 @@ function patchReadFile(context: PluginContext): void { | string | undefined => { if (isVirtualFile(fileName)) { - context.log(`host.readFile("${fileName}")`) const vueFile = getContainingFile(fileName) const scriptInfo = context.projectService.getScriptInfo(vueFile) const document = context.store.get(vueFile) @@ -701,6 +736,10 @@ function patchReadFile(context: PluginContext): void { function patchReadDirectory(context: PluginContext): void { tryPatchMethod(context.serviceHost, 'readDirectory', (readDirectory) => { + context.log( + `[patch] Override readDirectory to include .vue files. (ServiceHost)`, + ) + return wrapFn( 'readDirectory', (path, extensions, exclude, include, depth) => { @@ -746,22 +785,22 @@ function patchModuleResolution( isVueFile(moduleName) ? moduleName + VIRTUAL_FILENAME_SEPARATOR + MODULE_SELECTOR : moduleName.endsWith('.vue?internal') - ? moduleName.replace(/\?internal$/, '') + + ? moduleName.replace(/\?internal$/, '') + VIRTUAL_FILENAME_SEPARATOR + INTERNAL_MODULE_SELECTOR - : moduleName, + : moduleName, ) // TODO: Support paths mapped to .vue files, if needed. const result = resolveModuleNames != null ? resolveModuleNames( - newModuleNames, - containingFile, - reusedNames, - redirectedReferences, - options, - ) + newModuleNames, + containingFile, + reusedNames, + redirectedReferences, + options, + ) : [] const index = moduleNames.indexOf('@@vuedx/vue-2-support') @@ -783,16 +822,15 @@ function patchModuleResolution( if (!containingFile.includes('node_modules')) { context.log( `Module resolution in ${containingFile} :: ` + - JSON.stringify( - moduleNames.map( - (name, index) => - `${name} => ${newModuleNames[index]} => ${ - result[index]?.resolvedFileName ?? '?' - }`, - ), - null, - 2, + JSON.stringify( + moduleNames.map( + (name, index) => + `${name} => ${newModuleNames[index]} => ${result[index]?.resolvedFileName ?? '?' + }`, ), + null, + 2, + ), ) } } @@ -846,7 +884,7 @@ function patchWatchFile(context: PluginContext): void { function patchWatchDirectory(context: PluginContext): void { tryPatchMethod(context.serviceHost, 'watchDirectory', (watchDirectory) => { context.log( - `[patch] Override watchFile to watch virtual files. (ServiceHost)`, + `[patch] Override watchDirectory to watch virtual files. (ServiceHost)`, ) return wrapFn( diff --git a/packages/typescript-plugin-vue/src/helpers/utils.ts b/packages/typescript-plugin-vue/src/helpers/utils.ts index d5603c56..aa65dd76 100644 --- a/packages/typescript-plugin-vue/src/helpers/utils.ts +++ b/packages/typescript-plugin-vue/src/helpers/utils.ts @@ -76,15 +76,15 @@ export function createServerHelper( position: number | TS.TextRange, ): | { - node: Node - ancestors: TraversalAncestors - document: RenderFunctionTextDocument - } + node: Node + ancestors: TraversalAncestors + document: RenderFunctionTextDocument + } | { - node: null - ancestors: TraversalAncestors - document: RenderFunctionTextDocument | null - } { + node: null + ancestors: TraversalAncestors + document: RenderFunctionTextDocument | null + } { const document = getRenderDoc(fileName) if (document?.ast != null) { @@ -122,7 +122,7 @@ export function createServerHelper( ): VueTextDocument | VirtualTextDocument | null { return isVirtualFile(fileName) ? context.store.get(getContainingFile(fileName))?.getDocument(fileName) ?? - null + null : context.store.get(fileName) } @@ -130,8 +130,8 @@ export function createServerHelper( return isVueFile(fileName) ? context.store.get(fileName) : isVirtualFile(fileName) - ? context.store.get(getContainingFile(fileName)) - : null + ? context.store.get(getContainingFile(fileName)) + : null } function getBlockAt( @@ -304,17 +304,22 @@ export function createServerHelper( } } - const project = context.projectService.getDefaultProjectForFile( - context.typescript.server.toNormalizedPath(fileName), - true, - ) - - if (project == null) return fallback ?? languageService - const service = project.getLanguageService() - if (ORIGINAL_LANGUAGE_SERVER in service) { - return (service as any)[ORIGINAL_LANGUAGE_SERVER] - } else { - return service + try { + const project = context.projectService.getDefaultProjectForFile( + context.typescript.server.toNormalizedPath(fileName), + true, + ) + + if (project == null) return fallback ?? languageService + const service = project.getLanguageService() + context.debug(`Project for "${fileName}" is ${project.getProjectName()}`) + if (ORIGINAL_LANGUAGE_SERVER in service) { + return (service as any)[ORIGINAL_LANGUAGE_SERVER] + } else { + return service + } + } catch { + return fallback ?? languageService } } @@ -327,7 +332,7 @@ export function createServerHelper( .getProgram() ?.getSourceFile(fileName) } catch (error) { - context.error(error) + context.error({ message: `${error.name} – ${error.message}`, name: 'NotFoundSourceFileError', stack: error.stack }) } } diff --git a/packages/vue-virtual-textdocument/src/utils.ts b/packages/vue-virtual-textdocument/src/utils.ts index fbd6274f..6481c865 100644 --- a/packages/vue-virtual-textdocument/src/utils.ts +++ b/packages/vue-virtual-textdocument/src/utils.ts @@ -1,6 +1,6 @@ import { SFCBlock } from '@vuedx/compiler-sfc' import Path from 'path' -import { URI } from 'vscode-uri' +import { URI, uriToFsPath } from 'vscode-uri' import { Selector } from './types' export function getLanguageIdFromExtension(ext: string): string { @@ -45,27 +45,52 @@ export function getContainingFile(fileName: string): string { } export function asUri(fileNameOrUri: string): string { - if (/^[a-z]{2,}:\//i.test(fileNameOrUri)) return fileNameOrUri - - const uri = URI.file(replaceSlashes(fileNameOrUri)).toString() + if (isUri(fileNameOrUri)) return fileNameOrUri + const uri = asFsUri(fileNameOrUri) if (isVirtualFile(fileNameOrUri)) { - return uri.replace(/^[^:]+/, 'vue') + return uri.replace(/^file:/, 'vue:') } return uri } +function isUri(fileNameOrUri: string): boolean { + return /^[a-z]{2,}:\//i.test(fileNameOrUri) +} + +function encodeURIComponentMinimal(path: string): string { + let res: string | undefined = undefined; + for (let pos = 0; pos < path.length; pos++) { + const code = path.charCodeAt(pos); + if (code === 35 || code === 63) { + if (res === undefined) { + res = path.substr(0, pos); + } + res += code === 35 ? '%23' : '%3F'; + } else { + if (res !== undefined) { + res += path[pos]; + } + } + } + return res !== undefined ? res : path; +} export function asFsUri(fileName: string): string { - return URI.file(fileName).toString() + return `file://${fileName.startsWith('/') ? '' : '/'}${encodeURIComponentMinimal(replaceSlashes(fileName))}` } export function replaceSlashes(fileName: string): string { - return fileName.replace(/\\/g, '/') + if (fileName.includes('\\')) return fileName.replace(/\\/g, '/') + return fileName } export function asFsPath(uri: string): string { - return replaceSlashes(URI.parse(uri).fsPath) + if (isUri(uri)) { + return replaceSlashes(uriToFsPath(URI.parse(uri), true)) + } + + return replaceSlashes(uri) } export function parseVirtualFileName( @@ -74,7 +99,7 @@ export function parseVirtualFileName( const uri = URI.parse(asUri(fileName)) if (uri.scheme === 'vue') { - let [container, selector] = uri.fsPath.split(VIRTUAL_FILENAME_SEPARATOR) + let [container, selector] = asFsPath(fileName).split(VIRTUAL_FILENAME_SEPARATOR) if (!selector.includes('.')) { selector += '.' // Append a dot when extension is missing. } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53c20612..0f05ae00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,7 @@ importers: chalk: 4.1.0 fast-glob: 3.2.4 minimist: 1.2.5 + resolve-from: 5.0.0 typescript: 4.0.3 specifiers: '@vuedx/typescript-plugin-vue': 'workspace:*' @@ -239,6 +240,7 @@ importers: chalk: ^4.1.0 fast-glob: ^3.2.4 minimist: ^1.2.5 + resolve-from: ^5.0.0 typescript: ^4.0.3 packages/typescript-plugin-vue: dependencies: @@ -6358,7 +6360,6 @@ packages: resolution: integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== /resolve-from/5.0.0: - dev: true engines: node: '>=8' resolution: diff --git a/samples/typecheck-configured/tsconfig.json b/samples/typecheck-configured/tsconfig.json index bcd8df0e..78db18aa 100644 --- a/samples/typecheck-configured/tsconfig.json +++ b/samples/typecheck-configured/tsconfig.json @@ -1,3 +1,3 @@ { - "include": ["src"] + "files": ["src/main.ts"] } diff --git a/samples/typecheck-inferred/.gitignore b/samples/typecheck-inferred/.gitignore new file mode 100644 index 00000000..cbdb9611 --- /dev/null +++ b/samples/typecheck-inferred/.gitignore @@ -0,0 +1 @@ +!dist diff --git a/samples/typecheck-inferred/dist/index.ts b/samples/typecheck-inferred/dist/index.ts new file mode 100644 index 00000000..d5e64d93 --- /dev/null +++ b/samples/typecheck-inferred/dist/index.ts @@ -0,0 +1 @@ +const str: string = 2 diff --git a/test/support/helpers.ts b/test/support/helpers.ts index 993f451c..f76105a7 100644 --- a/test/support/helpers.ts +++ b/test/support/helpers.ts @@ -1,4 +1,5 @@ import FS from 'fs/promises' +import { asFsPath, asFsUri } from '../../packages/vue-virtual-textdocument/src/utils' import Path from 'path' import { CodeEdit, Location, TextSpan } from 'typescript/lib/protocol' import { @@ -6,7 +7,6 @@ import { TextDocument, TextEdit, } from 'vscode-languageserver-textdocument' -import { URI } from 'vscode-uri' export function locationToPosition(loc: Location): Position { return { line: loc.line - 1, character: loc.offset - 1 } @@ -98,7 +98,7 @@ export async function getTextDocument(file: string): Promise { async function createTextDocument(file: string): Promise { const content = await FS.readFile(file, { encoding: 'utf-8' }) const document = TextDocument.create( - URI.file(file).toString(), + asFsUri(file).toString(), Path.posix.extname(file), 0, content, @@ -110,6 +110,5 @@ async function createTextDocument(file: string): Promise { } export function toNormalizedPath(fileName: string): string { - const fsPath = URI.file(fileName).fsPath - return fsPath.replace(/\\/g, '/') + return asFsPath(fileName) }