diff --git a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts index c35d92139..7b013b1fe 100644 --- a/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts +++ b/packages/language-server/src/plugins/typescript/DocumentSnapshot.ts @@ -31,6 +31,7 @@ import { Logger } from '../../logger'; import { dirname, resolve } from 'path'; import { URI } from 'vscode-uri'; import { surroundWithIgnoreComments } from './features/utils'; +import { configLoader } from '../../lib/documents/configLoader'; /** * An error which occurred while trying to parse/preprocess the svelte file contents. @@ -397,16 +398,57 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot { } } -export const kitPageFiles = new Set([ - '+page.ts', - '+page.js', - '+layout.ts', - '+layout.js', - '+page.server.ts', - '+page.server.js', - '+layout.server.ts', - '+layout.server.js' -]); +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. @@ -416,6 +458,8 @@ export const kitPageFiles = new Set([ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSnapshot { scriptKind = getScriptKindFromFileName(this.filePath); scriptInfo = null; + originalText = this.text; + kitFile = false; private lineOffsets?: number[]; private internalLineOffsets?: number[]; private addedCode: Array<{ @@ -425,13 +469,12 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn inserted: string; total: number; }> = []; - private originalText = this.text; - private kitFile = ''; + private paramsPath = 'src/params'; + private serverHooksPath = 'src/hooks.server'; + private clientHooksPath = 'src/hooks.client'; constructor(public version: number, public readonly filePath: string, private text: string) { super(pathToUrl(filePath)); - const basename = path.basename(this.filePath); - this.kitFile = kitPageFiles.has(basename) ? basename : ''; this.adjustText(); } @@ -492,7 +535,7 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn const prev = this.addedCode[idx - 1]; // Special case: pos is in the middle of an added range if (pos > prev.generatedPos && pos < prev.generatedPos + prev.length) { - total -= pos - prev.generatedPos; + return this.originalPositionAt(prev.originalPos); } } @@ -547,145 +590,312 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn private adjustText() { this.addedCode = []; + this.text = this.upsertKitFile(this.filePath) ?? this.originalText; + } - if (this.kitFile) { - const source = ts.createSourceFile( - this.filePath, - this.originalText, - ts.ScriptTarget.Latest, - true, - this.scriptKind + 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); + if (!result) { + return; + } + + if (!this.kitFile) { + const files = configLoader.getConfig(this.filePath)?.kit?.files; + if (files) { + this.paramsPath ||= files.params; + this.serverHooksPath ||= files.hooks?.server; + this.clientHooksPath ||= files.hooks?.client; + } + } + + 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` ); - const insert = (pos: number, inserted: string) => { - const insertionIdx = this.addedCode.findIndex((c) => c.generatedPos > pos); - if (insertionIdx >= 0) { - for (let i = insertionIdx; i < this.addedCode.length; i++) { - this.addedCode[i].generatedPos += inserted.length; - this.addedCode[i].total += inserted.length; - } - const prevTotal = this.addedCode[insertionIdx - 1]?.total ?? 0; - this.addedCode.splice(insertionIdx, 0, { - generatedPos: pos + prevTotal, - originalPos: pos, - length: inserted.length, - inserted, - total: prevTotal + inserted.length - }); - } else { - const prevTotal = this.addedCode[this.addedCode.length - 1]?.total ?? 0; - this.addedCode.push({ - generatedPos: pos + prevTotal, - originalPos: pos, - length: inserted.length, - inserted, - total: prevTotal + inserted.length - }); - } - }; + insert(pos, inserted); + } - const isTsFile = this.filePath.endsWith('.ts'); - const exports = findExports(source, isTsFile); + // 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 load function if not explicitly typed - const load = exports.get('load'); + // 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 ( - load?.type === 'function' && - load.node.parameters.length === 1 && - !load.hasTypeDefinition + api?.type === 'function' && + api.node.parameters.length === 1 && + !api.hasTypeDefinition ) { - const pos = load.node.parameters[0].getEnd(); - const inserted = surroundWithIgnoreComments( - `: import('./$types').${this.kitFile.includes('layout') ? 'Layout' : 'Page'}${ - this.kitFile.includes('server') ? 'Server' : '' - }LoadEvent` - ); + 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; + } - // 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 = surroundWithIgnoreComments( - ` satisfies import('./$types').Actions` - ); - insert(pos, inserted); + 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 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); } + } - // 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 = surroundWithIgnoreComments(` : boolean | 'auto'`); - insert(pos, inserted); + 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); } + } - // add type to trailingSlash variable if not explicitly typed - const trailingSlash = exports.get('trailingSlash'); + 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 ( - trailingSlash?.type === 'var' && - !trailingSlash.hasTypeDefinition && - trailingSlash.node.initializer + fn?.type === 'function' && + fn.node.parameters.length === 1 && + !fn.hasTypeDefinition ) { - const pos = trailingSlash.node.name.getEnd(); - const inserted = surroundWithIgnoreComments(` : 'never' | 'always' | 'ignore'`); - insert(pos, inserted); + 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); + } } + }; - // 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 = surroundWithIgnoreComments(` : boolean`); - insert(pos, inserted); - } + addTypeToFunction('handleError', `import('@sveltejs/kit').HandleServerError`); + addTypeToFunction('handle', `import('@sveltejs/kit').Handle`); + addTypeToFunction('handleFetch', `import('@sveltejs/kit').HandleFetch`); - // 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 = surroundWithIgnoreComments(` : boolean`); - insert(pos, inserted); - } + return true; + } - // 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'); - - // construct generated text from internal text and addedCode array - let pos = 0; - this.text = ''; - for (const added of this.addedCode) { - this.text += this.originalText.slice(pos, added.originalPos) + added.inserted; - pos = added.originalPos; + 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; } - this.text += this.originalText.slice(pos); + 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 { - this.text = this.originalText; + const prevTotal = addedCode[addedCode.length - 1]?.total ?? 0; + addedCode.push({ + generatedPos: pos + prevTotal, + originalPos: pos, + length: inserted.length, + inserted, + total: prevTotal + inserted.length + }); } } + + private createSource() { + return ts.createSourceFile( + this.filePath, + this.originalText, + ts.ScriptTarget.Latest, + true, + this.scriptKind + ); + } } const sourceMapCommentRegExp = /^\/\/[@#] source[M]appingURL=(.+)\r?\n?$/; diff --git a/packages/language-server/src/plugins/typescript/service.ts b/packages/language-server/src/plugins/typescript/service.ts index c64412b23..88cdd3404 100644 --- a/packages/language-server/src/plugins/typescript/service.ts +++ b/packages/language-server/src/plugins/typescript/service.ts @@ -180,8 +180,7 @@ async function createLanguageService( ); // Load all configs within the tsconfig scope and the one above so that they are all loaded - // by the time they need to be accessed synchronously by DocumentSnapshots to determine - // the default language. + // by the time they need to be accessed synchronously by DocumentSnapshots. await configLoader.loadConfigs(workspacePath); const svelteModuleLoader = createSvelteModuleLoader(getSnapshot, compilerOptions, tsSystem); diff --git a/packages/language-server/src/svelte-check.ts b/packages/language-server/src/svelte-check.ts index 3cfd7e4f3..5d0240764 100644 --- a/packages/language-server/src/svelte-check.ts +++ b/packages/language-server/src/svelte-check.ts @@ -1,9 +1,8 @@ -import { basename, isAbsolute } from 'path'; +import { isAbsolute } from 'path'; import ts from 'typescript'; import { Diagnostic, Position, Range } from 'vscode-languageserver'; import { WorkspaceFolder } from 'vscode-languageserver-protocol'; import { Document, DocumentManager } from './lib/documents'; -import { FileSystemProvider } from './plugins/css/FileSystemProvider'; import { Logger } from './logger'; import { LSConfigManager } from './ls-config'; import { @@ -13,11 +12,12 @@ import { SveltePlugin, TypeScriptPlugin } from './plugins'; +import { FileSystemProvider } from './plugins/css/FileSystemProvider'; import { createLanguageServices } from './plugins/css/service'; +import { JSOrTSDocumentSnapshot } from './plugins/typescript/DocumentSnapshot'; +import { isInGeneratedCode } from './plugins/typescript/features/utils'; import { convertRange, getDiagnosticTag, mapSeverity } from './plugins/typescript/utils'; import { pathToUrl, urlToPath } from './utils'; -import { isInGeneratedCode } from './plugins/typescript/features/utils'; -import { kitPageFiles } from './plugins/typescript/DocumentSnapshot'; export type SvelteCheckDiagnosticSource = 'js' | 'css' | 'svelte'; @@ -200,58 +200,92 @@ export class SvelteCheck { (options.skipDefaultLibCheck && file.hasNoDefaultLib) || // ignore JS files in node_modules /\/node_modules\/.+\.(c|m)?js$/.test(file.fileName); - const isKitFile = kitPageFiles.has(basename(file.fileName)); + const snapshot = lsContainer.snapshotManager.get(file.fileName) as + | JSOrTSDocumentSnapshot + | undefined; + const isKitFile = snapshot?.kitFile ?? false; + const diagnostics: Diagnostic[] = []; + const map = (diagnostic: ts.Diagnostic, range?: Range) => ({ + range: + range ?? + convertRange( + { positionAt: file.getLineAndCharacterOfPosition.bind(file) }, + diagnostic + ), + severity: mapSeverity(diagnostic.category), + source: diagnostic.source, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'), + code: diagnostic.code, + tags: getDiagnosticTag(diagnostic) + }); + + if (!skipDiagnosticsForFile) { + const originalDiagnostics = [ + ...lang.getSyntacticDiagnostics(file.fileName), + ...lang.getSuggestionDiagnostics(file.fileName), + ...lang.getSemanticDiagnostics(file.fileName) + ]; - const diagnostics = skipDiagnosticsForFile - ? [] - : [ - ...lang.getSyntacticDiagnostics(file.fileName), - ...lang.getSuggestionDiagnostics(file.fileName), - ...lang.getSemanticDiagnostics(file.fileName) - ] - .filter((diagnostic) => { - if (!isKitFile) { - return true; - } + for (let diagnostic of originalDiagnostics) { + if (!diagnostic.start || !diagnostic.length || !isKitFile) { + diagnostics.push(map(diagnostic)); + continue; + } - if ( - diagnostic.start === undefined || - diagnostic.length === undefined - ) { - return true; - } + let range: Range | undefined = undefined; + const inGenerated = isInGeneratedCode( + file.text, + diagnostic.start, + diagnostic.start + diagnostic.length + ); + if (inGenerated && snapshot) { + const pos = snapshot.getOriginalPosition( + snapshot.positionAt(diagnostic.start) + ); + range = { + start: pos, + end: { + line: pos.line, + // adjust length so it doesn't spill over to the next line + character: pos.character + 1 + } + }; + // If not one of the specific error messages then filter out + if (diagnostic.code === 2307) { + diagnostic = { + ...diagnostic, + messageText: + typeof diagnostic.messageText === 'string' && + diagnostic.messageText.includes('./$types') + ? diagnostic.messageText + + ` (this likely means that SvelteKit's type generation didn't run yet - try running it by executing 'npm run dev' or 'npm run build')` + : diagnostic.messageText + }; + } else if (diagnostic.code === 2694) { + diagnostic = { + ...diagnostic, + messageText: + typeof diagnostic.messageText === 'string' && + diagnostic.messageText.includes('/$types') + ? diagnostic.messageText + + ` (this likely means that SvelteKit's generated types are out of date - try rerunning it by executing 'npm run dev' or 'npm run build')` + : diagnostic.messageText + }; + } else if ( + diagnostic.code !== + 2355 /* A function whose declared type is neither 'void' nor 'any' must return a value */ + ) { + continue; + } + } - const text = lang - .getProgram() - ?.getSourceFile(file.fileName) - ?.getFullText(); - return ( - !text || - !isInGeneratedCode( - text, - diagnostic.start, - diagnostic.start + diagnostic.length - ) - ); - }) - .map((diagnostic) => ({ - range: convertRange( - { positionAt: file.getLineAndCharacterOfPosition.bind(file) }, - diagnostic - ), - severity: mapSeverity(diagnostic.category), - source: diagnostic.source, - message: ts.flattenDiagnosticMessageText( - diagnostic.messageText, - '\n' - ), - code: diagnostic.code, - tags: getDiagnosticTag(diagnostic) - })); + diagnostics.push(map(diagnostic, range)); + } + } return { filePath: file.fileName, - text: file.text, + text: snapshot?.originalText ?? file.text, diagnostics }; } diff --git a/packages/svelte-check/src/writers.ts b/packages/svelte-check/src/writers.ts index aa18a1b38..511468ed5 100644 --- a/packages/svelte-check/src/writers.ts +++ b/packages/svelte-check/src/writers.ts @@ -83,38 +83,25 @@ export class HumanFriendlyWriter implements Writer { private getCodeLine(diagnostic: Diagnostic, text: string) { const startOffset = offsetAt(diagnostic.range.start, text); const endOffset = offsetAt(diagnostic.range.end, text); - const codePrev = this.removeGeneratedCode( - text.substring( - offsetAt({ line: diagnostic.range.start.line, character: 0 }, text), - startOffset - ) + const codePrev = text.substring( + offsetAt({ line: diagnostic.range.start.line, character: 0 }, text), + startOffset ); const codeHighlight = pc.magenta(text.substring(startOffset, endOffset)); - const codePost = this.removeGeneratedCode( - text.substring( - endOffset, - offsetAt( - { line: diagnostic.range.end.line, character: Number.MAX_SAFE_INTEGER }, - text - ) - ) + const codePost = text.substring( + endOffset, + offsetAt({ line: diagnostic.range.end.line, character: Number.MAX_SAFE_INTEGER }, text) ); return codePrev + codeHighlight + codePost; } private getLine(line: number, text: string): string { - return this.removeGeneratedCode( - text.substring( - offsetAt({ line, character: 0 }, text), - offsetAt({ line, character: Number.MAX_SAFE_INTEGER }, text) - ) + return text.substring( + offsetAt({ line, character: 0 }, text), + offsetAt({ line, character: Number.MAX_SAFE_INTEGER }, text) ); } - private removeGeneratedCode(text: string): string { - return text.replace(/\/\*Ωignore_startΩ\*\/.+\/\*Ωignore_endΩ\*\//g, ''); - } - completion( _f: number, errorCount: number, diff --git a/packages/typescript-plugin/src/index.ts b/packages/typescript-plugin/src/index.ts index 387b1c5de..fd60e9871 100644 --- a/packages/typescript-plugin/src/index.ts +++ b/packages/typescript-plugin/src/index.ts @@ -6,7 +6,7 @@ import { SvelteSnapshotManager } from './svelte-snapshots'; import type ts from 'typescript/lib/tsserverlibrary'; import { ConfigManager, Configuration } from './config-manager'; import { ProjectSvelteFilesManager } from './project-svelte-files'; -import { getConfigPathForProject } from './utils'; +import { getConfigPathForProject, hasNodeModule } from './utils'; function init(modules: { typescript: typeof ts }): ts.server.PluginModule { const configManager = new ConfigManager(); @@ -185,15 +185,7 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { function isSvelteProject(compilerOptions: ts.CompilerOptions) { // Add more checks like "no Svelte file found" or "no config file found"? - try { - const isSvelteProject = - typeof compilerOptions.configFilePath !== 'string' || - require.resolve('svelte', { paths: [compilerOptions.configFilePath] }); - return isSvelteProject; - } catch (e) { - // If require.resolve fails, we end up here - return false; - } + return hasNodeModule(compilerOptions, 'svelte'); } function onConfigurationChanged(config: Configuration) { diff --git a/packages/typescript-plugin/src/language-service/completions.ts b/packages/typescript-plugin/src/language-service/completions.ts index 49e5de434..ad05abc60 100644 --- a/packages/typescript-plugin/src/language-service/completions.ts +++ b/packages/typescript-plugin/src/language-service/completions.ts @@ -2,7 +2,7 @@ import { basename, dirname } from 'path'; import type ts from 'typescript/lib/tsserverlibrary'; import { Logger } from '../logger'; import { findNodeAtPosition, isSvelteFilePath, isTopLevelExport, replaceDeep } from '../utils'; -import { getVirtualLS, isKitExportAllowedIn, kitExports } from './sveltekit'; +import { getVirtualLS, isKitRouteExportAllowedIn, kitExports } from './sveltekit'; type _ts = typeof ts; @@ -52,7 +52,7 @@ export function decorateCompletions( if (node && isTopLevelExport(ts, node, source)) { return { entries: Object.entries(kitExports) - .filter(([, value]) => isKitExportAllowedIn(basename(fileName), value)) + .filter(([, value]) => isKitRouteExportAllowedIn(basename(fileName), value)) .map(([key, value]) => ({ kind: ts.ScriptElementKind.constElement, name: key, diff --git a/packages/typescript-plugin/src/language-service/diagnostics.ts b/packages/typescript-plugin/src/language-service/diagnostics.ts index b931deeae..341317e3d 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 type ts from 'typescript/lib/tsserverlibrary'; import { Logger } from '../logger'; import { findExports, findIdentifier, isSvelteFilePath } from '../utils'; -import { getVirtualLS, isKitExportAllowedIn, kitExports } from './sveltekit'; +import { getVirtualLS, isKitRouteExportAllowedIn, kitExports } from './sveltekit'; type _ts = typeof ts; @@ -139,6 +140,13 @@ function getKitDiagnostics< ` (this likely means that SvelteKit's generated types are out of date - try rerunning it by executing 'npm run dev' or 'npm run build')` : diagnostic.messageText }; + } else if (diagnostic.code === 2355) { + // A function whose declared type is neither 'void' nor 'any' must return a value + diagnostic = { + ...diagnostic, + // adjust length so it doesn't spill over to the next line + length: 1 + }; } else { continue; } @@ -156,11 +164,11 @@ function getKitDiagnostics< // We're in a Svelte file - check top level exports // We're using the original file to have the correct position without mapping const source = info.languageService.getProgram()?.getSourceFile(fileName); - const basename = fileName.split('/').pop() || ''; + const basename = path.basename(fileName); const validExports = Object.keys(kitExports).filter((key) => - isKitExportAllowedIn(basename, kitExports[key]) + isKitRouteExportAllowedIn(basename, kitExports[key]) ); - if (source) { + if (source && basename.startsWith('+')) { const exports = findExports(ts, source, /* irrelevant */ false); for (const exportName of exports.keys()) { if (!validExports.includes(exportName) && !exportName.startsWith('_')) { diff --git a/packages/typescript-plugin/src/language-service/sveltekit.ts b/packages/typescript-plugin/src/language-service/sveltekit.ts index db29ba7c5..317141c6c 100644 --- a/packages/typescript-plugin/src/language-service/sveltekit.ts +++ b/packages/typescript-plugin/src/language-service/sveltekit.ts @@ -1,7 +1,7 @@ import path from 'path'; import type ts from 'typescript/lib/tsserverlibrary'; import { Logger } from '../logger'; -import { findExports } from '../utils'; +import { findExports, hasNodeModule } from '../utils'; type _ts = typeof ts; interface KitSnapshot { @@ -24,7 +24,7 @@ const cache = new WeakMap< getKitScriptSnapshotIfUpToDate: (fileName: string) => KitSnapshot | undefined; upsertKitFile: (fileName: string) => void; }; - } + } | null >(); function createApiExport(name: string) { @@ -392,13 +392,71 @@ export const kitExports: Record< PUT: createApiExport('PUT'), PATCH: createApiExport('PATCH'), DELETE: createApiExport('DELETE'), - OPTIONS: createApiExport('OPTIONS') + OPTIONS: createApiExport('OPTIONS'), + // param matching + match: { + allowedIn: [], + displayParts: [], + documentation: [ + { + text: + `A parameter matcher. ` + + `More info: https://kit.svelte.dev/docs/advanced-routing#matching`, + kind: 'text' + } + ] + }, + // hooks + handle: { + allowedIn: [], + displayParts: [], + documentation: [ + { + text: + `The handle hook runs every time the SvelteKit server receives a request and determines the response. ` + + `It receives an 'event' object representing the request and a function called 'resolve', which renders the route and generates a Response. ` + + `This allows you to modify response headers or bodies, or bypass SvelteKit entirely (for implementing routes programmatically, for example). ` + + `More info: https://kit.svelte.dev/docs/hooks#server-hooks-handle`, + kind: 'text' + } + ] + }, + handleFetch: { + allowedIn: [], + displayParts: [], + documentation: [ + { + text: + `The handleFetch hook allows you to modify (or replace) a 'fetch' request that happens inside a 'load' function that runs on the server (or during pre-rendering). ` + + `More info: https://kit.svelte.dev/docs/hooks#server-hooks-handlefetch`, + kind: 'text' + } + ] + }, + handleError: { + allowedIn: [], + displayParts: [], + documentation: [ + { + text: + `The handleError hook runs when an unexpected error is thrown while responding to a request. ` + + `If an unexpected error is thrown during loading or rendering, this function will be called with the error and the event. ` + + `Make sure that this function _never_ throws an error. ` + + `More info: https://kit.svelte.dev/docs/hooks#shared-hooks-handleerror`, + kind: 'text' + } + ] + } }; -export function isKitExportAllowedIn( +export function isKitRouteExportAllowedIn( basename: string, kitExport: typeof kitExports[keyof typeof kitExports] ) { + if (!basename.startsWith('+')) { + return false; + } + const allowedIn = kitExport.allowedIn; return ( (basename.includes('layout') @@ -412,20 +470,54 @@ export function isKitExportAllowedIn( ); } -export function getProxiedLanguageService( - info: ts.server.PluginCreateInfo, - ts: _ts, - logger?: Logger -) { +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) { - return cachedProxiedLanguageService; + if (cachedProxiedLanguageService !== undefined) { + return cachedProxiedLanguageService ?? undefined; + } + + if (!hasNodeModule(info.project.getCompilerOptions(), '@sveltejs/kit')) { + // Not a SvelteKit project, do nothing + cache.set(info, null); + return; } const originalLanguageServiceHost = info.languageServiceHost; class ProxiedLanguageServiceHost implements ts.LanguageServiceHost { - files: Record = {}; + private files: Record = {}; + private paramsPath = 'src/params'; + private serverHooksPath = 'src/hooks.server'; + private clientHooksPath = 'src/hooks.client'; + + constructor() { + const configPath = info.project.getCurrentDirectory() + '/svelte.config.js'; + import(configPath) + .then((module) => { + const config = module.default; + if (config.kit && config.kit.files) { + if (config.kit.files.params) { + this.paramsPath = config.kit.files.params; + } + if (config.kit.files.hooks) { + this.serverHooksPath ||= config.kit.files.hooks.server; + this.clientHooksPath ||= config.kit.files.hooks.client; + } + // We could be more sophisticated with only removing the files that are actually + // wrong but this is good enough given how rare it is that this setting is used + Object.keys(this.files) + .filter((name) => { + return !name.includes('src/hooks') && !name.includes('src/params'); + }) + .forEach((name) => { + delete this.files[name]; + }); + } + }) + .catch(() => {}); + } log() {} @@ -500,7 +592,43 @@ export function getProxiedLanguageService( } upsertKitFile(fileName: string) { - const basename = path.basename(fileName); + let basename = path.basename(fileName); + const result = + this.upserKitRouteFile(fileName, basename) ?? + this.upserKitServerHooksFile(fileName, basename) ?? + this.upserKitClientHooksFile(fileName, basename) ?? + this.upserKitParamsFile(fileName, basename); + 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 snap = ts.ScriptSnapshot.fromString(text); + snap.getChangeRange = (_) => undefined; + this.files[fileName] = { + version: originalLanguageServiceHost.getScriptVersion(fileName), + file: snap, + addedCode + }; + 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); @@ -508,30 +636,7 @@ export function getProxiedLanguageService( const addedCode: KitSnapshot['addedCode'] = []; const insert = (pos: number, inserted: string) => { - const insertionIdx = addedCode.findIndex((c) => c.generatedPos > 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.insertCode(addedCode, pos, inserted); }; const isTsFile = basename.endsWith('.ts'); @@ -621,24 +726,159 @@ export function getProxiedLanguageService( insertApiMethod('DELETE'); insertApiMethod('OPTIONS'); - // construct generated text from internal text and addedCode array - const originalText = source.getFullText(); - let pos = 0; - let text = ''; - for (const added of addedCode) { - text += originalText.slice(pos, added.originalPos) + added.inserted; - pos = added.originalPos; + 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; } - text += originalText.slice(pos); - const snap = ts.ScriptSnapshot.fromString(text); - snap.getChangeRange = (_) => undefined; - this.files[fileName] = { - version: originalLanguageServiceHost.getScriptVersion(fileName), - file: snap, - addedCode + 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); }; - return this.files[fileName]; + + 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 + }); + } } readFile(fileName: string) { @@ -668,19 +908,6 @@ export function getProxiedLanguageService( }; } -const kitPageFiles = new Set([ - '+page.ts', - '+page.js', - '+layout.ts', - '+layout.js', - '+page.server.ts', - '+page.server.js', - '+layout.server.ts', - '+layout.server.js', - '+server.js', - '+server.ts' -]); - export function getVirtualLS( fileName: string, info: ts.server.PluginCreateInfo, @@ -688,6 +915,10 @@ export function getVirtualLS( logger?: Logger ) { const proxy = getProxiedLanguageService(info, ts, logger); + if (!proxy) { + return; + } + const result = proxy.languageServiceHost.getKitScriptSnapshotIfUpToDate(fileName) ?? proxy.languageServiceHost.upsertKitFile(fileName); @@ -702,7 +933,7 @@ export function getVirtualLS( } } -export function toVirtualPos(pos: number, addedCode: KitSnapshot['addedCode']) { +function toVirtualPos(pos: number, addedCode: KitSnapshot['addedCode']) { let total = 0; for (const added of addedCode) { if (pos < added.originalPos) break; @@ -711,7 +942,7 @@ export function toVirtualPos(pos: number, addedCode: KitSnapshot['addedCode']) { return pos + total; } -export function toOriginalPos(pos: number, addedCode: KitSnapshot['addedCode']) { +function toOriginalPos(pos: number, addedCode: KitSnapshot['addedCode']) { let total = 0; let idx = 0; for (; idx < addedCode.length; idx++) { diff --git a/packages/typescript-plugin/src/utils.ts b/packages/typescript-plugin/src/utils.ts index ec74f4fd8..fa0be9325 100644 --- a/packages/typescript-plugin/src/utils.ts +++ b/packages/typescript-plugin/src/utils.ts @@ -311,3 +311,16 @@ export function findIdentifier(ts: _ts, node: ts.Node): ts.Identifier | undefine node = node.parent; } } + +export function hasNodeModule(compilerOptions: ts.CompilerOptions, module: string) { + try { + const hasModule = + typeof compilerOptions.configFilePath !== 'string' || + require.resolve(module, { paths: [compilerOptions.configFilePath] }); + return hasModule; + } catch (e) { + // If require.resolve fails, we end up here, which can be either because the package is not found, + // or (in case of things like SvelteKit) the package is found but the package.json is not exported. + return (e as any)?.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED'; + } +}