diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 3d46e0274..da0f861c8 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -57,7 +57,7 @@ "prettier-plugin-svelte": "~2.9.0", "svelte": "^3.55.0", "svelte-preprocess": "~5.0.0", - "svelte2tsx": "~0.6.0", + "svelte2tsx": "~0.6.4", "typescript": "*", "vscode-css-languageservice": "~6.2.0", "vscode-html-languageservice": "~5.0.0", diff --git a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts index 7b013b1fe..edeb272b6 100644 --- a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts +++ b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts @@ -2,7 +2,7 @@ import { EncodedSourceMap, TraceMap, originalPositionFor } from '@jridgewell/tra import path from 'path'; import { walk } from 'svelte/compiler'; import { TemplateNode } from 'svelte/types/compiler/interfaces'; -import { svelte2tsx, IExportedNames } from 'svelte2tsx'; +import { svelte2tsx, IExportedNames, internalHelpers } from 'svelte2tsx'; import ts from 'typescript'; import { Position, Range, TextDocumentContentChangeEvent } from 'vscode-languageserver'; import { @@ -24,8 +24,7 @@ import { getScriptKindFromAttributes, getScriptKindFromFileName, isSvelteFilePath, - getTsCheckComment, - findExports + getTsCheckComment } from './utils'; import { Logger } from '../../logger'; import { dirname, resolve } from 'path'; @@ -398,58 +397,6 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot { } } -const kitPageFiles = new Set(['+page', '+layout', '+page.server', '+layout.server', '+server']); - -export function isKitFile( - fileName: string, - serverHooksPath: string, - clientHooksPath: string, - paramsPath: string -) { - const basename = path.basename(fileName); - return ( - isKitRouteFile(fileName, basename) || - isServerHooksFile(fileName, basename, serverHooksPath) || - isClientHooksFile(fileName, basename, clientHooksPath) || - isParamsFile(fileName, basename, paramsPath) - ); -} - -function isKitRouteFile(fileName: string, basename: string) { - if (basename.includes('@')) { - // +page@foo -> +page - basename = basename.split('@')[0]; - } else { - basename = basename.slice(0, -path.extname(fileName).length); - } - - return kitPageFiles.has(basename); -} - -function isServerHooksFile(fileName: string, basename: string, serverHooksPath: string) { - return ( - ((basename === 'index.ts' || basename === 'index.js') && - fileName.slice(0, -basename.length - 1).endsWith(serverHooksPath)) || - fileName.slice(0, -path.extname(basename).length).endsWith(serverHooksPath) - ); -} - -function isClientHooksFile(fileName: string, basename: string, clientHooksPath: string) { - return ( - ((basename === 'index.ts' || basename === 'index.js') && - fileName.slice(0, -basename.length - 1).endsWith(clientHooksPath)) || - fileName.slice(0, -path.extname(basename).length).endsWith(clientHooksPath) - ); -} - -function isParamsFile(fileName: string, basename: string, paramsPath: string) { - return ( - fileName.slice(0, -basename.length - 1).endsWith(paramsPath) && - !basename.includes('.test') && - !basename.includes('.spec') - ); -} - /** * A js/ts document snapshot suitable for the ts language service and the plugin. * Since no mapping has to be done here, it also implements the mapper interface. @@ -589,18 +536,20 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn } private adjustText() { - this.addedCode = []; - this.text = this.upsertKitFile(this.filePath) ?? this.originalText; - } - - private upsertKitFile(fileName: string) { - let basename = path.basename(fileName); - const result = - this.upserKitRouteFile(fileName, basename) ?? - this.upserKitServerHooksFile(fileName, basename) ?? - this.upserKitClientHooksFile(fileName, basename) ?? - this.upserKitParamsFile(fileName, basename); + const result = internalHelpers.upsertKitFile( + this.filePath, + { + clientHooksPath: this.clientHooksPath, + paramsPath: this.paramsPath, + serverHooksPath: this.serverHooksPath + }, + () => this.createSource(), + surroundWithIgnoreComments + ); if (!result) { + this.kitFile = false; + this.addedCode = []; + this.text = this.originalText; return; } @@ -613,278 +562,11 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn } } - this.kitFile = true; - - // construct generated text from internal text and addedCode array - let pos = 0; - let text = ''; - for (const added of this.addedCode) { - text += this.originalText.slice(pos, added.originalPos) + added.inserted; - pos = added.originalPos; - } - text += this.originalText.slice(pos); - return text; - } - - private upserKitRouteFile(fileName: string, basename: string) { - if (basename.includes('@')) { - // +page@foo -> +page - basename = basename.split('@')[0]; - } else { - basename = basename.slice(0, -path.extname(fileName).length); - } - if (!kitPageFiles.has(basename)) return; - - const source = this.createSource(); - - this.addedCode = []; - const insert = (pos: number, inserted: string) => { - this.insertCode(this.addedCode, pos, inserted); - }; - - const isTsFile = basename.endsWith('.ts'); - const allExports = findExports(source, isTsFile); - - // add type to load function if not explicitly typed - const load = allExports.get('load'); - if ( - load?.type === 'function' && - load.node.parameters.length === 1 && - !load.hasTypeDefinition - ) { - const pos = load.node.parameters[0].getEnd(); - const inserted = surroundWithIgnoreComments( - `: import('./$types').${basename.includes('layout') ? 'Layout' : 'Page'}${ - basename.includes('server') ? 'Server' : '' - }LoadEvent` - ); - - insert(pos, inserted); - } - - // add type to actions variable if not explicitly typed - const actions = allExports.get('actions'); - if (actions?.type === 'var' && !actions.hasTypeDefinition && actions.node.initializer) { - const pos = actions.node.initializer.getEnd(); - const inserted = surroundWithIgnoreComments(` satisfies import('./$types').Actions`); - insert(pos, inserted); - } - - // add type to prerender variable if not explicitly typed - const prerender = allExports.get('prerender'); - if ( - prerender?.type === 'var' && - !prerender.hasTypeDefinition && - prerender.node.initializer - ) { - const pos = prerender.node.name.getEnd(); - const inserted = surroundWithIgnoreComments(` : boolean | 'auto'`); - insert(pos, inserted); - } - - // add type to trailingSlash variable if not explicitly typed - const trailingSlash = allExports.get('trailingSlash'); - if ( - trailingSlash?.type === 'var' && - !trailingSlash.hasTypeDefinition && - trailingSlash.node.initializer - ) { - const pos = trailingSlash.node.name.getEnd(); - const inserted = surroundWithIgnoreComments(` : 'never' | 'always' | 'ignore'`); - insert(pos, inserted); - } - - // add type to ssr variable if not explicitly typed - const ssr = allExports.get('ssr'); - if (ssr?.type === 'var' && !ssr.hasTypeDefinition && ssr.node.initializer) { - const pos = ssr.node.name.getEnd(); - const inserted = surroundWithIgnoreComments(` : boolean`); - insert(pos, inserted); - } - - // add type to csr variable if not explicitly typed - const csr = allExports.get('csr'); - if (csr?.type === 'var' && !csr.hasTypeDefinition && csr.node.initializer) { - const pos = csr.node.name.getEnd(); - const inserted = surroundWithIgnoreComments(` : boolean`); - insert(pos, inserted); - } - - // add types to GET/PUT/POST/PATCH/DELETE/OPTIONS if not explicitly typed - const insertApiMethod = (name: string) => { - const api = allExports.get(name); - if ( - api?.type === 'function' && - api.node.parameters.length === 1 && - !api.hasTypeDefinition - ) { - const pos = api.node.parameters[0].getEnd(); - const inserted = surroundWithIgnoreComments(`: import('./$types').RequestHandler`); - - insert(pos, inserted); - } - }; - insertApiMethod('GET'); - insertApiMethod('PUT'); - insertApiMethod('POST'); - insertApiMethod('PATCH'); - insertApiMethod('DELETE'); - insertApiMethod('OPTIONS'); - - return true; - } - - private upserKitParamsFile(fileName: string, basename: string) { - if ( - !fileName.slice(0, -basename.length - 1).endsWith(this.paramsPath) || - basename.includes('.test') || - basename.includes('.spec') - ) { - return; - } - - const source = this.createSource(); - - this.addedCode = []; - const insert = (pos: number, inserted: string) => { - this.insertCode(this.addedCode, pos, inserted); - }; + const { text, addedCode } = result; - const isTsFile = basename.endsWith('.ts'); - const allExports = findExports(source, isTsFile); - - // add type to match function if not explicitly typed - const match = allExports.get('match'); - if ( - match?.type === 'function' && - match.node.parameters.length === 1 && - !match.hasTypeDefinition - ) { - const pos = match.node.parameters[0].getEnd(); - const inserted = surroundWithIgnoreComments(`: string`); - insert(pos, inserted); - if (!match.node.type && match.node.body) { - const returnPos = match.node.body.getStart(); - const returnInsertion = surroundWithIgnoreComments(`: boolean`); - insert(returnPos, returnInsertion); - } - } - - return true; - } - - private upserKitClientHooksFile(fileName: string, basename: string) { - const matchesHooksFile = - ((basename === 'index.ts' || basename === 'index.js') && - fileName.slice(0, -basename.length - 1).endsWith(this.clientHooksPath)) || - fileName.slice(0, -path.extname(basename).length).endsWith(this.clientHooksPath); - if (!matchesHooksFile) { - return; - } - - const source = this.createSource(); - - this.addedCode = []; - const insert = (pos: number, inserted: string) => { - this.insertCode(this.addedCode, pos, inserted); - }; - - const isTsFile = basename.endsWith('.ts'); - const allExports = findExports(source, isTsFile); - - // add type to handleError function if not explicitly typed - const handleError = allExports.get('handleError'); - if ( - handleError?.type === 'function' && - handleError.node.parameters.length === 1 && - !handleError.hasTypeDefinition - ) { - const paramPos = handleError.node.parameters[0].getEnd(); - const paramInsertion = surroundWithIgnoreComments( - `: Parameters[0]` - ); - insert(paramPos, paramInsertion); - if (!handleError.node.type && handleError.node.body) { - const returnPos = handleError.node.body.getStart(); - const returnInsertion = surroundWithIgnoreComments( - `: ReturnType` - ); - insert(returnPos, returnInsertion); - } - } - - return { addedCode: this.addedCode, originalText: this.originalText }; - } - - private upserKitServerHooksFile(fileName: string, basename: string) { - const matchesHooksFile = - ((basename === 'index.ts' || basename === 'index.js') && - fileName.slice(0, -basename.length - 1).endsWith(this.serverHooksPath)) || - fileName.slice(0, -path.extname(basename).length).endsWith(this.serverHooksPath); - if (!matchesHooksFile) { - return; - } - - const source = this.createSource(); - - this.addedCode = []; - const insert = (pos: number, inserted: string) => { - this.insertCode(this.addedCode, pos, inserted); - }; - - const isTsFile = basename.endsWith('.ts'); - const allExports = findExports(source, isTsFile); - - const addTypeToFunction = (name: string, type: string) => { - const fn = allExports.get(name); - if ( - fn?.type === 'function' && - fn.node.parameters.length === 1 && - !fn.hasTypeDefinition - ) { - const paramPos = fn.node.parameters[0].getEnd(); - const paramInsertion = surroundWithIgnoreComments(`: Parameters<${type}>[0]`); - insert(paramPos, paramInsertion); - if (!fn.node.type && fn.node.body) { - const returnPos = fn.node.body.getStart(); - const returnInsertion = surroundWithIgnoreComments(`: ReturnType<${type}>`); - insert(returnPos, returnInsertion); - } - } - }; - - addTypeToFunction('handleError', `import('@sveltejs/kit').HandleServerError`); - addTypeToFunction('handle', `import('@sveltejs/kit').Handle`); - addTypeToFunction('handleFetch', `import('@sveltejs/kit').HandleFetch`); - - return true; - } - - private insertCode(addedCode: typeof this.addedCode, pos: number, inserted: string) { - const insertionIdx = addedCode.findIndex((c) => c.originalPos > pos); - if (insertionIdx >= 0) { - for (let i = insertionIdx; i < addedCode.length; i++) { - addedCode[i].generatedPos += inserted.length; - addedCode[i].total += inserted.length; - } - const prevTotal = addedCode[insertionIdx - 1]?.total ?? 0; - addedCode.splice(insertionIdx, 0, { - generatedPos: pos + prevTotal, - originalPos: pos, - length: inserted.length, - inserted, - total: prevTotal + inserted.length - }); - } else { - const prevTotal = addedCode[addedCode.length - 1]?.total ?? 0; - addedCode.push({ - generatedPos: pos + prevTotal, - originalPos: pos, - length: inserted.length, - inserted, - total: prevTotal + inserted.length - }); - } + this.kitFile = true; + this.addedCode = addedCode; + this.text = text; } private createSource() { diff --git a/packages/language-server/src/plugins/typescript/utils.ts b/packages/language-server/src/plugins/typescript/utils.ts index 050ffa187..b9c00d1f2 100644 --- a/packages/language-server/src/plugins/typescript/utils.ts +++ b/packages/language-server/src/plugins/typescript/utils.ts @@ -371,89 +371,3 @@ export function hasTsExtensions(fileName: string) { fileName.endsWith(ts.Extension.Ts) ); } - -/** - * Finds the top level const/let/function exports of a source file. - */ -export function findExports(source: ts.SourceFile, isTsFile: boolean) { - const exports = new Map< - string, - | { - type: 'function'; - node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression; - hasTypeDefinition: boolean; - } - | { - type: 'var'; - node: ts.VariableDeclaration; - hasTypeDefinition: boolean; - } - >(); - // TODO handle indirect exports? - for (const statement of source.statements) { - if ( - ts.isFunctionDeclaration(statement) && - statement.name && - ts.getModifiers(statement)?.[0]?.kind === ts.SyntaxKind.ExportKeyword - ) { - // export function x ... - exports.set(statement.name.text, { - type: 'function', - node: statement, - hasTypeDefinition: hasTypedParameter(statement, isTsFile) - }); - } - if ( - ts.isVariableStatement(statement) && - statement.declarationList.declarations.length === 1 && - ts.getModifiers(statement)?.[0]?.kind === ts.SyntaxKind.ExportKeyword - ) { - // export const x = ... - const declaration = statement.declarationList.declarations[0]; - const hasTypeDefinition = - !!declaration.type || - (!isTsFile && !!ts.getJSDocType(declaration)) || - (!!declaration.initializer && ts.isSatisfiesExpression(declaration.initializer)); - - if ( - declaration.initializer && - (ts.isFunctionExpression(declaration.initializer) || - ts.isArrowFunction(declaration.initializer) || - (ts.isSatisfiesExpression(declaration.initializer) && - ts.isParenthesizedExpression(declaration.initializer.expression) && - (ts.isFunctionExpression(declaration.initializer.expression.expression) || - ts.isArrowFunction(declaration.initializer.expression.expression)))) - ) { - const node = ts.isSatisfiesExpression(declaration.initializer) - ? ((declaration.initializer.expression as ts.ParenthesizedExpression) - .expression as ts.FunctionExpression | ts.ArrowFunction) - : declaration.initializer; - exports.set(declaration.name.getText(), { - type: 'function', - node, - hasTypeDefinition: hasTypeDefinition || hasTypedParameter(node, isTsFile) - }); - } else { - exports.set(declaration.name.getText(), { - type: 'var', - node: declaration, - hasTypeDefinition - }); - } - } - } - - return exports; -} - -function hasTypedParameter( - node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression, - isTsFile: boolean -): boolean { - return ( - !!node.parameters[0]?.type || - (!isTsFile && - (!!ts.getJSDocType(node) || - (node.parameters[0] && !!ts.getJSDocParameterTags(node.parameters[0]).length))) - ); -} diff --git a/packages/svelte2tsx/index.d.ts b/packages/svelte2tsx/index.d.ts index 36c6190e6..d31b970e0 100644 --- a/packages/svelte2tsx/index.d.ts +++ b/packages/svelte2tsx/index.d.ts @@ -1,3 +1,5 @@ +import ts from 'typescript'; + export interface SvelteCompiledToTsx { code: string; map: import("magic-string").SourceMap; @@ -100,3 +102,69 @@ export interface EmitDtsConig extends EmitDtsConfig {} * touch these files. */ export function emitDts(config: EmitDtsConfig): Promise; + + +/** + * ## Internal, do not use! This is subject to change at any time. + * + * Implementation notice: If one of the methods use a TypeScript function which is not from the + * static top level `ts` namespace, it must be passed as a parameter. + */ +export const internalHelpers: { + isKitFile: ( + fileName: string, + options: InternalHelpers.KitFilesSettings + ) => boolean; + isKitRouteFile: (fileName: string, basename: string) =>boolean, + isClientHooksFile: ( + fileName: string, + basename: string, + clientHooksPath: string + ) =>boolean, + isServerHooksFile: ( + fileName: string, + basename: string, + serverHooksPath: string + )=> boolean, + isParamsFile: (fileName: string, basename: string, paramsPath: string) =>boolean, + upsertKitFile: ( + fileName: string, + kitFilesSettings: InternalHelpers.KitFilesSettings, + getSource: () => ts.SourceFile | undefined, + surround?: (code: string) => string + ) => { text: string; addedCode: InternalHelpers.AddedCode[] } | undefined, + toVirtualPos: (pos: number, addedCode: InternalHelpers.AddedCode[]) => number, + toOriginalPos: (pos: number, addedCode: InternalHelpers.AddedCode[]) => {pos: number; inGenerated: boolean}, + findExports: (source: ts.SourceFile, isTsFile: boolean) => Map< + string, + | { + type: 'function'; + node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression; + hasTypeDefinition: boolean; + } + | { + type: 'var'; + node: ts.VariableDeclaration; + hasTypeDefinition: boolean; + } + >, +}; + +/** + * ## Internal, do not use! This is subject to change at any time. + */ +export namespace InternalHelpers { + export interface AddedCode { + generatedPos: number; + originalPos: number; + length: number; + total: number; + inserted: string; + } + + export interface KitFilesSettings { + serverHooksPath: string; + clientHooksPath: string; + paramsPath: string; + } +} diff --git a/packages/svelte2tsx/package.json b/packages/svelte2tsx/package.json index 97ea67c69..65efc3867 100644 --- a/packages/svelte2tsx/package.json +++ b/packages/svelte2tsx/package.json @@ -1,6 +1,6 @@ { "name": "svelte2tsx", - "version": "0.6.0", + "version": "0.6.4", "description": "Convert Svelte components to TSX for type checking", "author": "David Pershouse", "license": "MIT", diff --git a/packages/svelte2tsx/src/helpers/index.ts b/packages/svelte2tsx/src/helpers/index.ts new file mode 100644 index 000000000..83620923b --- /dev/null +++ b/packages/svelte2tsx/src/helpers/index.ts @@ -0,0 +1,29 @@ +import { + isClientHooksFile, + isKitFile, + isKitRouteFile, + isParamsFile, + isServerHooksFile, + toOriginalPos, + toVirtualPos, + upsertKitFile +} from './sveltekit'; +import { findExports } from './typescript'; + +/** + * ## Internal, do not use! This is subject to change at any time. + * + * Implementation notice: If one of the methods use a TypeScript function which is not from the + * static top level `ts` namespace, it must be passed as a parameter. + */ +export const internalHelpers = { + isKitFile, + isKitRouteFile, + isClientHooksFile, + isServerHooksFile, + isParamsFile, + upsertKitFile, + toVirtualPos, + toOriginalPos, + findExports +}; diff --git a/packages/svelte2tsx/src/helpers/sveltekit.ts b/packages/svelte2tsx/src/helpers/sveltekit.ts new file mode 100644 index 000000000..3a5d79bc9 --- /dev/null +++ b/packages/svelte2tsx/src/helpers/sveltekit.ts @@ -0,0 +1,415 @@ +import path from 'path'; +import ts from 'typescript'; +import { findExports } from './typescript'; + +export interface AddedCode { + generatedPos: number; + originalPos: number; + length: number; + total: number; + inserted: string; +} + +export interface KitFilesSettings { + serverHooksPath: string; + clientHooksPath: string; + paramsPath: string; +} + +const kitPageFiles = new Set(['+page', '+layout', '+page.server', '+layout.server', '+server']); + +/** + * Determines whether or not a given file is a SvelteKit-specific file (route file, hooks file, or params file) + */ +export function isKitFile(fileName: string, options: KitFilesSettings): boolean { + const basename = path.basename(fileName); + return ( + isKitRouteFile(fileName, basename) || + isServerHooksFile(fileName, basename, options.serverHooksPath) || + isClientHooksFile(fileName, basename, options.clientHooksPath) || + isParamsFile(fileName, basename, options.paramsPath) + ); +} + +/** + * Determines whether or not a given file is a SvelteKit-specific route file + */ +export function isKitRouteFile(fileName: string, basename: string): boolean { + if (basename.includes('@')) { + // +page@foo -> +page + basename = basename.split('@')[0]; + } else { + basename = basename.slice(0, -path.extname(fileName).length); + } + + return kitPageFiles.has(basename); +} + +/** + * Determines whether or not a given file is a SvelteKit-specific hooks file + */ +export function isServerHooksFile( + fileName: string, + basename: string, + serverHooksPath: string +): boolean { + return ( + ((basename === 'index.ts' || basename === 'index.js') && + fileName.slice(0, -basename.length - 1).endsWith(serverHooksPath)) || + fileName.slice(0, -path.extname(basename).length).endsWith(serverHooksPath) + ); +} + +/** + * Determines whether or not a given file is a SvelteKit-specific hooks file + */ +export function isClientHooksFile( + fileName: string, + basename: string, + clientHooksPath: string +): boolean { + return ( + ((basename === 'index.ts' || basename === 'index.js') && + fileName.slice(0, -basename.length - 1).endsWith(clientHooksPath)) || + fileName.slice(0, -path.extname(basename).length).endsWith(clientHooksPath) + ); +} + +/** + * Determines whether or not a given file is a SvelteKit-specific params file + */ +export function isParamsFile(fileName: string, basename: string, paramsPath: string): boolean { + return ( + fileName.slice(0, -basename.length - 1).endsWith(paramsPath) && + !basename.includes('.test') && + !basename.includes('.spec') + ); +} + +export function upsertKitFile( + fileName: string, + kitFilesSettings: KitFilesSettings, + getSource: () => ts.SourceFile | undefined, + surround: (text: string) => string = (text) => text +): { text: string; addedCode: AddedCode[] } { + let basename = path.basename(fileName); + const result = + upserKitRouteFile(fileName, basename, getSource, surround) ?? + upserKitServerHooksFile( + fileName, + basename, + kitFilesSettings.serverHooksPath, + getSource, + surround + ) ?? + upserKitClientHooksFile( + fileName, + basename, + kitFilesSettings.clientHooksPath, + getSource, + surround + ) ?? + upserKitParamsFile(fileName, basename, kitFilesSettings.paramsPath, getSource, surround); + if (!result) { + return; + } + + // construct generated text from internal text and addedCode array + const { originalText, addedCode } = result; + let pos = 0; + let text = ''; + for (const added of addedCode) { + text += originalText.slice(pos, added.originalPos) + added.inserted; + pos = added.originalPos; + } + text += originalText.slice(pos); + + return { text, addedCode }; +} + +function upserKitRouteFile( + fileName: string, + basename: string, + getSource: () => ts.SourceFile | undefined, + surround: (text: string) => string +) { + if (!isKitRouteFile(fileName, basename)) return; + + const source = getSource(); + if (!source) return; + + const addedCode: AddedCode[] = []; + const insert = (pos: number, inserted: string) => { + insertCode(addedCode, pos, inserted); + }; + + const isTsFile = basename.endsWith('.ts'); + const exports = findExports(source, isTsFile); + + // add type to load function if not explicitly typed + const load = exports.get('load'); + if (load?.type === 'function' && load.node.parameters.length === 1 && !load.hasTypeDefinition) { + const pos = load.node.parameters[0].getEnd(); + const inserted = surround( + `: import('./$types').${basename.includes('layout') ? 'Layout' : 'Page'}${ + basename.includes('server') ? 'Server' : '' + }LoadEvent` + ); + + insert(pos, inserted); + } + + // add type to actions variable if not explicitly typed + const actions = exports.get('actions'); + if (actions?.type === 'var' && !actions.hasTypeDefinition && actions.node.initializer) { + const pos = actions.node.initializer.getEnd(); + const inserted = surround(` satisfies import('./$types').Actions`); + insert(pos, inserted); + } + + // add type to prerender variable if not explicitly typed + const prerender = exports.get('prerender'); + if (prerender?.type === 'var' && !prerender.hasTypeDefinition && prerender.node.initializer) { + const pos = prerender.node.name.getEnd(); + const inserted = surround(` : boolean | 'auto'`); + insert(pos, inserted); + } + + // add type to trailingSlash variable if not explicitly typed + const trailingSlash = exports.get('trailingSlash'); + if ( + trailingSlash?.type === 'var' && + !trailingSlash.hasTypeDefinition && + trailingSlash.node.initializer + ) { + const pos = trailingSlash.node.name.getEnd(); + const inserted = surround(` : 'never' | 'always' | 'ignore'`); + insert(pos, inserted); + } + + // add type to ssr variable if not explicitly typed + const ssr = exports.get('ssr'); + if (ssr?.type === 'var' && !ssr.hasTypeDefinition && ssr.node.initializer) { + const pos = ssr.node.name.getEnd(); + const inserted = surround(` : boolean`); + insert(pos, inserted); + } + + // add type to csr variable if not explicitly typed + const csr = exports.get('csr'); + if (csr?.type === 'var' && !csr.hasTypeDefinition && csr.node.initializer) { + const pos = csr.node.name.getEnd(); + const inserted = surround(` : boolean`); + insert(pos, inserted); + } + + // add types to GET/PUT/POST/PATCH/DELETE/OPTIONS if not explicitly typed + const insertApiMethod = (name: string) => { + const api = exports.get(name); + if ( + api?.type === 'function' && + api.node.parameters.length === 1 && + !api.hasTypeDefinition + ) { + const pos = api.node.parameters[0].getEnd(); + const inserted = surround(`: import('./$types').RequestHandler`); + + insert(pos, inserted); + } + }; + insertApiMethod('GET'); + insertApiMethod('PUT'); + insertApiMethod('POST'); + insertApiMethod('PATCH'); + insertApiMethod('DELETE'); + insertApiMethod('OPTIONS'); + + return { addedCode, originalText: source.getFullText() }; +} + +function upserKitParamsFile( + fileName: string, + basename: string, + paramsPath: string, + getSource: () => ts.SourceFile | undefined, + surround: (text: string) => string +) { + if (!isParamsFile(fileName, basename, paramsPath)) { + return; + } + + const source = getSource(); + if (!source) return; + + const addedCode: AddedCode[] = []; + const insert = (pos: number, inserted: string) => { + insertCode(addedCode, pos, inserted); + }; + + const isTsFile = basename.endsWith('.ts'); + const exports = findExports(source, isTsFile); + + // add type to match function if not explicitly typed + const match = exports.get('match'); + if ( + match?.type === 'function' && + match.node.parameters.length === 1 && + !match.hasTypeDefinition + ) { + const pos = match.node.parameters[0].getEnd(); + const inserted = surround(`: string`); + insert(pos, inserted); + if (!match.node.type && match.node.body) { + const returnPos = match.node.body.getStart(); + const returnInsertion = surround(`: boolean`); + insert(returnPos, returnInsertion); + } + } + + return { addedCode, originalText: source.getFullText() }; +} + +function upserKitClientHooksFile( + fileName: string, + basename: string, + clientHooksPath: string, + getSource: () => ts.SourceFile | undefined, + surround: (text: string) => string +) { + if (!isClientHooksFile(fileName, basename, clientHooksPath)) { + return; + } + + const source = getSource(); + if (!source) return; + + const addedCode: AddedCode[] = []; + const insert = (pos: number, inserted: string) => { + insertCode(addedCode, pos, inserted); + }; + + const isTsFile = basename.endsWith('.ts'); + const exports = findExports(source, isTsFile); + + // add type to handleError function if not explicitly typed + const handleError = exports.get('handleError'); + if ( + handleError?.type === 'function' && + handleError.node.parameters.length === 1 && + !handleError.hasTypeDefinition + ) { + const paramPos = handleError.node.parameters[0].getEnd(); + const paramInsertion = surround( + `: Parameters[0]` + ); + insert(paramPos, paramInsertion); + if (!handleError.node.type && handleError.node.body) { + const returnPos = handleError.node.body.getStart(); + const returnInsertion = surround( + `: ReturnType` + ); + insert(returnPos, returnInsertion); + } + } + + return { addedCode, originalText: source.getFullText() }; +} + +function upserKitServerHooksFile( + fileName: string, + basename: string, + serverHooksPath: string, + getSource: () => ts.SourceFile | undefined, + surround: (text: string) => string +) { + if (!isServerHooksFile(fileName, basename, serverHooksPath)) { + return; + } + + const source = getSource(); + if (!source) return; + + const addedCode: AddedCode[] = []; + const insert = (pos: number, inserted: string) => { + insertCode(addedCode, pos, inserted); + }; + + const isTsFile = basename.endsWith('.ts'); + const exports = findExports(source, isTsFile); + + const addTypeToFunction = (name: string, type: string) => { + const fn = exports.get(name); + if (fn?.type === 'function' && fn.node.parameters.length === 1 && !fn.hasTypeDefinition) { + const paramPos = fn.node.parameters[0].getEnd(); + const paramInsertion = surround(`: Parameters<${type}>[0]`); + insert(paramPos, paramInsertion); + if (!fn.node.type && fn.node.body) { + const returnPos = fn.node.body.getStart(); + const returnInsertion = surround(`: ReturnType<${type}>`); + insert(returnPos, returnInsertion); + } + } + }; + + addTypeToFunction('handleError', `import('@sveltejs/kit').HandleServerError`); + addTypeToFunction('handle', `import('@sveltejs/kit').Handle`); + addTypeToFunction('handleFetch', `import('@sveltejs/kit').HandleFetch`); + + return { addedCode, originalText: source.getFullText() }; +} + +function insertCode(addedCode: AddedCode[], pos: number, inserted: string) { + const insertionIdx = addedCode.findIndex((c) => c.originalPos > pos); + if (insertionIdx >= 0) { + for (let i = insertionIdx; i < addedCode.length; i++) { + addedCode[i].generatedPos += inserted.length; + addedCode[i].total += inserted.length; + } + const prevTotal = addedCode[insertionIdx - 1]?.total ?? 0; + addedCode.splice(insertionIdx, 0, { + generatedPos: pos + prevTotal, + originalPos: pos, + length: inserted.length, + inserted, + total: prevTotal + inserted.length + }); + } else { + const prevTotal = addedCode[addedCode.length - 1]?.total ?? 0; + addedCode.push({ + generatedPos: pos + prevTotal, + originalPos: pos, + length: inserted.length, + inserted, + total: prevTotal + inserted.length + }); + } +} + +export function toVirtualPos(pos: number, addedCode: AddedCode[]) { + let total = 0; + for (const added of addedCode) { + if (pos < added.originalPos) break; + total += added.length; + } + return pos + total; +} + +export function toOriginalPos(pos: number, addedCode: AddedCode[]) { + let total = 0; + let idx = 0; + for (; idx < addedCode.length; idx++) { + const added = addedCode[idx]; + if (pos < added.generatedPos) break; + total += added.length; + } + + if (idx > 0) { + const prev = addedCode[idx - 1]; + // If pos is in the middle of an added range, return the start of the addition + if (pos > prev.generatedPos && pos < prev.generatedPos + prev.length) { + return { pos: prev.originalPos, inGenerated: true }; + } + } + + return { pos: pos - total, inGenerated: false }; +} diff --git a/packages/svelte2tsx/src/helpers/typescript.ts b/packages/svelte2tsx/src/helpers/typescript.ts new file mode 100644 index 000000000..e7dad8098 --- /dev/null +++ b/packages/svelte2tsx/src/helpers/typescript.ts @@ -0,0 +1,87 @@ +import ts from 'typescript'; + +/** + * Finds the top level const/let/function exports of a source file. + */ +export function findExports(source: ts.SourceFile, isTsFile: boolean) { + const exports = new Map< + string, + | { + type: 'function'; + node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression; + hasTypeDefinition: boolean; + } + | { + type: 'var'; + node: ts.VariableDeclaration; + hasTypeDefinition: boolean; + } + >(); + // TODO handle indirect exports? + for (const statement of source.statements) { + if ( + ts.isFunctionDeclaration(statement) && + statement.name && + ts.getModifiers(statement)?.[0]?.kind === ts.SyntaxKind.ExportKeyword + ) { + // export function x ... + exports.set(statement.name.text, { + type: 'function', + node: statement, + hasTypeDefinition: hasTypedParameter(statement, isTsFile) + }); + } + if ( + ts.isVariableStatement(statement) && + statement.declarationList.declarations.length === 1 && + ts.getModifiers(statement)?.[0]?.kind === ts.SyntaxKind.ExportKeyword + ) { + // export const x = ... + const declaration = statement.declarationList.declarations[0]; + const hasTypeDefinition = + !!declaration.type || + (!isTsFile && !!ts.getJSDocType(declaration)) || + (!!declaration.initializer && ts.isSatisfiesExpression(declaration.initializer)); + + if ( + declaration.initializer && + (ts.isFunctionExpression(declaration.initializer) || + ts.isArrowFunction(declaration.initializer) || + (ts.isSatisfiesExpression(declaration.initializer) && + ts.isParenthesizedExpression(declaration.initializer.expression) && + (ts.isFunctionExpression(declaration.initializer.expression.expression) || + ts.isArrowFunction(declaration.initializer.expression.expression)))) + ) { + const node = ts.isSatisfiesExpression(declaration.initializer) + ? ((declaration.initializer.expression as ts.ParenthesizedExpression) + .expression as ts.FunctionExpression | ts.ArrowFunction) + : declaration.initializer; + exports.set(declaration.name.getText(), { + type: 'function', + node, + hasTypeDefinition: hasTypeDefinition || hasTypedParameter(node, isTsFile) + }); + } else { + exports.set(declaration.name.getText(), { + type: 'var', + node: declaration, + hasTypeDefinition + }); + } + } + } + + return exports; +} + +function hasTypedParameter( + node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression, + isTsFile: boolean +): boolean { + return ( + !!node.parameters[0]?.type || + (!isTsFile && + (!!ts.getJSDocType(node) || + (node.parameters[0] && !!ts.getJSDocParameterTags(node.parameters[0]).length))) + ); +} diff --git a/packages/svelte2tsx/src/index.ts b/packages/svelte2tsx/src/index.ts index ef3d5bcf2..36d193cd7 100644 --- a/packages/svelte2tsx/src/index.ts +++ b/packages/svelte2tsx/src/index.ts @@ -1,2 +1,3 @@ export { svelte2tsx } from './svelte2tsx'; export { emitDts } from './emitDts'; +export { internalHelpers } from './helpers'; diff --git a/packages/typescript-plugin/package.json b/packages/typescript-plugin/package.json index 89632070a..041926110 100644 --- a/packages/typescript-plugin/package.json +++ b/packages/typescript-plugin/package.json @@ -24,6 +24,6 @@ }, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.14", - "svelte2tsx": "~0.6.0" + "svelte2tsx": "~0.6.4" } } diff --git a/packages/typescript-plugin/src/language-service/diagnostics.ts b/packages/typescript-plugin/src/language-service/diagnostics.ts index 341317e3d..7ac5d67a1 100644 --- a/packages/typescript-plugin/src/language-service/diagnostics.ts +++ b/packages/typescript-plugin/src/language-service/diagnostics.ts @@ -1,7 +1,8 @@ import path from 'path'; +import { internalHelpers } from 'svelte2tsx'; import type ts from 'typescript/lib/tsserverlibrary'; import { Logger } from '../logger'; -import { findExports, findIdentifier, isSvelteFilePath } from '../utils'; +import { findIdentifier, isSvelteFilePath } from '../utils'; import { getVirtualLS, isKitRouteExportAllowedIn, kitExports } from './sveltekit'; type _ts = typeof ts; @@ -169,7 +170,7 @@ function getKitDiagnostics< isKitRouteExportAllowedIn(basename, kitExports[key]) ); if (source && basename.startsWith('+')) { - const exports = findExports(ts, source, /* irrelevant */ false); + const exports = internalHelpers.findExports(source, /* irrelevant */ false); for (const exportName of exports.keys()) { if (!validExports.includes(exportName) && !exportName.startsWith('_')) { const node = exports.get(exportName)!.node; diff --git a/packages/typescript-plugin/src/language-service/index.ts b/packages/typescript-plugin/src/language-service/index.ts index f3ca487cc..9f7ab4f69 100644 --- a/packages/typescript-plugin/src/language-service/index.ts +++ b/packages/typescript-plugin/src/language-service/index.ts @@ -10,6 +10,7 @@ import { decorateDiagnostics } from './diagnostics'; import { decorateFindReferences } from './find-references'; import { decorateHover } from './hover'; import { decorateGetImplementation } from './implementation'; +import { decorateInlayHints } from './inlay-hints'; import { decorateRename } from './rename'; import { decorateUpdateImports } from './update-imports'; @@ -51,6 +52,7 @@ function decorateLanguageServiceInner( decorateUpdateImports(ls, snapshotManager, logger); decorateCallHierarchy(ls, snapshotManager, typescript); decorateHover(ls, info, typescript, logger); + decorateInlayHints(ls, info, typescript, logger); return ls; } diff --git a/packages/typescript-plugin/src/language-service/inlay-hints.ts b/packages/typescript-plugin/src/language-service/inlay-hints.ts new file mode 100644 index 000000000..bc108abae --- /dev/null +++ b/packages/typescript-plugin/src/language-service/inlay-hints.ts @@ -0,0 +1,35 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { getVirtualLS } from './sveltekit'; + +type _ts = typeof ts; + +export function decorateInlayHints( + ls: ts.LanguageService, + info: ts.server.PluginCreateInfo, + ts: _ts, + logger: Logger +): void { + const provideInlayHints = ls.provideInlayHints; + ls.provideInlayHints = (fileName, span, preferences) => { + const result = getVirtualLS(fileName, info, ts); + if (!result) { + return provideInlayHints(fileName, span, preferences); + } + + const { languageService, toVirtualPos, toOriginalPos } = result; + return languageService + .provideInlayHints( + fileName, + { + start: toVirtualPos(span.start), + length: span.length + }, + preferences + ) + .map((hint) => ({ + ...hint, + position: toOriginalPos(hint.position).pos + })); + }; +} diff --git a/packages/typescript-plugin/src/language-service/sveltekit.ts b/packages/typescript-plugin/src/language-service/sveltekit.ts index 317141c6c..2eecd1d54 100644 --- a/packages/typescript-plugin/src/language-service/sveltekit.ts +++ b/packages/typescript-plugin/src/language-service/sveltekit.ts @@ -1,19 +1,13 @@ -import path from 'path'; import type ts from 'typescript/lib/tsserverlibrary'; import { Logger } from '../logger'; -import { findExports, hasNodeModule } from '../utils'; +import { hasNodeModule } from '../utils'; +import { InternalHelpers, internalHelpers } from 'svelte2tsx'; type _ts = typeof ts; interface KitSnapshot { file: ts.IScriptSnapshot; version: string; - addedCode: Array<{ - generatedPos: number; - originalPos: number; - length: number; - total: number; - inserted: string; - }>; + addedCode: InternalHelpers.AddedCode[]; } const cache = new WeakMap< @@ -470,8 +464,6 @@ export function isKitRouteExportAllowedIn( ); } -const kitPageFiles = new Set(['+page', '+layout', '+page.server', '+layout.server', '+server']); - function getProxiedLanguageService(info: ts.server.PluginCreateInfo, ts: _ts, logger?: Logger) { const cachedProxiedLanguageService = cache.get(info); if (cachedProxiedLanguageService !== undefined) { @@ -488,9 +480,9 @@ function getProxiedLanguageService(info: ts.server.PluginCreateInfo, ts: _ts, lo class ProxiedLanguageServiceHost implements ts.LanguageServiceHost { private files: Record = {}; - private paramsPath = 'src/params'; - private serverHooksPath = 'src/hooks.server'; - private clientHooksPath = 'src/hooks.client'; + paramsPath = 'src/params'; + serverHooksPath = 'src/hooks.server'; + clientHooksPath = 'src/hooks.client'; constructor() { const configPath = info.project.getCurrentDirectory() + '/svelte.config.js'; @@ -529,10 +521,6 @@ function getProxiedLanguageService(info: ts.server.PluginCreateInfo, ts: _ts, lo return originalLanguageServiceHost.getCompilationSettings(); } - getScriptIsOpen() { - return true; - } - getCurrentDirectory() { return originalLanguageServiceHost.getCurrentDirectory(); } @@ -592,26 +580,20 @@ function getProxiedLanguageService(info: ts.server.PluginCreateInfo, ts: _ts, lo } upsertKitFile(fileName: string) { - let basename = path.basename(fileName); - const result = - this.upserKitRouteFile(fileName, basename) ?? - this.upserKitServerHooksFile(fileName, basename) ?? - this.upserKitClientHooksFile(fileName, basename) ?? - this.upserKitParamsFile(fileName, basename); + const result = internalHelpers.upsertKitFile( + fileName, + { + clientHooksPath: this.clientHooksPath, + paramsPath: this.paramsPath, + serverHooksPath: this.serverHooksPath + }, + () => info.languageService.getProgram()?.getSourceFile(fileName) + ); if (!result) { return; } - // construct generated text from internal text and addedCode array - const { originalText, addedCode } = result; - let pos = 0; - let text = ''; - for (const added of addedCode) { - text += originalText.slice(pos, added.originalPos) + added.inserted; - pos = added.originalPos; - } - text += originalText.slice(pos); - + const { text, addedCode } = result; const snap = ts.ScriptSnapshot.fromString(text); snap.getChangeRange = (_) => undefined; this.files[fileName] = { @@ -622,264 +604,15 @@ function getProxiedLanguageService(info: ts.server.PluginCreateInfo, ts: _ts, lo return this.files[fileName]; } - private upserKitRouteFile(fileName: string, basename: string) { - if (basename.includes('@')) { - // +page@foo -> +page - basename = basename.split('@')[0]; - } else { - basename = basename.slice(0, -path.extname(fileName).length); - } - if (!kitPageFiles.has(basename)) return; - - const source = info.languageService.getProgram()?.getSourceFile(fileName); - if (!source) return; - - const addedCode: KitSnapshot['addedCode'] = []; - const insert = (pos: number, inserted: string) => { - this.insertCode(addedCode, pos, inserted); - }; - - const isTsFile = basename.endsWith('.ts'); - const exports = findExports(ts, source, isTsFile); - - // add type to load function if not explicitly typed - const load = exports.get('load'); - if ( - load?.type === 'function' && - load.node.parameters.length === 1 && - !load.hasTypeDefinition - ) { - const pos = load.node.parameters[0].getEnd(); - const inserted = `: import('./$types').${ - basename.includes('layout') ? 'Layout' : 'Page' - }${basename.includes('server') ? 'Server' : ''}LoadEvent`; - - insert(pos, inserted); - } - - // add type to actions variable if not explicitly typed - const actions = exports.get('actions'); - if (actions?.type === 'var' && !actions.hasTypeDefinition && actions.node.initializer) { - const pos = actions.node.initializer.getEnd(); - const inserted = ` satisfies import('./$types').Actions`; - insert(pos, inserted); - } - - // add type to prerender variable if not explicitly typed - const prerender = exports.get('prerender'); - if ( - prerender?.type === 'var' && - !prerender.hasTypeDefinition && - prerender.node.initializer - ) { - const pos = prerender.node.name.getEnd(); - const inserted = ` : boolean | 'auto'`; - insert(pos, inserted); - } - - // add type to trailingSlash variable if not explicitly typed - const trailingSlash = exports.get('trailingSlash'); - if ( - trailingSlash?.type === 'var' && - !trailingSlash.hasTypeDefinition && - trailingSlash.node.initializer - ) { - const pos = trailingSlash.node.name.getEnd(); - const inserted = ` : 'never' | 'always' | 'ignore'`; // TODO this should be exported from kit - insert(pos, inserted); - } - - // add type to ssr variable if not explicitly typed - const ssr = exports.get('ssr'); - if (ssr?.type === 'var' && !ssr.hasTypeDefinition && ssr.node.initializer) { - const pos = ssr.node.name.getEnd(); - const inserted = ` : boolean`; - insert(pos, inserted); - } - - // add type to csr variable if not explicitly typed - const csr = exports.get('csr'); - if (csr?.type === 'var' && !csr.hasTypeDefinition && csr.node.initializer) { - const pos = csr.node.name.getEnd(); - const inserted = ` : boolean`; - insert(pos, inserted); - } - - // add types to GET/PUT/POST/PATCH/DELETE/OPTIONS if not explicitly typed - const insertApiMethod = (name: string) => { - const api = exports.get(name); - if ( - api?.type === 'function' && - api.node.parameters.length === 1 && - !api.hasTypeDefinition - ) { - const pos = api.node.parameters[0].getEnd(); - const inserted = `: import('./$types').RequestHandler`; - - insert(pos, inserted); - } - }; - insertApiMethod('GET'); - insertApiMethod('PUT'); - insertApiMethod('POST'); - insertApiMethod('PATCH'); - insertApiMethod('DELETE'); - insertApiMethod('OPTIONS'); - - return { addedCode, originalText: source.getFullText() }; - } - - private upserKitParamsFile(fileName: string, basename: string) { - if ( - !fileName.slice(0, -basename.length - 1).endsWith(this.paramsPath) || - basename.includes('.test') || - basename.includes('.spec') - ) { - return; - } - - const source = info.languageService.getProgram()?.getSourceFile(fileName); - if (!source) return; - - const addedCode: KitSnapshot['addedCode'] = []; - const insert = (pos: number, inserted: string) => { - this.insertCode(addedCode, pos, inserted); - }; - - const isTsFile = basename.endsWith('.ts'); - const exports = findExports(ts, source, isTsFile); - - // add type to match function if not explicitly typed - const match = exports.get('match'); - if ( - match?.type === 'function' && - match.node.parameters.length === 1 && - !match.hasTypeDefinition - ) { - const pos = match.node.parameters[0].getEnd(); - const inserted = `: string`; - insert(pos, inserted); - if (!match.node.type && match.node.body) { - const returnPos = match.node.body.getStart(); - const returnInsertion = `: boolean`; - insert(returnPos, returnInsertion); - } - } - - return { addedCode, originalText: source.getFullText() }; - } - - private upserKitClientHooksFile(fileName: string, basename: string) { - const matchesHooksFile = - ((basename === 'index.ts' || basename === 'index.js') && - fileName.slice(0, -basename.length - 1).endsWith(this.clientHooksPath)) || - fileName.slice(0, -path.extname(basename).length).endsWith(this.clientHooksPath); - if (!matchesHooksFile) { - return; - } - - const source = info.languageService.getProgram()?.getSourceFile(fileName); - if (!source) return; - - const addedCode: KitSnapshot['addedCode'] = []; - const insert = (pos: number, inserted: string) => { - this.insertCode(addedCode, pos, inserted); - }; - - const isTsFile = basename.endsWith('.ts'); - const exports = findExports(ts, source, isTsFile); - - // add type to handleError function if not explicitly typed - const handleError = exports.get('handleError'); - if ( - handleError?.type === 'function' && - handleError.node.parameters.length === 1 && - !handleError.hasTypeDefinition - ) { - const paramPos = handleError.node.parameters[0].getEnd(); - const paramInsertion = `: Parameters[0]`; - insert(paramPos, paramInsertion); - if (!handleError.node.type && handleError.node.body) { - const returnPos = handleError.node.body.getStart(); - const returnInsertion = `: ReturnType`; - insert(returnPos, returnInsertion); - } - } - - return { addedCode, originalText: source.getFullText() }; - } - - private upserKitServerHooksFile(fileName: string, basename: string) { - const matchesHooksFile = - ((basename === 'index.ts' || basename === 'index.js') && - fileName.slice(0, -basename.length - 1).endsWith(this.serverHooksPath)) || - fileName.slice(0, -path.extname(basename).length).endsWith(this.serverHooksPath); - if (!matchesHooksFile) { - return; - } - - const source = info.languageService.getProgram()?.getSourceFile(fileName); - if (!source) return; - - const addedCode: KitSnapshot['addedCode'] = []; - const insert = (pos: number, inserted: string) => { - this.insertCode(addedCode, pos, inserted); - }; - - const isTsFile = basename.endsWith('.ts'); - const exports = findExports(ts, source, isTsFile); - - const addTypeToFunction = (name: string, type: string) => { - const fn = exports.get(name); - if ( - fn?.type === 'function' && - fn.node.parameters.length === 1 && - !fn.hasTypeDefinition - ) { - const paramPos = fn.node.parameters[0].getEnd(); - const paramInsertion = `: Parameters<${type}>[0]`; - insert(paramPos, paramInsertion); - if (!fn.node.type && fn.node.body) { - const returnPos = fn.node.body.getStart(); - const returnInsertion = `: ReturnType<${type}>`; - insert(returnPos, returnInsertion); - } - } - }; - - addTypeToFunction('handleError', `import('@sveltejs/kit').HandleServerError`); - addTypeToFunction('handle', `import('@sveltejs/kit').Handle`); - addTypeToFunction('handleFetch', `import('@sveltejs/kit').HandleFetch`); - - return { addedCode, originalText: source.getFullText() }; - } - - private insertCode(addedCode: KitSnapshot['addedCode'], pos: number, inserted: string) { - const insertionIdx = addedCode.findIndex((c) => c.originalPos > pos); - if (insertionIdx >= 0) { - for (let i = insertionIdx; i < addedCode.length; i++) { - addedCode[i].generatedPos += inserted.length; - addedCode[i].total += inserted.length; - } - const prevTotal = addedCode[insertionIdx - 1]?.total ?? 0; - addedCode.splice(insertionIdx, 0, { - generatedPos: pos + prevTotal, - originalPos: pos, - length: inserted.length, - inserted, - total: prevTotal + inserted.length - }); - } else { - const prevTotal = addedCode[addedCode.length - 1]?.total ?? 0; - addedCode.push({ - generatedPos: pos + prevTotal, - originalPos: pos, - length: inserted.length, - inserted, - total: prevTotal + inserted.length - }); - } - } + // needed for path auto completions + readDirectory = originalLanguageServiceHost.readDirectory + ? (...args: any[]) => { + return originalLanguageServiceHost.readDirectory!( + // @ts-ignore + ...args + ); + } + : undefined; readFile(fileName: string) { const file = this.files[fileName]; @@ -896,10 +629,12 @@ function getProxiedLanguageService(info: ts.server.PluginCreateInfo, ts: _ts, lo } } + // Ideally we'd create a full Proxy of the language service, but that seems to have cache issues + // with diagnostics, which makes positions go out of sync. const languageServiceHost = new ProxiedLanguageServiceHost(); const languageService = ts.createLanguageService( languageServiceHost, - ts.createDocumentRegistry() + createProxyRegistry(ts, originalLanguageServiceHost, languageServiceHost) ); cache.set(info, { languageService, languageServiceHost }); return { @@ -908,6 +643,89 @@ function getProxiedLanguageService(info: ts.server.PluginCreateInfo, ts: _ts, lo }; } +function createProxyRegistry( + ts: _ts, + originalLanguageServiceHost: ts.LanguageServiceHost, + options: InternalHelpers.KitFilesSettings +) { + // Don't destructure options param, as the value may be mutated through a svelte.config.js later + const registry = ts.createDocumentRegistry(); + const originalRegistry = (originalLanguageServiceHost as any).documentRegistry; + const proxyRegistry: ts.DocumentRegistry = { + ...originalRegistry, + acquireDocumentWithKey( + fileName, + tsPath, + compilationSettingsOrHost, + key, + scriptSnapshot, + version, + scriptKind, + sourceFileOptions + ) { + if (internalHelpers.isKitFile(fileName, options)) { + return registry.acquireDocumentWithKey( + fileName, + tsPath, + compilationSettingsOrHost, + key, + scriptSnapshot, + version, + scriptKind, + sourceFileOptions + ); + } + + return originalRegistry.acquireDocumentWithKey( + fileName, + tsPath, + compilationSettingsOrHost, + key, + scriptSnapshot, + version, + scriptKind, + sourceFileOptions + ); + }, + updateDocumentWithKey( + fileName, + tsPath, + compilationSettingsOrHost, + key, + scriptSnapshot, + version, + scriptKind, + sourceFileOptions + ) { + if (internalHelpers.isKitFile(fileName, options)) { + return registry.updateDocumentWithKey( + fileName, + tsPath, + compilationSettingsOrHost, + key, + scriptSnapshot, + version, + scriptKind, + sourceFileOptions + ); + } + + return originalRegistry.updateDocumentWithKey( + fileName, + tsPath, + compilationSettingsOrHost, + key, + scriptSnapshot, + version, + scriptKind, + sourceFileOptions + ); + } + }; + + return proxyRegistry; +} + export function getVirtualLS( fileName: string, info: ts.server.PluginCreateInfo, @@ -927,37 +745,8 @@ export function getVirtualLS( return { languageService: proxy.languageService, addedCode: result.addedCode, - toVirtualPos: (pos: number) => toVirtualPos(pos, result.addedCode), - toOriginalPos: (pos: number) => toOriginalPos(pos, result.addedCode) + toVirtualPos: (pos: number) => internalHelpers.toVirtualPos(pos, result.addedCode), + toOriginalPos: (pos: number) => internalHelpers.toOriginalPos(pos, result.addedCode) }; } } - -function toVirtualPos(pos: number, addedCode: KitSnapshot['addedCode']) { - let total = 0; - for (const added of addedCode) { - if (pos < added.originalPos) break; - total += added.length; - } - return pos + total; -} - -function toOriginalPos(pos: number, addedCode: KitSnapshot['addedCode']) { - let total = 0; - let idx = 0; - for (; idx < addedCode.length; idx++) { - const added = addedCode[idx]; - if (pos < added.generatedPos) break; - total += added.length; - } - - if (idx > 0) { - const prev = addedCode[idx - 1]; - // If pos is in the middle of an added range, return the start of the addition - if (pos > prev.generatedPos && pos < prev.generatedPos + prev.length) { - return { pos: prev.originalPos, inGenerated: true }; - } - } - - return { pos: pos - total, inGenerated: false }; -} diff --git a/packages/typescript-plugin/src/utils.ts b/packages/typescript-plugin/src/utils.ts index fa0be9325..d304bde39 100644 --- a/packages/typescript-plugin/src/utils.ts +++ b/packages/typescript-plugin/src/utils.ts @@ -204,93 +204,6 @@ export function gatherDescendants( return dest; } -/** - * Finds the top level const/let/function exports of a source file. - */ -export function findExports(ts: _ts, source: ts.SourceFile, isTsFile: boolean) { - const exports = new Map< - string, - | { - type: 'function'; - node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression; - hasTypeDefinition: boolean; - } - | { - type: 'var'; - node: ts.VariableDeclaration; - hasTypeDefinition: boolean; - } - >(); - // TODO handle indirect exports? - for (const statement of source.statements) { - if ( - ts.isFunctionDeclaration(statement) && - statement.name && - ts.getModifiers(statement)?.[0]?.kind === ts.SyntaxKind.ExportKeyword - ) { - // export function x ... - exports.set(statement.name.text, { - type: 'function', - node: statement, - hasTypeDefinition: hasTypedParameter(ts, statement, isTsFile) - }); - } - if ( - ts.isVariableStatement(statement) && - statement.declarationList.declarations.length === 1 && - ts.getModifiers(statement)?.[0]?.kind === ts.SyntaxKind.ExportKeyword - ) { - // export const x = ... - const declaration = statement.declarationList.declarations[0]; - const hasTypeDefinition = - !!declaration.type || - (!isTsFile && !!ts.getJSDocType(declaration)) || - (!!declaration.initializer && ts.isSatisfiesExpression(declaration.initializer)); - - if ( - declaration.initializer && - (ts.isFunctionExpression(declaration.initializer) || - ts.isArrowFunction(declaration.initializer) || - (ts.isSatisfiesExpression(declaration.initializer) && - ts.isParenthesizedExpression(declaration.initializer.expression) && - (ts.isFunctionExpression(declaration.initializer.expression.expression) || - ts.isArrowFunction(declaration.initializer.expression.expression)))) - ) { - const node = ts.isSatisfiesExpression(declaration.initializer) - ? ((declaration.initializer.expression as ts.ParenthesizedExpression) - .expression as ts.FunctionExpression | ts.ArrowFunction) - : declaration.initializer; - exports.set(declaration.name.getText(), { - type: 'function', - node, - hasTypeDefinition: hasTypeDefinition || hasTypedParameter(ts, node, isTsFile) - }); - } else { - exports.set(declaration.name.getText(), { - type: 'var', - node: declaration, - hasTypeDefinition - }); - } - } - } - - return exports; -} - -function hasTypedParameter( - ts: _ts, - node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression, - isTsFile: boolean -): boolean { - return ( - !!node.parameters[0]?.type || - (!isTsFile && - (!!ts.getJSDocType(node) || - (node.parameters[0] && !!ts.getJSDocParameterTags(node.parameters[0]).length))) - ); -} - export function findIdentifier(ts: _ts, node: ts.Node): ts.Identifier | undefined { if (ts.isIdentifier(node)) { return node;