diff --git a/packages/typescript-plugin/.gitignore b/packages/typescript-plugin/.gitignore index dbb1d0f08..96a7feca7 100644 --- a/packages/typescript-plugin/.gitignore +++ b/packages/typescript-plugin/.gitignore @@ -1,5 +1,3 @@ -src/**/*.js -src/**/*.d.ts -src/tsconfig.tsbuildinfo +dist node_modules tsconfig.tsbuildinfo \ No newline at end of file diff --git a/packages/typescript-plugin/.npmignore b/packages/typescript-plugin/.npmignore new file mode 100644 index 000000000..6cf94fb64 --- /dev/null +++ b/packages/typescript-plugin/.npmignore @@ -0,0 +1,5 @@ +/node_modules +/src +tsconfig.json +.gitignore +internal.md diff --git a/packages/typescript-plugin/README.md b/packages/typescript-plugin/README.md new file mode 100644 index 000000000..796e9f6ce --- /dev/null +++ b/packages/typescript-plugin/README.md @@ -0,0 +1,35 @@ +# A TypeScript plugin for Svelte intellisense + +This plugin provides intellisense for interacting with Svelte files. It is in a very early stage, so expect bugs. So far the plugin supports + +- Rename +- Find Usages +- Go To Definition +- Diagnostics + +Note that these features are only available within TS/JS files. Intellisense within Svelte files is provided by the [svelte-language-server](https://www.npmjs.com/package/svelte-language-server). + +## Usage + +The plugin comes packaged with the [Svelte for VS Code extension](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). If you are using that one, you don't need to add it manually. + +Adding it manually: + +`npm install --save-dev typescript-svelte-plugin` + +Then add it to your `tsconfig.json` or `jsconfig.json`: + +``` +{ + "compilerOptions": { + ... + "plugins": [{ + "name": "typescript-svelte-plugin" + }] + } +} +``` + +## Limitations + +Changes to Svelte files are only recognized after they are saved to disk. diff --git a/packages/typescript-plugin/internal.md b/packages/typescript-plugin/internal.md new file mode 100644 index 000000000..1df2d17c7 --- /dev/null +++ b/packages/typescript-plugin/internal.md @@ -0,0 +1,21 @@ +# Notes on how this works internally + +To get a general understanding on how to write a TypeScript plugin, read [this how-to](https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin). + +However, for getting Svelte support inside TS/JS files, we need to do more than what's shown in the how-to. We need to + +- make TypeScript aware that Svelte files exist and can be loaded +- present Svelte files to TypeScript in a way TypeScript understands +- enhance the language service (the part that's shown in the how-to) + +To make TypeScript aware of Svelte files, we need to patch its module resolution algorithm. `.svelte` is not a valid file ending for TypeScript, so it searches for files like `.svelte.ts`. This logic is decorated in `src/module-loader` to also resolve Svelte files. They are resolved to file-type TSX/JSX, which leads us to the next obstacle: to present Svelte files to TypeScript in a way it understands. + +We achieve that by utilizing `svelte2tsx`, which transforms Svelte code into TSX/JSX code. We do that transformation by patching `readFile` of TypeScript's project service in `src/svelte-snapshots`: If a Svelte file is read, transform the code before returning it. During that we also patch the ScriptInfo that TypeScript uses to interact with files. We patch the methods that transform positions to offsets and vice versa and either do transforms on the generated or original code, depending on the situation. + +The last step is to enhance the language service. For that, we patch the desired methods and apply custom logic. Most of that is transforming the generated code positions to the original code positions. + +Along the way, we need to patch some internal methods, which is brittly and hacky, but to our knowledge there currently is no other way. + +## Limitations + +Currently, changes to Svelte files are only recognized after they are saved to disk. That could be changed by adding `"languages": ["svelte"]` to the plugin provide options. The huge disadvantage is that diagnostics, rename etc within Svelte files no longer stay in the control of the language-server, instead TS/JS starts interacting with Svelte files on a much deeper level, which would mean patching many more undocumented/private methods, and having less control of the situation overall. diff --git a/packages/typescript-plugin/package.json b/packages/typescript-plugin/package.json index 42bde7f25..3def53605 100644 --- a/packages/typescript-plugin/package.json +++ b/packages/typescript-plugin/package.json @@ -2,9 +2,10 @@ "name": "typescript-svelte-plugin", "version": "0.1.0", "description": "A TypeScript Plugin providing Svelte intellisense", - "main": "src/index.js", + "main": "dist/src/index.js", "scripts": { "build": "tsc -p ./", + "watch": "tsc -w -p ./", "test": "echo 'NOOP'" }, "keywords": [ @@ -19,5 +20,9 @@ "@tsconfig/node12": "^1.0.0", "@types/node": "^13.9.0", "typescript": "*" + }, + "dependencies": { + "svelte2tsx": "*", + "sourcemap-codec": "^1.4.4" } } diff --git a/packages/typescript-plugin/src/index.ts b/packages/typescript-plugin/src/index.ts index 98c183a61..abc000a2d 100644 --- a/packages/typescript-plugin/src/index.ts +++ b/packages/typescript-plugin/src/index.ts @@ -1,10 +1,56 @@ -function init(modules: { typescript: typeof import('typescript/lib/tsserverlibrary') }) { +import { dirname, resolve } from 'path'; +import { decorateLanguageService } from './language-service'; +import { Logger } from './logger'; +import { patchModuleLoader } from './module-loader'; +import { SvelteSnapshotManager } from './svelte-snapshots'; +import type ts from 'typescript/lib/tsserverlibrary'; + +function init(modules: { typescript: typeof ts }) { function create(info: ts.server.PluginCreateInfo) { - // TODO + const logger = new Logger(info.project.projectService.logger); + logger.log('Starting Svelte plugin'); + + const snapshotManager = new SvelteSnapshotManager( + modules.typescript, + info.project.projectService, + logger, + !!info.project.getCompilerOptions().strict + ); + + patchCompilerOptions(info.project); + patchModuleLoader( + logger, + snapshotManager, + modules.typescript, + info.languageServiceHost, + info.project + ); + return decorateLanguageService(info.languageService, snapshotManager, logger); } function getExternalFiles(project: ts.server.ConfiguredProject) { - // TODO + // Needed so the ambient definitions are known inside the tsx files + const svelteTsPath = dirname(require.resolve('svelte2tsx')); + const svelteTsxFiles = [ + './svelte-shims.d.ts', + './svelte-jsx.d.ts', + './svelte-native-jsx.d.ts' + ].map((f) => modules.typescript.sys.resolvePath(resolve(svelteTsPath, f))); + return svelteTsxFiles; + } + + function patchCompilerOptions(project: ts.server.Project) { + const compilerOptions = project.getCompilerOptions(); + // Patch needed because svelte2tsx creates jsx/tsx files + compilerOptions.jsx = modules.typescript.JsxEmit.Preserve; + + // detect which JSX namespace to use (svelte | svelteNative) if not specified or not compatible + if (!compilerOptions.jsxFactory || !compilerOptions.jsxFactory.startsWith('svelte')) { + // Default to regular svelte, this causes the usage of the "svelte.JSX" namespace + // We don't need to add a switch for svelte-native because the jsx is only relevant + // within Svelte files, which this plugin does not deal with. + compilerOptions.jsxFactory = 'svelte.createElement'; + } } return { create, getExternalFiles }; diff --git a/packages/typescript-plugin/src/language-service/completions.ts b/packages/typescript-plugin/src/language-service/completions.ts new file mode 100644 index 000000000..017f7bea6 --- /dev/null +++ b/packages/typescript-plugin/src/language-service/completions.ts @@ -0,0 +1,70 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { isSvelteFilePath, replaceDeep } from '../utils'; + +const componentPostfix = '__SvelteComponent_'; + +export function decorateCompletions(ls: ts.LanguageService, logger: Logger): void { + const getCompletionsAtPosition = ls.getCompletionsAtPosition; + ls.getCompletionsAtPosition = (fileName, position, options) => { + const completions = getCompletionsAtPosition(fileName, position, options); + if (!completions) { + return completions; + } + return { + ...completions, + entries: completions.entries.map((entry) => { + if ( + !isSvelteFilePath(entry.source || '') || + !entry.name.endsWith(componentPostfix) + ) { + return entry; + } + return { + ...entry, + name: entry.name.slice(0, -componentPostfix.length) + }; + }) + }; + }; + + const getCompletionEntryDetails = ls.getCompletionEntryDetails; + ls.getCompletionEntryDetails = ( + fileName, + position, + entryName, + formatOptions, + source, + preferences + ) => { + const details = getCompletionEntryDetails( + fileName, + position, + entryName, + formatOptions, + source, + preferences + ); + if (details || !isSvelteFilePath(source || '')) { + return details; + } + + // In the completion list we removed the component postfix. Internally, + // the language service saved the list with the postfix, so details + // won't match anything. Therefore add it back and remove it afterwards again. + const svelteDetails = getCompletionEntryDetails( + fileName, + position, + entryName + componentPostfix, + formatOptions, + source, + preferences + ); + if (!svelteDetails) { + return undefined; + } + logger.debug('Found Svelte Component import completion details'); + + return replaceDeep(svelteDetails, componentPostfix, ''); + }; +} diff --git a/packages/typescript-plugin/src/language-service/definition.ts b/packages/typescript-plugin/src/language-service/definition.ts new file mode 100644 index 000000000..15fb6cae5 --- /dev/null +++ b/packages/typescript-plugin/src/language-service/definition.ts @@ -0,0 +1,44 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { SvelteSnapshotManager } from '../svelte-snapshots'; +import { isNotNullOrUndefined, isSvelteFilePath } from '../utils'; + +export function decorateGetDefinition( + ls: ts.LanguageService, + snapshotManager: SvelteSnapshotManager, + logger: Logger +): void { + const getDefinitionAndBoundSpan = ls.getDefinitionAndBoundSpan; + ls.getDefinitionAndBoundSpan = (fileName, position) => { + const definition = getDefinitionAndBoundSpan(fileName, position); + if (!definition?.definitions) { + return definition; + } + + return { + ...definition, + definitions: definition.definitions + .map((def) => { + if (!isSvelteFilePath(def.fileName)) { + return def; + } + + const textSpan = snapshotManager + .get(def.fileName) + ?.getOriginalTextSpan(def.textSpan); + if (!textSpan) { + return undefined; + } + return { + ...def, + textSpan, + // Spare the work for now + originalTextSpan: undefined, + contextSpan: undefined, + originalContextSpan: undefined + }; + }) + .filter(isNotNullOrUndefined) + }; + }; +} diff --git a/packages/typescript-plugin/src/language-service/diagnostics.ts b/packages/typescript-plugin/src/language-service/diagnostics.ts new file mode 100644 index 000000000..78fcd215f --- /dev/null +++ b/packages/typescript-plugin/src/language-service/diagnostics.ts @@ -0,0 +1,45 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { isSvelteFilePath } from '../utils'; + +export function decorateDiagnostics(ls: ts.LanguageService, logger: Logger): void { + decorateSyntacticDiagnostics(ls); + decorateSemanticDiagnostics(ls); + decorateSuggestionDiagnostics(ls); +} + +function decorateSyntacticDiagnostics(ls: ts.LanguageService): void { + const getSyntacticDiagnostics = ls.getSyntacticDiagnostics; + ls.getSyntacticDiagnostics = (fileName: string) => { + // Diagnostics inside Svelte files are done + // by the svelte-language-server / Svelte for VS Code extension + if (isSvelteFilePath(fileName)) { + return []; + } + return getSyntacticDiagnostics(fileName); + }; +} + +function decorateSemanticDiagnostics(ls: ts.LanguageService): void { + const getSemanticDiagnostics = ls.getSemanticDiagnostics; + ls.getSemanticDiagnostics = (fileName: string) => { + // Diagnostics inside Svelte files are done + // by the svelte-language-server / Svelte for VS Code extension + if (isSvelteFilePath(fileName)) { + return []; + } + return getSemanticDiagnostics(fileName); + }; +} + +function decorateSuggestionDiagnostics(ls: ts.LanguageService): void { + const getSuggestionDiagnostics = ls.getSuggestionDiagnostics; + ls.getSuggestionDiagnostics = (fileName: string) => { + // Diagnostics inside Svelte files are done + // by the svelte-language-server / Svelte for VS Code extension + if (isSvelteFilePath(fileName)) { + return []; + } + return getSuggestionDiagnostics(fileName); + }; +} diff --git a/packages/typescript-plugin/src/language-service/find-references.ts b/packages/typescript-plugin/src/language-service/find-references.ts new file mode 100644 index 000000000..f951963cd --- /dev/null +++ b/packages/typescript-plugin/src/language-service/find-references.ts @@ -0,0 +1,95 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { SvelteSnapshotManager } from '../svelte-snapshots'; +import { isNotNullOrUndefined, isSvelteFilePath } from '../utils'; + +export function decorateFindReferences( + ls: ts.LanguageService, + snapshotManager: SvelteSnapshotManager, + logger: Logger +): void { + decorateGetReferencesAtPosition(ls, snapshotManager, logger); + _decorateFindReferences(ls, snapshotManager, logger); +} + +function _decorateFindReferences( + ls: ts.LanguageService, + snapshotManager: SvelteSnapshotManager, + logger: Logger +) { + const findReferences = ls.findReferences; + ls.findReferences = (fileName, position) => { + const references = findReferences(fileName, position); + return references + ?.map((reference) => { + const snapshot = snapshotManager.get(reference.definition.fileName); + if (!isSvelteFilePath(reference.definition.fileName) || !snapshot) { + return reference; + } + + const textSpan = snapshot.getOriginalTextSpan(reference.definition.textSpan); + if (!textSpan) { + return null; + } + + return { + definition: { + ...reference.definition, + textSpan, + // Spare the work for now + originalTextSpan: undefined + }, + references: mapReferences(reference.references, snapshotManager, logger) + }; + }) + .filter(isNotNullOrUndefined); + }; +} + +function decorateGetReferencesAtPosition( + ls: ts.LanguageService, + snapshotManager: SvelteSnapshotManager, + logger: Logger +) { + const getReferencesAtPosition = ls.getReferencesAtPosition; + ls.getReferencesAtPosition = (fileName, position) => { + const references = getReferencesAtPosition(fileName, position); + return references && mapReferences(references, snapshotManager, logger); + }; +} + +function mapReferences( + references: ts.ReferenceEntry[], + snapshotManager: SvelteSnapshotManager, + logger: Logger +): ts.ReferenceEntry[] { + return references + .map((reference) => { + const snapshot = snapshotManager.get(reference.fileName); + if (!isSvelteFilePath(reference.fileName) || !snapshot) { + return reference; + } + + const textSpan = snapshot.getOriginalTextSpan(reference.textSpan); + if (!textSpan) { + return null; + } + + logger.debug( + 'Find references; map textSpan: changed', + reference.textSpan, + 'to', + textSpan + ); + + return { + ...reference, + textSpan, + // Spare the work for now + contextSpan: undefined, + originalTextSpan: undefined, + originalContextSpan: undefined + }; + }) + .filter(isNotNullOrUndefined); +} diff --git a/packages/typescript-plugin/src/language-service/implementation.ts b/packages/typescript-plugin/src/language-service/implementation.ts new file mode 100644 index 000000000..d719714c6 --- /dev/null +++ b/packages/typescript-plugin/src/language-service/implementation.ts @@ -0,0 +1,38 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { SvelteSnapshotManager } from '../svelte-snapshots'; +import { isNotNullOrUndefined, isSvelteFilePath } from '../utils'; + +export function decorateGetImplementation( + ls: ts.LanguageService, + snapshotManager: SvelteSnapshotManager, + logger: Logger +): void { + const getImplementationAtPosition = ls.getImplementationAtPosition; + ls.getImplementationAtPosition = (fileName, position) => { + const implementation = getImplementationAtPosition(fileName, position); + return implementation + ?.map((impl) => { + if (!isSvelteFilePath(impl.fileName)) { + return impl; + } + + const textSpan = snapshotManager + .get(impl.fileName) + ?.getOriginalTextSpan(impl.textSpan); + if (!textSpan) { + return undefined; + } + + return { + ...impl, + textSpan, + // Spare the work for now + contextSpan: undefined, + originalTextSpan: undefined, + originalContextSpan: undefined + }; + }) + .filter(isNotNullOrUndefined); + }; +} diff --git a/packages/typescript-plugin/src/language-service/index.ts b/packages/typescript-plugin/src/language-service/index.ts new file mode 100644 index 000000000..2c166d416 --- /dev/null +++ b/packages/typescript-plugin/src/language-service/index.ts @@ -0,0 +1,43 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { SvelteSnapshotManager } from '../svelte-snapshots'; +import { isSvelteFilePath } from '../utils'; +import { decorateCompletions } from './completions'; +import { decorateGetDefinition } from './definition'; +import { decorateDiagnostics } from './diagnostics'; +import { decorateFindReferences } from './find-references'; +import { decorateGetImplementation } from './implementation'; +import { decorateRename } from './rename'; + +export function decorateLanguageService( + ls: ts.LanguageService, + snapshotManager: SvelteSnapshotManager, + logger: Logger +): ts.LanguageService { + patchLineColumnOffset(ls, snapshotManager); + decorateRename(ls, snapshotManager, logger); + decorateDiagnostics(ls, logger); + decorateFindReferences(ls, snapshotManager, logger); + decorateCompletions(ls, logger); + decorateGetDefinition(ls, snapshotManager, logger); + decorateGetImplementation(ls, snapshotManager, logger); + return ls; +} + +function patchLineColumnOffset(ls: ts.LanguageService, snapshotManager: SvelteSnapshotManager) { + if (!ls.toLineColumnOffset) { + return; + } + + // We need to patch this because (according to source, only) getDefinition uses this + const toLineColumnOffset = ls.toLineColumnOffset; + ls.toLineColumnOffset = (fileName, position) => { + if (isSvelteFilePath(fileName)) { + const snapshot = snapshotManager.get(fileName); + if (snapshot) { + return snapshot.positionAt(position); + } + } + return toLineColumnOffset(fileName, position); + }; +} diff --git a/packages/typescript-plugin/src/language-service/rename.ts b/packages/typescript-plugin/src/language-service/rename.ts new file mode 100644 index 000000000..4d920b61c --- /dev/null +++ b/packages/typescript-plugin/src/language-service/rename.ts @@ -0,0 +1,52 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { SvelteSnapshotManager } from '../svelte-snapshots'; +import { isNotNullOrUndefined, isSvelteFilePath } from '../utils'; + +export function decorateRename( + ls: ts.LanguageService, + snapshotManager: SvelteSnapshotManager, + logger: Logger +): void { + const findRenameLocations = ls.findRenameLocations; + ls.findRenameLocations = ( + fileName, + position, + findInStrings, + findInComments, + providePrefixAndSuffixTextForRename + ) => { + const renameLocations = findRenameLocations( + fileName, + position, + findInStrings, + findInComments, + providePrefixAndSuffixTextForRename + ); + return renameLocations + ?.map((renameLocation) => { + const snapshot = snapshotManager.get(renameLocation.fileName); + if (!isSvelteFilePath(renameLocation.fileName) || !snapshot) { + return renameLocation; + } + + // TODO more needed to filter invalid locations, see RenameProvider + const textSpan = snapshot.getOriginalTextSpan(renameLocation.textSpan); + if (!textSpan) { + return null; + } + + const converted = { + ...renameLocation, + textSpan + }; + if (converted.contextSpan) { + // Not important, spare the work + converted.contextSpan = undefined; + } + logger.debug('Converted rename location ', converted); + return converted; + }) + .filter(isNotNullOrUndefined); + }; +} diff --git a/packages/typescript-plugin/src/logger.ts b/packages/typescript-plugin/src/logger.ts new file mode 100644 index 000000000..76fa0e8e2 --- /dev/null +++ b/packages/typescript-plugin/src/logger.ts @@ -0,0 +1,41 @@ +import type ts from 'typescript/lib/tsserverlibrary'; + +export class Logger { + constructor( + private tsLogService: ts.server.Logger, + suppressNonSvelteLogs = false, + private logDebug = false + ) { + if (suppressNonSvelteLogs) { + const log = this.tsLogService.info.bind(this.tsLogService); + this.tsLogService.info = (s: string) => { + if (s.startsWith('-Svelte Plugin-')) { + log(s); + } + }; + } + } + + log(...args: any[]) { + const str = args + .map((arg) => { + if (typeof arg === 'object') { + try { + return JSON.stringify(arg); + } catch (e) { + return '[object that cannot by stringified]'; + } + } + return arg; + }) + .join(' '); + this.tsLogService.info('-Svelte Plugin- ' + str); + } + + debug(...args: any[]) { + if (!this.logDebug) { + return; + } + this.log(...args); + } +} diff --git a/packages/typescript-plugin/src/module-loader.ts b/packages/typescript-plugin/src/module-loader.ts new file mode 100644 index 000000000..2ce556500 --- /dev/null +++ b/packages/typescript-plugin/src/module-loader.ts @@ -0,0 +1,145 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from './logger'; +import { SvelteSnapshotManager } from './svelte-snapshots'; +import { createSvelteSys } from './svelte-sys'; +import { ensureRealSvelteFilePath, isVirtualSvelteFilePath } from './utils'; + +/** + * Caches resolved modules. + */ +class ModuleResolutionCache { + private cache = new Map(); + + /** + * Tries to get a cached module. + */ + get(moduleName: string, containingFile: string): ts.ResolvedModule | undefined { + return this.cache.get(this.getKey(moduleName, containingFile)); + } + + /** + * Caches resolved module, if it is not undefined. + */ + set(moduleName: string, containingFile: string, resolvedModule: ts.ResolvedModule | undefined) { + if (!resolvedModule) { + return; + } + this.cache.set(this.getKey(moduleName, containingFile), resolvedModule); + } + + /** + * Deletes module from cache. Call this if a file was deleted. + * @param resolvedModuleName full path of the module + */ + delete(resolvedModuleName: string): void { + this.cache.forEach((val, key) => { + if (val.resolvedFileName === resolvedModuleName) { + this.cache.delete(key); + } + }); + } + + private getKey(moduleName: string, containingFile: string) { + return containingFile + ':::' + ensureRealSvelteFilePath(moduleName); + } +} + +/** + * Creates a module loader than can also resolve `.svelte` files. + * + * The typescript language service tries to look up other files that are referenced in the currently open svelte file. + * For `.ts`/`.js` files this works, for `.svelte` files it does not by default. + * Reason: The typescript language service does not know about the `.svelte` file ending, + * so it assumes it's a normal typescript file and searches for files like `../Component.svelte.ts`, which is wrong. + * In order to fix this, we need to wrap typescript's module resolution and reroute all `.svelte.ts` file lookups to .svelte. + */ +export function patchModuleLoader( + logger: Logger, + snapshotManager: SvelteSnapshotManager, + typescript: typeof ts, + lsHost: ts.LanguageServiceHost, + project: ts.server.Project +): void { + const svelteSys = createSvelteSys(logger); + const moduleCache = new ModuleResolutionCache(); + const origResolveModuleNames = lsHost.resolveModuleNames?.bind(lsHost); + + lsHost.resolveModuleNames = resolveModuleNames; + + const origRemoveFile = project.removeFile.bind(project); + project.removeFile = (info, fileExists, detachFromProject) => { + logger.log('File is being removed. Delete from cache: ', info.fileName); + moduleCache.delete(info.fileName); + return origRemoveFile(info, fileExists, detachFromProject); + }; + + function resolveModuleNames( + moduleNames: string[], + containingFile: string, + reusedNames: string[] | undefined, + redirectedReference: ts.ResolvedProjectReference | undefined, + compilerOptions: ts.CompilerOptions + ): Array { + logger.log('Resolving modules names for ' + containingFile); + // Try resolving all module names with the original method first. + // The ones that are undefined will be re-checked if they are a + // Svelte file and if so, are resolved, too. This way we can defer + // all module resolving logic except for Svelte files to TypeScript. + const resolved = + origResolveModuleNames?.( + moduleNames, + containingFile, + reusedNames, + redirectedReference, + compilerOptions + ) || Array.from(Array(moduleNames.length)); + + return resolved.map((moduleName, idx) => { + if (moduleName) { + return moduleName; + } + + const fileName = moduleNames[idx]; + const cachedModule = moduleCache.get(fileName, containingFile); + if (cachedModule) { + return cachedModule; + } + + const resolvedModule = resolveModuleName(fileName, containingFile, compilerOptions); + moduleCache.set(fileName, containingFile, resolvedModule); + return resolvedModule; + }); + } + + function resolveModuleName( + name: string, + containingFile: string, + compilerOptions: ts.CompilerOptions + ): ts.ResolvedModule | undefined { + const svelteResolvedModule = typescript.resolveModuleName( + name, + containingFile, + compilerOptions, + svelteSys + ).resolvedModule; + if ( + !svelteResolvedModule || + !isVirtualSvelteFilePath(svelteResolvedModule.resolvedFileName) + ) { + return svelteResolvedModule; + } + + const resolvedFileName = ensureRealSvelteFilePath(svelteResolvedModule.resolvedFileName); + logger.log('Resolved', name, 'to Svelte file', resolvedFileName); + const snapshot = snapshotManager.create(resolvedFileName); + if (!snapshot) { + return undefined; + } + + const resolvedSvelteModule: ts.ResolvedModuleFull = { + extension: snapshot.isTsFile ? typescript.Extension.Tsx : typescript.Extension.Jsx, + resolvedFileName + }; + return resolvedSvelteModule; + } +} diff --git a/packages/typescript-plugin/src/source-mapper.ts b/packages/typescript-plugin/src/source-mapper.ts new file mode 100644 index 000000000..db562e0e1 --- /dev/null +++ b/packages/typescript-plugin/src/source-mapper.ts @@ -0,0 +1,113 @@ +import { decode } from 'sourcemap-codec'; +import type ts from 'typescript/lib/tsserverlibrary'; + +type LineChar = ts.LineAndCharacter; + +type FileMapping = LineMapping[]; + +type LineMapping = CharacterMapping[]; // FileMapping[generated_line_index] = LineMapping + +type CharacterMapping = [ + number, // generated character + number, // original file + number, // original line + number // original index +]; + +type ReorderedChar = [ + original_character: number, + generated_line: number, + generated_character: number +]; + +interface ReorderedMap { + [original_line: number]: ReorderedChar[]; +} + +function binaryInsert(array: number[], value: number): void; +function binaryInsert | number[]>( + array: T[], + value: T, + key: keyof T +): void; +function binaryInsert> | number[]>( + array: A, + value: A[any], + key?: keyof (A[any] & object) +) { + if (0 === key) key = '0' as keyof A[any]; + const index = 1 + binarySearch(array, (key ? value[key] : value) as number, key); + let i = array.length; + while (index !== i--) array[1 + i] = array[i]; + array[index] = value; +} + +function binarySearch( + array: T[], + target: number, + key?: keyof (T & object) +) { + if (!array || 0 === array.length) return -1; + if (0 === key) key = '0' as keyof T; + let low = 0; + let high = array.length - 1; + while (low <= high) { + const i = low + ((high - low) >> 1); + const item = undefined === key ? array[i] : array[i][key]; + if (item === target) return i; + if (item < target) low = i + 1; + else high = i - 1; + } + if ((low = ~low) < 0) low = ~low - 1; + return low; +} + +export class SourceMapper { + private mappings: FileMapping; + private reverseMappings?: ReorderedMap; + + constructor(mappings: FileMapping | string) { + if (typeof mappings === 'string') this.mappings = decode(mappings) as FileMapping; + else this.mappings = mappings; + } + + getOriginalPosition(position: LineChar): LineChar { + const lineMap = this.mappings[position.line]; + if (!lineMap) { + return { line: -1, character: -1 }; + } + + const closestMatch = binarySearch(lineMap, position.character, 0); + const { 2: line, 3: character } = lineMap[closestMatch]; + return { line, character }; + } + + getGeneratedPosition(position: LineChar): LineChar { + if (!this.reverseMappings) this.computeReversed(); + const lineMap = this.reverseMappings![position.line]; + if (!lineMap) { + return { line: -1, character: -1 }; + } + + const closestMatch = binarySearch(lineMap, position.character, 0); + const { 1: line, 2: character } = lineMap[closestMatch]; + return { line, character }; + } + + private computeReversed() { + this.reverseMappings = {} as ReorderedMap; + for (let generated_line = 0; generated_line !== this.mappings.length; generated_line++) { + for (const { 0: generated_index, 2: original_line, 3: original_character_index } of this + .mappings[generated_line]) { + const reordered_char: ReorderedChar = [ + original_character_index, + generated_line, + generated_index + ]; + if (original_line in this.reverseMappings) + binaryInsert(this.reverseMappings[original_line], reordered_char, 0); + else this.reverseMappings[original_line] = [reordered_char]; + } + } + } +} diff --git a/packages/typescript-plugin/src/svelte-snapshots.ts b/packages/typescript-plugin/src/svelte-snapshots.ts new file mode 100644 index 000000000..cf7c901da --- /dev/null +++ b/packages/typescript-plugin/src/svelte-snapshots.ts @@ -0,0 +1,318 @@ +import svelte2tsx from 'svelte2tsx'; +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from './logger'; +import { SourceMapper } from './source-mapper'; +import { isNoTextSpanInGeneratedCode, isSvelteFilePath } from './utils'; + +export class SvelteSnapshot { + private scriptInfo?: ts.server.ScriptInfo; + private lineOffsets?: number[]; + private convertInternalCodePositions = false; + + constructor( + private typescript: typeof ts, + private fileName: string, + private svelteCode: string, + private mapper: SourceMapper, + private logger: Logger, + public readonly isTsFile: boolean + ) {} + + update(svelteCode: string, mapper: SourceMapper) { + this.svelteCode = svelteCode; + this.mapper = mapper; + this.lineOffsets = undefined; + this.log('Updated Snapshot'); + } + + getOriginalTextSpan(textSpan: ts.TextSpan): ts.TextSpan | null { + if (!isNoTextSpanInGeneratedCode(this.getText(), textSpan)) { + return null; + } + + const start = this.getOriginalOffset(textSpan.start); + if (start === -1) { + return null; + } + + // Assumption: We don't change identifiers itself, so we don't change ranges. + return { + start, + length: textSpan.length + }; + } + + getOriginalOffset(generatedOffset: number) { + if (!this.scriptInfo) { + return generatedOffset; + } + + this.toggleMappingMode(true); + const lineOffset = this.scriptInfo.positionToLineOffset(generatedOffset); + this.debug('try convert offset', generatedOffset, '/', lineOffset); + const original = this.mapper.getOriginalPosition({ + line: lineOffset.line - 1, + character: lineOffset.offset - 1 + }); + this.toggleMappingMode(false); + if (original.line === -1) { + return -1; + } + + const originalOffset = this.scriptInfo.lineOffsetToPosition( + original.line + 1, + original.character + 1 + ); + this.debug('converted offset to', original, '/', originalOffset); + return originalOffset; + } + + setAndPatchScriptInfo(scriptInfo: ts.server.ScriptInfo) { + // @ts-expect-error + scriptInfo.scriptKind = this.typescript.ScriptKind.TSX; + + const positionToLineOffset = scriptInfo.positionToLineOffset.bind(scriptInfo); + scriptInfo.positionToLineOffset = (position) => { + if (this.convertInternalCodePositions) { + const lineOffset = positionToLineOffset(position); + this.debug('positionToLineOffset for generated code', position, lineOffset); + return lineOffset; + } + + const lineOffset = this.positionAt(position); + this.debug('positionToLineOffset for original code', position, lineOffset); + return { line: lineOffset.line + 1, offset: lineOffset.character + 1 }; + }; + + const lineOffsetToPosition = scriptInfo.lineOffsetToPosition.bind(scriptInfo); + scriptInfo.lineOffsetToPosition = (line, offset) => { + if (this.convertInternalCodePositions) { + const position = lineOffsetToPosition(line, offset); + this.debug('lineOffsetToPosition for generated code', { line, offset }, position); + return position; + } + + const position = this.offsetAt({ line: line - 1, character: offset - 1 }); + this.debug('lineOffsetToPosition for original code', { line, offset }, position); + return position; + }; + + // TODO do we need to patch this? + // const lineToTextSpan = scriptInfo.lineToTextSpan.bind(scriptInfo); + // scriptInfo.lineToTextSpan = (line) => { + // if (this.convertInternalCodePositions) { + // const span = lineToTextSpan(line); + // this.debug('lineToTextSpan for generated code', line, span); + // return span; + // } + + // const lineOffset = this.getLineOffsets(); + // const start = lineOffset[line - 1]; + // const span: ts.TextSpan = { + // start, + // length: (lineOffset[line] || this.svelteCode.length) - start + // }; + // this.debug('lineToTextSpan for original code', line, span); + // return span; + // }; + + this.scriptInfo = scriptInfo; + this.log('patched scriptInfo'); + } + + /** + * Get the line and character based on the offset + * @param offset The index of the position + */ + positionAt(offset: number): ts.LineAndCharacter { + offset = this.clamp(offset, 0, this.svelteCode.length); + + const lineOffsets = this.getLineOffsets(); + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return { line: 0, character: offset }; + } + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + + return { line, character: offset - lineOffsets[line] }; + } + + /** + * Get the index of the line and character position + * @param position Line and character position + */ + offsetAt(position: ts.LineAndCharacter): number { + const lineOffsets = this.getLineOffsets(); + + if (position.line >= lineOffsets.length) { + return this.svelteCode.length; + } else if (position.line < 0) { + return 0; + } + + const lineOffset = lineOffsets[position.line]; + const nextLineOffset = + position.line + 1 < lineOffsets.length + ? lineOffsets[position.line + 1] + : this.svelteCode.length; + + return this.clamp(nextLineOffset, lineOffset, lineOffset + position.character); + } + + private getLineOffsets() { + if (this.lineOffsets) { + return this.lineOffsets; + } + + const lineOffsets = []; + const text = this.svelteCode; + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + this.lineOffsets = lineOffsets; + return lineOffsets; + } + + private clamp(num: number, min: number, max: number): number { + return Math.max(min, Math.min(max, num)); + } + + private log(...args: any[]) { + this.logger.log('SvelteSnapshot:', this.fileName, '-', ...args); + } + + private debug(...args: any[]) { + this.logger.debug('SvelteSnapshot:', this.fileName, '-', ...args); + } + + private toggleMappingMode(convertInternalCodePositions: boolean) { + this.convertInternalCodePositions = convertInternalCodePositions; + } + + private getText() { + const snapshot = this.scriptInfo?.getSnapshot(); + if (!snapshot) { + return ''; + } + return snapshot.getText(0, snapshot.getLength()); + } +} + +export class SvelteSnapshotManager { + private snapshots = new Map(); + + constructor( + private typescript: typeof ts, + private projectService: ts.server.ProjectService, + private logger: Logger, + private strictMode: boolean + ) { + this.patchProjectServiceReadFile(); + } + + get(fileName: string) { + return this.snapshots.get(fileName); + } + + create(fileName: string): SvelteSnapshot | undefined { + if (this.snapshots.has(fileName)) { + return this.snapshots.get(fileName)!; + } + + // This will trigger projectService.host.readFile which is patched below + const scriptInfo = this.projectService.getOrCreateScriptInfoForNormalizedPath( + this.typescript.server.toNormalizedPath(fileName), + false + ); + if (!scriptInfo) { + this.logger.log('Was not able get snapshot for', fileName); + return; + } + + try { + scriptInfo.getSnapshot(); // needed to trigger readFile + } catch (e) { + this.logger.log('Loading Snapshot failed', fileName); + } + const snapshot = this.snapshots.get(fileName); + if (!snapshot) { + this.logger.log( + 'Svelte snapshot was not found after trying to load script snapshot for', + fileName + ); + return; // should never get here + } + snapshot.setAndPatchScriptInfo(scriptInfo); + this.snapshots.set(fileName, snapshot); + return snapshot; + } + + private patchProjectServiceReadFile() { + const readFile = this.projectService.host.readFile; + this.projectService.host.readFile = (path: string) => { + if (isSvelteFilePath(path)) { + this.logger.debug('Read Svelte file:', path); + const svelteCode = readFile(path) || ''; + try { + const isTsFile = true; // TODO check file contents? TS might be okay with importing ts into js. + const result = svelte2tsx(svelteCode, { + filename: path.split('/').pop(), + strictMode: this.strictMode, + isTsFile + }); + const existingSnapshot = this.snapshots.get(path); + if (existingSnapshot) { + existingSnapshot.update(svelteCode, new SourceMapper(result.map.mappings)); + } else { + this.snapshots.set( + path, + new SvelteSnapshot( + this.typescript, + path, + svelteCode, + new SourceMapper(result.map.mappings), + this.logger, + isTsFile + ) + ); + } + this.logger.log('Successfully read Svelte file contents of', path); + return result.code; + } catch (e) { + this.logger.log('Error loading Svelte file:', path); + this.logger.debug('Error:', e); + } + } else { + return readFile(path); + } + }; + } +} diff --git a/packages/typescript-plugin/src/svelte-sys.ts b/packages/typescript-plugin/src/svelte-sys.ts new file mode 100644 index 000000000..e8afc22ee --- /dev/null +++ b/packages/typescript-plugin/src/svelte-sys.ts @@ -0,0 +1,32 @@ +import ts from 'typescript'; +import { Logger } from './logger'; +import { ensureRealSvelteFilePath, isVirtualSvelteFilePath, toRealSvelteFilePath } from './utils'; + +/** + * This should only be accessed by TS svelte module resolution. + */ +export function createSvelteSys(logger: Logger) { + const svelteSys: ts.System = { + ...ts.sys, + fileExists(path: string) { + return ts.sys.fileExists(ensureRealSvelteFilePath(path)); + }, + readDirectory(path, extensions, exclude, include, depth) { + const extensionsWithSvelte = (extensions ?? []).concat('.svelte'); + + return ts.sys.readDirectory(path, extensionsWithSvelte, exclude, include, depth); + } + }; + + if (ts.sys.realpath) { + const realpath = ts.sys.realpath; + svelteSys.realpath = function (path) { + if (isVirtualSvelteFilePath(path)) { + return realpath(toRealSvelteFilePath(path)) + '.ts'; + } + return realpath(path); + }; + } + + return svelteSys; +} diff --git a/packages/typescript-plugin/src/utils.ts b/packages/typescript-plugin/src/utils.ts new file mode 100644 index 000000000..b034a47b1 --- /dev/null +++ b/packages/typescript-plugin/src/utils.ts @@ -0,0 +1,66 @@ +export function isSvelteFilePath(filePath: string) { + return filePath.endsWith('.svelte'); +} + +export function isVirtualSvelteFilePath(filePath: string) { + return filePath.endsWith('.svelte.ts'); +} + +export function toRealSvelteFilePath(filePath: string) { + return filePath.slice(0, -'.ts'.length); +} + +export function ensureRealSvelteFilePath(filePath: string) { + return isVirtualSvelteFilePath(filePath) ? toRealSvelteFilePath(filePath) : filePath; +} + +export function isNotNullOrUndefined(val: T | undefined | null): val is T { + return val !== undefined && val !== null; +} + +/** + * Checks if this a section that should be completely ignored + * because it's purely generated. + */ +export function isInGeneratedCode(text: string, start: number, end: number) { + const lineStart = text.lastIndexOf('\n', start); + const lineEnd = text.indexOf('\n', end); + const lastStart = text.substring(lineStart, start).lastIndexOf('/*Ωignore_startΩ*/'); + const lastEnd = text.substring(lineStart, start).lastIndexOf('/*Ωignore_endΩ*/'); + return lastStart > lastEnd && text.substring(end, lineEnd).includes('/*Ωignore_endΩ*/'); +} + +/** + * Checks that this isn't a text span that should be completely ignored + * because it's purely generated. + */ +export function isNoTextSpanInGeneratedCode(text: string, span: ts.TextSpan) { + return !isInGeneratedCode(text, span.start, span.start + span.length); +} + +/** + * Replace all occurrences of a string within an object with another string, + */ +export function replaceDeep>( + obj: T, + searchStr: string | RegExp, + replacementStr: string +): T { + return _replaceDeep(obj); + + function _replaceDeep(_obj: any): any { + if (typeof _obj === 'string') { + return _obj.replace(searchStr, replacementStr); + } + if (Array.isArray(_obj)) { + return _obj.map((entry) => _replaceDeep(entry)); + } + if (typeof _obj === 'object') { + return Object.keys(_obj).reduce((_o, key) => { + _o[key] = _replaceDeep(_obj[key]); + return _o; + }, {} as any); + } + return _obj; + } +} diff --git a/packages/typescript-plugin/tsconfig.json b/packages/typescript-plugin/tsconfig.json index 651378bd6..b681e7991 100644 --- a/packages/typescript-plugin/tsconfig.json +++ b/packages/typescript-plugin/tsconfig.json @@ -5,7 +5,7 @@ "esModuleInterop": true, "strict": true, "declaration": true, - "outDir": ".", + "outDir": "dist", "sourceMap": false, "composite": true },