From c947c7e6dd85b433c5220588d8027caa5f43b1b4 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 3 May 2021 12:34:50 +0200 Subject: [PATCH 01/10] (feat) TypeScript plugin Initially support - rename (doesn't work properly for all kinds of renames yet; need to filter out references inside generated code) - diagnostics - find references (need to filter out references inside generated code) This makes all files TSX hardcoded for now, it seems the TS server is okay with importing tsx into js #580 #550 #342 #110 --- packages/typescript-plugin/.gitignore | 4 +- packages/typescript-plugin/package.json | 7 +- packages/typescript-plugin/src/index.ts | 52 ++- .../src/language-service/diagnostics.ts | 46 +++ .../src/language-service/find-references.ts | 95 ++++++ .../src/language-service/index.ts | 17 + .../src/language-service/rename.ts | 55 ++++ packages/typescript-plugin/src/logger.ts | 41 +++ .../typescript-plugin/src/module-loader.ts | 145 ++++++++ .../typescript-plugin/src/source-mapper.ts | 113 +++++++ .../typescript-plugin/src/svelte-snapshots.ts | 310 ++++++++++++++++++ packages/typescript-plugin/src/svelte-sys.ts | 49 +++ packages/typescript-plugin/src/utils.ts | 260 +++++++++++++++ packages/typescript-plugin/tsconfig.json | 2 +- 14 files changed, 1188 insertions(+), 8 deletions(-) create mode 100644 packages/typescript-plugin/src/language-service/diagnostics.ts create mode 100644 packages/typescript-plugin/src/language-service/find-references.ts create mode 100644 packages/typescript-plugin/src/language-service/index.ts create mode 100644 packages/typescript-plugin/src/language-service/rename.ts create mode 100644 packages/typescript-plugin/src/logger.ts create mode 100644 packages/typescript-plugin/src/module-loader.ts create mode 100644 packages/typescript-plugin/src/source-mapper.ts create mode 100644 packages/typescript-plugin/src/svelte-snapshots.ts create mode 100644 packages/typescript-plugin/src/svelte-sys.ts create mode 100644 packages/typescript-plugin/src/utils.ts 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/package.json b/packages/typescript-plugin/package.json index 42bde7f25..1e26ea518 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.8" } } 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/diagnostics.ts b/packages/typescript-plugin/src/language-service/diagnostics.ts new file mode 100644 index 000000000..59e0145a0 --- /dev/null +++ b/packages/typescript-plugin/src/language-service/diagnostics.ts @@ -0,0 +1,46 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { isSvelteFilePath } from '../utils'; + +export function decorateDiagnostics(ls: ts.LanguageService, logger: Logger) { + decorateSyntacticDiagnostics(ls); + decorateSemanticDiagnostics(ls); + decorateSuggestionDiagnostics(ls); + return 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/index.ts b/packages/typescript-plugin/src/language-service/index.ts new file mode 100644 index 000000000..71045a59a --- /dev/null +++ b/packages/typescript-plugin/src/language-service/index.ts @@ -0,0 +1,17 @@ +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from '../logger'; +import { SvelteSnapshotManager } from '../svelte-snapshots'; +import { decorateDiagnostics } from './diagnostics'; +import { decorateFindReferences } from './find-references'; +import { decorateRename } from './rename'; + +export function decorateLanguageService( + ls: ts.LanguageService, + snapshotManager: SvelteSnapshotManager, + logger: Logger +): ts.LanguageService { + decorateRename(ls, snapshotManager, logger); + decorateDiagnostics(ls, logger); + decorateFindReferences(ls, snapshotManager, logger); + return ls; +} 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..399bea8b0 --- /dev/null +++ b/packages/typescript-plugin/src/language-service/rename.ts @@ -0,0 +1,55 @@ +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 start = snapshot.getOriginalOffset(renameLocation.textSpan.start); + if (start === -1) { + return null; + } + + const converted = { + ...renameLocation, + textSpan: { + start: start, + length: renameLocation.textSpan.length + } + }; + 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..440d32be8 --- /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, + surpressNonSvelteLogs = false, + private logDebug = false + ) { + if (surpressNonSvelteLogs) { + 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..7f1248be3 --- /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..c591c093d --- /dev/null +++ b/packages/typescript-plugin/src/svelte-snapshots.ts @@ -0,0 +1,310 @@ +import svelte2tsx from 'svelte2tsx'; +import type ts from 'typescript/lib/tsserverlibrary'; +import { Logger } from './logger'; +import { SourceMapper } from './source-mapper'; +import { isSvelteFilePath } from './utils'; + +export class SvelteSnapshot { + private scriptInfo?: ts.server.ScriptInfo; + 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.log('Updated Snapshot'); + } + + getOriginalTextSpan(textSpan: ts.TextSpan): ts.TextSpan | 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: start, + length: textSpan.length + }; + } + + getOriginalOffset(generatedOffset: number) { + if (!this.scriptInfo) { + return generatedOffset; + } + + this.toggleMappingMode(true); + const lineOffset = this.scriptInfo.positionToLineOffset(generatedOffset); + this.log('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.log('converted to', original, '/', originalOffset); + return originalOffset; + } + + setAndPatchScriptInfo(scriptInfo: ts.server.ScriptInfo) { + // @ts-expect-error + scriptInfo.scriptKind = this.typescript.ScriptKind.TSX; + // const editContent = scriptInfo.editContent.bind(scriptInfo); + // scriptInfo.editContent = (start, end, newText) => { + // const getOriginal = (pos: number) => { + // const lineOffset = scriptInfo.positionToLineOffset(start); + // const original = this.mapper.getOriginalPosition({ + // line: lineOffset.line - 1, + // character: lineOffset.offset - 1 + // }); + // return scriptInfo.lineOffsetToPosition(original.line + 1, original.character + 1); + // }; + // this.log( + // 'EDIT CONTENT: ', + // newText, + // '|', + // start, + // '->', + // getOriginal(start), + // '|', + // end, + // '->', + // getOriginal(end) + // ); + // editContent(getOriginal(start), getOriginal(end), newText); + // }; + + const positionToLineOffset = scriptInfo.positionToLineOffset.bind(scriptInfo); + scriptInfo.positionToLineOffset = (position) => { + const lineOffset = this.positionAt(position); + // const e = new Error('').stack; + this.log( + 'script info for', + this.fileName, + 'convert ', + position, + this.convertInternalCodePositions, + lineOffset + // e + ); + if (this.convertInternalCodePositions) { + return positionToLineOffset(position); + } + return { line: lineOffset.line + 1, offset: lineOffset.character + 1 }; + }; + + const lineOffsetToPosition = scriptInfo.lineOffsetToPosition.bind(scriptInfo); + scriptInfo.lineOffsetToPosition = (line, offset) => { + if (this.convertInternalCodePositions) { + return lineOffsetToPosition(line, offset); + } + return this.offsetAt({ line: line - 1, character: offset - 1 }); + }; + + const lineToTextSpan = scriptInfo.lineToTextSpan.bind(scriptInfo); + scriptInfo.lineToTextSpan = (line) => { + // if (this.convertInternalCodePositions) { + const res = lineToTextSpan(line); + this.log('lineToTextSpan', line, '::', res); + return res; + // } + }; + + 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() { + 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); + } + + 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 toggleMappingMode(convertInternalCodePositions: boolean) { + this.convertInternalCodePositions = convertInternalCodePositions; + } +} + +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..ae2f7cb80 --- /dev/null +++ b/packages/typescript-plugin/src/svelte-sys.ts @@ -0,0 +1,49 @@ +import ts from 'typescript'; +import svelte2tsx from 'svelte2tsx'; +import { + ensureRealSvelteFilePath, + isSvelteFilePath, + isVirtualSvelteFilePath, + toRealSvelteFilePath +} from './utils'; +import { Logger } from './logger'; + +/** + * 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)); + }, + readFile(path: string) { + if (isSvelteFilePath(path)) { + try { + return svelte2tsx(ts.sys.readFile(path) || '').code; + } catch (e) { + throw e; + } + } else { + return ts.sys.readFile(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..19929c8b9 --- /dev/null +++ b/packages/typescript-plugin/src/utils.ts @@ -0,0 +1,260 @@ +import { dirname } from 'path'; +import ts from 'typescript'; +import { + CompletionItemKind, + DiagnosticSeverity, + Position, + Range, + SymbolKind +} from 'vscode-languageserver'; + +export function getScriptKindFromFileName(fileName: string): ts.ScriptKind { + const ext = fileName.substr(fileName.lastIndexOf('.')); + switch (ext.toLowerCase()) { + case ts.Extension.Js: + return ts.ScriptKind.JS; + case ts.Extension.Jsx: + return ts.ScriptKind.JSX; + case ts.Extension.Ts: + return ts.ScriptKind.TS; + case ts.Extension.Tsx: + return ts.ScriptKind.TSX; + case ts.Extension.Json: + return ts.ScriptKind.JSON; + default: + return ts.ScriptKind.Unknown; + } +} + +export function getExtensionFromScriptKind(kind: ts.ScriptKind | undefined): ts.Extension { + switch (kind) { + case ts.ScriptKind.JSX: + return ts.Extension.Jsx; + case ts.ScriptKind.TS: + return ts.Extension.Ts; + case ts.ScriptKind.TSX: + return ts.Extension.Tsx; + case ts.ScriptKind.JSON: + return ts.Extension.Json; + case ts.ScriptKind.JS: + default: + return ts.Extension.Js; + } +} + +export function getScriptKindFromAttributes( + attrs: Record +): ts.ScriptKind.TSX | ts.ScriptKind.JSX { + const type = attrs.lang || attrs.type; + + switch (type) { + case 'ts': + case 'typescript': + case 'text/ts': + case 'text/typescript': + return ts.ScriptKind.TSX; + case 'javascript': + case 'text/javascript': + default: + return ts.ScriptKind.JSX; + } +} + +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 convertRange( + document: { positionAt: (offset: number) => Position }, + range: { start?: number; length?: number } +): Range { + return Range.create( + document.positionAt(range.start || 0), + document.positionAt((range.start || 0) + (range.length || 0)) + ); +} + +export function symbolKindFromString(kind: string): SymbolKind { + switch (kind) { + case 'module': + return SymbolKind.Module; + case 'class': + return SymbolKind.Class; + case 'local class': + return SymbolKind.Class; + case 'interface': + return SymbolKind.Interface; + case 'enum': + return SymbolKind.Enum; + case 'enum member': + return SymbolKind.Constant; + case 'var': + return SymbolKind.Variable; + case 'local var': + return SymbolKind.Variable; + case 'function': + return SymbolKind.Function; + case 'local function': + return SymbolKind.Function; + case 'method': + return SymbolKind.Method; + case 'getter': + return SymbolKind.Method; + case 'setter': + return SymbolKind.Method; + case 'property': + return SymbolKind.Property; + case 'constructor': + return SymbolKind.Constructor; + case 'parameter': + return SymbolKind.Variable; + case 'type parameter': + return SymbolKind.Variable; + case 'alias': + return SymbolKind.Variable; + case 'let': + return SymbolKind.Variable; + case 'const': + return SymbolKind.Constant; + case 'JSX attribute': + return SymbolKind.Property; + default: + return SymbolKind.Variable; + } +} + +export function scriptElementKindToCompletionItemKind( + kind: ts.ScriptElementKind +): CompletionItemKind { + switch (kind) { + case ts.ScriptElementKind.primitiveType: + case ts.ScriptElementKind.keyword: + return CompletionItemKind.Keyword; + case ts.ScriptElementKind.constElement: + return CompletionItemKind.Constant; + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + case ts.ScriptElementKind.localVariableElement: + case ts.ScriptElementKind.alias: + return CompletionItemKind.Variable; + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + return CompletionItemKind.Field; + case ts.ScriptElementKind.functionElement: + return CompletionItemKind.Function; + case ts.ScriptElementKind.memberFunctionElement: + case ts.ScriptElementKind.constructSignatureElement: + case ts.ScriptElementKind.callSignatureElement: + case ts.ScriptElementKind.indexSignatureElement: + return CompletionItemKind.Method; + case ts.ScriptElementKind.enumElement: + return CompletionItemKind.Enum; + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.externalModuleName: + return CompletionItemKind.Module; + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.typeElement: + return CompletionItemKind.Class; + case ts.ScriptElementKind.interfaceElement: + return CompletionItemKind.Interface; + case ts.ScriptElementKind.warning: + case ts.ScriptElementKind.scriptElement: + return CompletionItemKind.File; + case ts.ScriptElementKind.directory: + return CompletionItemKind.Folder; + case ts.ScriptElementKind.string: + return CompletionItemKind.Constant; + } + return CompletionItemKind.Property; +} + +export function getCommitCharactersForScriptElement( + kind: ts.ScriptElementKind +): string[] | undefined { + const commitCharacters: string[] = []; + switch (kind) { + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + case ts.ScriptElementKind.constructSignatureElement: + case ts.ScriptElementKind.callSignatureElement: + case ts.ScriptElementKind.indexSignatureElement: + case ts.ScriptElementKind.enumElement: + case ts.ScriptElementKind.interfaceElement: + commitCharacters.push('.'); + break; + + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.alias: + case ts.ScriptElementKind.constElement: + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + case ts.ScriptElementKind.localVariableElement: + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.functionElement: + case ts.ScriptElementKind.memberFunctionElement: + commitCharacters.push('.', ','); + commitCharacters.push('('); + break; + } + + return commitCharacters.length === 0 ? undefined : commitCharacters; +} + +export function mapSeverity(category: ts.DiagnosticCategory): DiagnosticSeverity { + switch (category) { + case ts.DiagnosticCategory.Error: + return DiagnosticSeverity.Error; + case ts.DiagnosticCategory.Warning: + return DiagnosticSeverity.Warning; + case ts.DiagnosticCategory.Suggestion: + return DiagnosticSeverity.Hint; + case ts.DiagnosticCategory.Message: + return DiagnosticSeverity.Information; + } + + return DiagnosticSeverity.Error; +} + +// Matches comments that come before any non-comment content +const commentsRegex = /^(\s*\/\/.*\s*)*/; +// The following regex matches @ts-check or @ts-nocheck if: +// - must be @ts-(no)check +// - the comment which has @ts-(no)check can have any type of whitespace before it, but not other characters +// - what's coming after @ts-(no)check is irrelevant as long there is any kind of whitespace or line break, so this would be picked up, too: // @ts-check asdasd +// [ \t\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff] +// is just \s (a.k.a any whitespace character) without linebreak and vertical tab +// eslint-disable-next-line max-len +const tsCheckRegex = /\/\/[ \t\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]*(@ts-(no)?check)($|\s)/; + +/** + * Returns `// @ts-check` or `// @ts-nocheck` if content starts with comments and has one of these + * in its comments. + */ +export function getTsCheckComment(str = ''): string | undefined { + const comments = str.match(commentsRegex)?.[0]; + if (comments) { + const tsCheck = comments.match(tsCheckRegex); + if (tsCheck) { + // second-last entry is the capturing group with the exact ts-check wording + return `// ${tsCheck[tsCheck.length - 3]}${ts.sys.newLine}`; + } + } +} + +export function isNotNullOrUndefined(val: T | undefined | null): val is T { + return val !== undefined && val !== null; +} 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 }, From c5896af435b38025f39e4f794fc6814f11ec3e5e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 3 May 2021 12:50:34 +0200 Subject: [PATCH 02/10] tslint, cleanup --- .../src/language-service/rename.ts | 2 +- .../typescript-plugin/src/source-mapper.ts | 2 +- .../typescript-plugin/src/svelte-snapshots.ts | 26 +- packages/typescript-plugin/src/svelte-sys.ts | 19 +- packages/typescript-plugin/src/utils.ts | 241 ------------------ 5 files changed, 4 insertions(+), 286 deletions(-) diff --git a/packages/typescript-plugin/src/language-service/rename.ts b/packages/typescript-plugin/src/language-service/rename.ts index 399bea8b0..68197aa0c 100644 --- a/packages/typescript-plugin/src/language-service/rename.ts +++ b/packages/typescript-plugin/src/language-service/rename.ts @@ -39,7 +39,7 @@ export function decorateRename( const converted = { ...renameLocation, textSpan: { - start: start, + start, length: renameLocation.textSpan.length } }; diff --git a/packages/typescript-plugin/src/source-mapper.ts b/packages/typescript-plugin/src/source-mapper.ts index 7f1248be3..db562e0e1 100644 --- a/packages/typescript-plugin/src/source-mapper.ts +++ b/packages/typescript-plugin/src/source-mapper.ts @@ -30,7 +30,7 @@ function binaryInsert | number[]>( value: T, key: keyof T ): void; -function binaryInsert[] | number[]>( +function binaryInsert> | number[]>( array: A, value: A[any], key?: keyof (A[any] & object) diff --git a/packages/typescript-plugin/src/svelte-snapshots.ts b/packages/typescript-plugin/src/svelte-snapshots.ts index c591c093d..08e13cc7d 100644 --- a/packages/typescript-plugin/src/svelte-snapshots.ts +++ b/packages/typescript-plugin/src/svelte-snapshots.ts @@ -31,7 +31,7 @@ export class SvelteSnapshot { // Assumption: We don't change identifiers itself, so we don't change ranges. return { - start: start, + start, length: textSpan.length }; } @@ -64,30 +64,6 @@ export class SvelteSnapshot { setAndPatchScriptInfo(scriptInfo: ts.server.ScriptInfo) { // @ts-expect-error scriptInfo.scriptKind = this.typescript.ScriptKind.TSX; - // const editContent = scriptInfo.editContent.bind(scriptInfo); - // scriptInfo.editContent = (start, end, newText) => { - // const getOriginal = (pos: number) => { - // const lineOffset = scriptInfo.positionToLineOffset(start); - // const original = this.mapper.getOriginalPosition({ - // line: lineOffset.line - 1, - // character: lineOffset.offset - 1 - // }); - // return scriptInfo.lineOffsetToPosition(original.line + 1, original.character + 1); - // }; - // this.log( - // 'EDIT CONTENT: ', - // newText, - // '|', - // start, - // '->', - // getOriginal(start), - // '|', - // end, - // '->', - // getOriginal(end) - // ); - // editContent(getOriginal(start), getOriginal(end), newText); - // }; const positionToLineOffset = scriptInfo.positionToLineOffset.bind(scriptInfo); scriptInfo.positionToLineOffset = (position) => { diff --git a/packages/typescript-plugin/src/svelte-sys.ts b/packages/typescript-plugin/src/svelte-sys.ts index ae2f7cb80..e8afc22ee 100644 --- a/packages/typescript-plugin/src/svelte-sys.ts +++ b/packages/typescript-plugin/src/svelte-sys.ts @@ -1,12 +1,6 @@ import ts from 'typescript'; -import svelte2tsx from 'svelte2tsx'; -import { - ensureRealSvelteFilePath, - isSvelteFilePath, - isVirtualSvelteFilePath, - toRealSvelteFilePath -} from './utils'; import { Logger } from './logger'; +import { ensureRealSvelteFilePath, isVirtualSvelteFilePath, toRealSvelteFilePath } from './utils'; /** * This should only be accessed by TS svelte module resolution. @@ -17,17 +11,6 @@ export function createSvelteSys(logger: Logger) { fileExists(path: string) { return ts.sys.fileExists(ensureRealSvelteFilePath(path)); }, - readFile(path: string) { - if (isSvelteFilePath(path)) { - try { - return svelte2tsx(ts.sys.readFile(path) || '').code; - } catch (e) { - throw e; - } - } else { - return ts.sys.readFile(path); - } - }, readDirectory(path, extensions, exclude, include, depth) { const extensionsWithSvelte = (extensions ?? []).concat('.svelte'); diff --git a/packages/typescript-plugin/src/utils.ts b/packages/typescript-plugin/src/utils.ts index 19929c8b9..4d4142e71 100644 --- a/packages/typescript-plugin/src/utils.ts +++ b/packages/typescript-plugin/src/utils.ts @@ -1,65 +1,3 @@ -import { dirname } from 'path'; -import ts from 'typescript'; -import { - CompletionItemKind, - DiagnosticSeverity, - Position, - Range, - SymbolKind -} from 'vscode-languageserver'; - -export function getScriptKindFromFileName(fileName: string): ts.ScriptKind { - const ext = fileName.substr(fileName.lastIndexOf('.')); - switch (ext.toLowerCase()) { - case ts.Extension.Js: - return ts.ScriptKind.JS; - case ts.Extension.Jsx: - return ts.ScriptKind.JSX; - case ts.Extension.Ts: - return ts.ScriptKind.TS; - case ts.Extension.Tsx: - return ts.ScriptKind.TSX; - case ts.Extension.Json: - return ts.ScriptKind.JSON; - default: - return ts.ScriptKind.Unknown; - } -} - -export function getExtensionFromScriptKind(kind: ts.ScriptKind | undefined): ts.Extension { - switch (kind) { - case ts.ScriptKind.JSX: - return ts.Extension.Jsx; - case ts.ScriptKind.TS: - return ts.Extension.Ts; - case ts.ScriptKind.TSX: - return ts.Extension.Tsx; - case ts.ScriptKind.JSON: - return ts.Extension.Json; - case ts.ScriptKind.JS: - default: - return ts.Extension.Js; - } -} - -export function getScriptKindFromAttributes( - attrs: Record -): ts.ScriptKind.TSX | ts.ScriptKind.JSX { - const type = attrs.lang || attrs.type; - - switch (type) { - case 'ts': - case 'typescript': - case 'text/ts': - case 'text/typescript': - return ts.ScriptKind.TSX; - case 'javascript': - case 'text/javascript': - default: - return ts.ScriptKind.JSX; - } -} - export function isSvelteFilePath(filePath: string) { return filePath.endsWith('.svelte'); } @@ -76,185 +14,6 @@ export function ensureRealSvelteFilePath(filePath: string) { return isVirtualSvelteFilePath(filePath) ? toRealSvelteFilePath(filePath) : filePath; } -export function convertRange( - document: { positionAt: (offset: number) => Position }, - range: { start?: number; length?: number } -): Range { - return Range.create( - document.positionAt(range.start || 0), - document.positionAt((range.start || 0) + (range.length || 0)) - ); -} - -export function symbolKindFromString(kind: string): SymbolKind { - switch (kind) { - case 'module': - return SymbolKind.Module; - case 'class': - return SymbolKind.Class; - case 'local class': - return SymbolKind.Class; - case 'interface': - return SymbolKind.Interface; - case 'enum': - return SymbolKind.Enum; - case 'enum member': - return SymbolKind.Constant; - case 'var': - return SymbolKind.Variable; - case 'local var': - return SymbolKind.Variable; - case 'function': - return SymbolKind.Function; - case 'local function': - return SymbolKind.Function; - case 'method': - return SymbolKind.Method; - case 'getter': - return SymbolKind.Method; - case 'setter': - return SymbolKind.Method; - case 'property': - return SymbolKind.Property; - case 'constructor': - return SymbolKind.Constructor; - case 'parameter': - return SymbolKind.Variable; - case 'type parameter': - return SymbolKind.Variable; - case 'alias': - return SymbolKind.Variable; - case 'let': - return SymbolKind.Variable; - case 'const': - return SymbolKind.Constant; - case 'JSX attribute': - return SymbolKind.Property; - default: - return SymbolKind.Variable; - } -} - -export function scriptElementKindToCompletionItemKind( - kind: ts.ScriptElementKind -): CompletionItemKind { - switch (kind) { - case ts.ScriptElementKind.primitiveType: - case ts.ScriptElementKind.keyword: - return CompletionItemKind.Keyword; - case ts.ScriptElementKind.constElement: - return CompletionItemKind.Constant; - case ts.ScriptElementKind.letElement: - case ts.ScriptElementKind.variableElement: - case ts.ScriptElementKind.localVariableElement: - case ts.ScriptElementKind.alias: - return CompletionItemKind.Variable; - case ts.ScriptElementKind.memberVariableElement: - case ts.ScriptElementKind.memberGetAccessorElement: - case ts.ScriptElementKind.memberSetAccessorElement: - return CompletionItemKind.Field; - case ts.ScriptElementKind.functionElement: - return CompletionItemKind.Function; - case ts.ScriptElementKind.memberFunctionElement: - case ts.ScriptElementKind.constructSignatureElement: - case ts.ScriptElementKind.callSignatureElement: - case ts.ScriptElementKind.indexSignatureElement: - return CompletionItemKind.Method; - case ts.ScriptElementKind.enumElement: - return CompletionItemKind.Enum; - case ts.ScriptElementKind.moduleElement: - case ts.ScriptElementKind.externalModuleName: - return CompletionItemKind.Module; - case ts.ScriptElementKind.classElement: - case ts.ScriptElementKind.typeElement: - return CompletionItemKind.Class; - case ts.ScriptElementKind.interfaceElement: - return CompletionItemKind.Interface; - case ts.ScriptElementKind.warning: - case ts.ScriptElementKind.scriptElement: - return CompletionItemKind.File; - case ts.ScriptElementKind.directory: - return CompletionItemKind.Folder; - case ts.ScriptElementKind.string: - return CompletionItemKind.Constant; - } - return CompletionItemKind.Property; -} - -export function getCommitCharactersForScriptElement( - kind: ts.ScriptElementKind -): string[] | undefined { - const commitCharacters: string[] = []; - switch (kind) { - case ts.ScriptElementKind.memberGetAccessorElement: - case ts.ScriptElementKind.memberSetAccessorElement: - case ts.ScriptElementKind.constructSignatureElement: - case ts.ScriptElementKind.callSignatureElement: - case ts.ScriptElementKind.indexSignatureElement: - case ts.ScriptElementKind.enumElement: - case ts.ScriptElementKind.interfaceElement: - commitCharacters.push('.'); - break; - - case ts.ScriptElementKind.moduleElement: - case ts.ScriptElementKind.alias: - case ts.ScriptElementKind.constElement: - case ts.ScriptElementKind.letElement: - case ts.ScriptElementKind.variableElement: - case ts.ScriptElementKind.localVariableElement: - case ts.ScriptElementKind.memberVariableElement: - case ts.ScriptElementKind.classElement: - case ts.ScriptElementKind.functionElement: - case ts.ScriptElementKind.memberFunctionElement: - commitCharacters.push('.', ','); - commitCharacters.push('('); - break; - } - - return commitCharacters.length === 0 ? undefined : commitCharacters; -} - -export function mapSeverity(category: ts.DiagnosticCategory): DiagnosticSeverity { - switch (category) { - case ts.DiagnosticCategory.Error: - return DiagnosticSeverity.Error; - case ts.DiagnosticCategory.Warning: - return DiagnosticSeverity.Warning; - case ts.DiagnosticCategory.Suggestion: - return DiagnosticSeverity.Hint; - case ts.DiagnosticCategory.Message: - return DiagnosticSeverity.Information; - } - - return DiagnosticSeverity.Error; -} - -// Matches comments that come before any non-comment content -const commentsRegex = /^(\s*\/\/.*\s*)*/; -// The following regex matches @ts-check or @ts-nocheck if: -// - must be @ts-(no)check -// - the comment which has @ts-(no)check can have any type of whitespace before it, but not other characters -// - what's coming after @ts-(no)check is irrelevant as long there is any kind of whitespace or line break, so this would be picked up, too: // @ts-check asdasd -// [ \t\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff] -// is just \s (a.k.a any whitespace character) without linebreak and vertical tab -// eslint-disable-next-line max-len -const tsCheckRegex = /\/\/[ \t\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]*(@ts-(no)?check)($|\s)/; - -/** - * Returns `// @ts-check` or `// @ts-nocheck` if content starts with comments and has one of these - * in its comments. - */ -export function getTsCheckComment(str = ''): string | undefined { - const comments = str.match(commentsRegex)?.[0]; - if (comments) { - const tsCheck = comments.match(tsCheckRegex); - if (tsCheck) { - // second-last entry is the capturing group with the exact ts-check wording - return `// ${tsCheck[tsCheck.length - 3]}${ts.sys.newLine}`; - } - } -} - export function isNotNullOrUndefined(val: T | undefined | null): val is T { return val !== undefined && val !== null; } From 1841ceeb520d9f9ca1de0db410d7fbd4f92ad9d1 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 3 May 2021 18:40:05 +0200 Subject: [PATCH 03/10] filter out textspans in generated code --- .../src/language-service/rename.ts | 9 +++------ .../typescript-plugin/src/svelte-snapshots.ts | 14 ++++++++++++- packages/typescript-plugin/src/utils.ts | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/typescript-plugin/src/language-service/rename.ts b/packages/typescript-plugin/src/language-service/rename.ts index 68197aa0c..4d920b61c 100644 --- a/packages/typescript-plugin/src/language-service/rename.ts +++ b/packages/typescript-plugin/src/language-service/rename.ts @@ -31,17 +31,14 @@ export function decorateRename( } // TODO more needed to filter invalid locations, see RenameProvider - const start = snapshot.getOriginalOffset(renameLocation.textSpan.start); - if (start === -1) { + const textSpan = snapshot.getOriginalTextSpan(renameLocation.textSpan); + if (!textSpan) { return null; } const converted = { ...renameLocation, - textSpan: { - start, - length: renameLocation.textSpan.length - } + textSpan }; if (converted.contextSpan) { // Not important, spare the work diff --git a/packages/typescript-plugin/src/svelte-snapshots.ts b/packages/typescript-plugin/src/svelte-snapshots.ts index 08e13cc7d..9d27625f9 100644 --- a/packages/typescript-plugin/src/svelte-snapshots.ts +++ b/packages/typescript-plugin/src/svelte-snapshots.ts @@ -2,7 +2,7 @@ import svelte2tsx from 'svelte2tsx'; import type ts from 'typescript/lib/tsserverlibrary'; import { Logger } from './logger'; import { SourceMapper } from './source-mapper'; -import { isSvelteFilePath } from './utils'; +import { isNoTextSpanInGeneratedCode, isSvelteFilePath } from './utils'; export class SvelteSnapshot { private scriptInfo?: ts.server.ScriptInfo; @@ -24,6 +24,10 @@ export class SvelteSnapshot { } 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; @@ -192,6 +196,14 @@ export class SvelteSnapshot { 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 { diff --git a/packages/typescript-plugin/src/utils.ts b/packages/typescript-plugin/src/utils.ts index 4d4142e71..b7af21ac7 100644 --- a/packages/typescript-plugin/src/utils.ts +++ b/packages/typescript-plugin/src/utils.ts @@ -17,3 +17,23 @@ export function ensureRealSvelteFilePath(filePath: string) { 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); +} From 6801c5d56b8a78c2fded7a6e579a7f14354d108a Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 4 May 2021 09:02:00 +0200 Subject: [PATCH 04/10] nice component import completions --- .../src/language-service/completions.ts | 70 +++++++++++++++++++ .../src/language-service/diagnostics.ts | 3 +- .../src/language-service/index.ts | 2 + packages/typescript-plugin/src/utils.ts | 27 +++++++ 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/typescript-plugin/src/language-service/completions.ts 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/diagnostics.ts b/packages/typescript-plugin/src/language-service/diagnostics.ts index 59e0145a0..78fcd215f 100644 --- a/packages/typescript-plugin/src/language-service/diagnostics.ts +++ b/packages/typescript-plugin/src/language-service/diagnostics.ts @@ -2,11 +2,10 @@ import type ts from 'typescript/lib/tsserverlibrary'; import { Logger } from '../logger'; import { isSvelteFilePath } from '../utils'; -export function decorateDiagnostics(ls: ts.LanguageService, logger: Logger) { +export function decorateDiagnostics(ls: ts.LanguageService, logger: Logger): void { decorateSyntacticDiagnostics(ls); decorateSemanticDiagnostics(ls); decorateSuggestionDiagnostics(ls); - return ls; } function decorateSyntacticDiagnostics(ls: ts.LanguageService): void { diff --git a/packages/typescript-plugin/src/language-service/index.ts b/packages/typescript-plugin/src/language-service/index.ts index 71045a59a..c77148948 100644 --- a/packages/typescript-plugin/src/language-service/index.ts +++ b/packages/typescript-plugin/src/language-service/index.ts @@ -1,6 +1,7 @@ import type ts from 'typescript/lib/tsserverlibrary'; import { Logger } from '../logger'; import { SvelteSnapshotManager } from '../svelte-snapshots'; +import { decorateCompletions } from './completions'; import { decorateDiagnostics } from './diagnostics'; import { decorateFindReferences } from './find-references'; import { decorateRename } from './rename'; @@ -13,5 +14,6 @@ export function decorateLanguageService( decorateRename(ls, snapshotManager, logger); decorateDiagnostics(ls, logger); decorateFindReferences(ls, snapshotManager, logger); + decorateCompletions(ls, logger); return ls; } diff --git a/packages/typescript-plugin/src/utils.ts b/packages/typescript-plugin/src/utils.ts index b7af21ac7..3a52dccd6 100644 --- a/packages/typescript-plugin/src/utils.ts +++ b/packages/typescript-plugin/src/utils.ts @@ -37,3 +37,30 @@ export function isInGeneratedCode(text: string, start: number, end: number) { export function isNoTextSpanInGeneratedCode(text: string, span: ts.TextSpan) { return !isInGeneratedCode(text, span.start, span.start + span.length); } + +/** + * Replace all occurences 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; + } +} From c95430392ae397d265ba4d384514f26d7b1dcefe Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 4 May 2021 11:10:45 +0200 Subject: [PATCH 05/10] go to definition --- .../src/language-service/definition.ts | 44 +++++++++++++++++++ .../src/language-service/index.ts | 22 ++++++++++ 2 files changed, 66 insertions(+) create mode 100644 packages/typescript-plugin/src/language-service/definition.ts 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..d6d5370e7 --- /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 || !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/index.ts b/packages/typescript-plugin/src/language-service/index.ts index c77148948..65e9c1217 100644 --- a/packages/typescript-plugin/src/language-service/index.ts +++ b/packages/typescript-plugin/src/language-service/index.ts @@ -1,7 +1,9 @@ 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 { decorateRename } from './rename'; @@ -11,9 +13,29 @@ export function decorateLanguageService( 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); 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); + }; +} From e96491778fc7d84a5e5b568943223ba0ab4b0361 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 4 May 2021 11:19:32 +0200 Subject: [PATCH 06/10] cleanup --- .../typescript-plugin/src/svelte-snapshots.ts | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/packages/typescript-plugin/src/svelte-snapshots.ts b/packages/typescript-plugin/src/svelte-snapshots.ts index 9d27625f9..cf7c901da 100644 --- a/packages/typescript-plugin/src/svelte-snapshots.ts +++ b/packages/typescript-plugin/src/svelte-snapshots.ts @@ -6,6 +6,7 @@ import { isNoTextSpanInGeneratedCode, isSvelteFilePath } from './utils'; export class SvelteSnapshot { private scriptInfo?: ts.server.ScriptInfo; + private lineOffsets?: number[]; private convertInternalCodePositions = false; constructor( @@ -20,6 +21,7 @@ export class SvelteSnapshot { update(svelteCode: string, mapper: SourceMapper) { this.svelteCode = svelteCode; this.mapper = mapper; + this.lineOffsets = undefined; this.log('Updated Snapshot'); } @@ -47,7 +49,7 @@ export class SvelteSnapshot { this.toggleMappingMode(true); const lineOffset = this.scriptInfo.positionToLineOffset(generatedOffset); - this.log('try convert offset', generatedOffset, '/', lineOffset); + this.debug('try convert offset', generatedOffset, '/', lineOffset); const original = this.mapper.getOriginalPosition({ line: lineOffset.line - 1, character: lineOffset.offset - 1 @@ -61,7 +63,7 @@ export class SvelteSnapshot { original.line + 1, original.character + 1 ); - this.log('converted to', original, '/', originalOffset); + this.debug('converted offset to', original, '/', originalOffset); return originalOffset; } @@ -71,40 +73,49 @@ export class SvelteSnapshot { const positionToLineOffset = scriptInfo.positionToLineOffset.bind(scriptInfo); scriptInfo.positionToLineOffset = (position) => { - const lineOffset = this.positionAt(position); - // const e = new Error('').stack; - this.log( - 'script info for', - this.fileName, - 'convert ', - position, - this.convertInternalCodePositions, - lineOffset - // e - ); if (this.convertInternalCodePositions) { - return positionToLineOffset(position); + 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) { - return lineOffsetToPosition(line, offset); + const position = lineOffsetToPosition(line, offset); + this.debug('lineOffsetToPosition for generated code', { line, offset }, position); + return position; } - return this.offsetAt({ line: line - 1, character: offset - 1 }); - }; - const lineToTextSpan = scriptInfo.lineToTextSpan.bind(scriptInfo); - scriptInfo.lineToTextSpan = (line) => { - // if (this.convertInternalCodePositions) { - const res = lineToTextSpan(line); - this.log('lineToTextSpan', line, '::', res); - return res; - // } + 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'); } @@ -162,6 +173,10 @@ export class SvelteSnapshot { } private getLineOffsets() { + if (this.lineOffsets) { + return this.lineOffsets; + } + const lineOffsets = []; const text = this.svelteCode; let isLineStart = true; @@ -182,6 +197,7 @@ export class SvelteSnapshot { lineOffsets.push(text.length); } + this.lineOffsets = lineOffsets; return lineOffsets; } @@ -190,7 +206,11 @@ export class SvelteSnapshot { } private log(...args: any[]) { - this.logger.log('-SvelteSnapshot:', this.fileName, '-', ...args); + this.logger.log('SvelteSnapshot:', this.fileName, '-', ...args); + } + + private debug(...args: any[]) { + this.logger.debug('SvelteSnapshot:', this.fileName, '-', ...args); } private toggleMappingMode(convertInternalCodePositions: boolean) { From f300f9e53edef7bd3c4d373161b20ebeed5a4163 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 4 May 2021 11:57:45 +0200 Subject: [PATCH 07/10] docs --- packages/typescript-plugin/.npmignore | 5 ++++ packages/typescript-plugin/README.md | 35 ++++++++++++++++++++++++++ packages/typescript-plugin/internal.md | 21 ++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 packages/typescript-plugin/.npmignore create mode 100644 packages/typescript-plugin/README.md create mode 100644 packages/typescript-plugin/internal.md 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. From eb260d1336b80cefc759aecd90edb35e5e3435cc Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 4 May 2021 16:51:37 +0200 Subject: [PATCH 08/10] fixes --- packages/typescript-plugin/src/language-service/definition.ts | 2 +- packages/typescript-plugin/src/logger.ts | 4 ++-- packages/typescript-plugin/src/utils.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/typescript-plugin/src/language-service/definition.ts b/packages/typescript-plugin/src/language-service/definition.ts index d6d5370e7..15fb6cae5 100644 --- a/packages/typescript-plugin/src/language-service/definition.ts +++ b/packages/typescript-plugin/src/language-service/definition.ts @@ -11,7 +11,7 @@ export function decorateGetDefinition( const getDefinitionAndBoundSpan = ls.getDefinitionAndBoundSpan; ls.getDefinitionAndBoundSpan = (fileName, position) => { const definition = getDefinitionAndBoundSpan(fileName, position); - if (!definition || !definition.definitions) { + if (!definition?.definitions) { return definition; } diff --git a/packages/typescript-plugin/src/logger.ts b/packages/typescript-plugin/src/logger.ts index 440d32be8..76fa0e8e2 100644 --- a/packages/typescript-plugin/src/logger.ts +++ b/packages/typescript-plugin/src/logger.ts @@ -3,10 +3,10 @@ import type ts from 'typescript/lib/tsserverlibrary'; export class Logger { constructor( private tsLogService: ts.server.Logger, - surpressNonSvelteLogs = false, + suppressNonSvelteLogs = false, private logDebug = false ) { - if (surpressNonSvelteLogs) { + if (suppressNonSvelteLogs) { const log = this.tsLogService.info.bind(this.tsLogService); this.tsLogService.info = (s: string) => { if (s.startsWith('-Svelte Plugin-')) { diff --git a/packages/typescript-plugin/src/utils.ts b/packages/typescript-plugin/src/utils.ts index 3a52dccd6..b034a47b1 100644 --- a/packages/typescript-plugin/src/utils.ts +++ b/packages/typescript-plugin/src/utils.ts @@ -39,7 +39,7 @@ export function isNoTextSpanInGeneratedCode(text: string, span: ts.TextSpan) { } /** - * Replace all occurences of a string within an object with another string, + * Replace all occurrences of a string within an object with another string, */ export function replaceDeep>( obj: T, From 2747713704d08b1906ebebde48479eeb44cc5df5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 4 May 2021 16:57:20 +0200 Subject: [PATCH 09/10] pkg json, consolidate later --- packages/typescript-plugin/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typescript-plugin/package.json b/packages/typescript-plugin/package.json index 1e26ea518..3def53605 100644 --- a/packages/typescript-plugin/package.json +++ b/packages/typescript-plugin/package.json @@ -23,6 +23,6 @@ }, "dependencies": { "svelte2tsx": "*", - "sourcemap-codec": "^1.4.8" + "sourcemap-codec": "^1.4.4" } } From 0d52346abb98e19d4d507475fbeb119dd60169bd Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 4 May 2021 17:17:53 +0200 Subject: [PATCH 10/10] decorateGetImplementation --- .../src/language-service/implementation.ts | 38 +++++++++++++++++++ .../src/language-service/index.ts | 2 + 2 files changed, 40 insertions(+) create mode 100644 packages/typescript-plugin/src/language-service/implementation.ts 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 index 65e9c1217..2c166d416 100644 --- a/packages/typescript-plugin/src/language-service/index.ts +++ b/packages/typescript-plugin/src/language-service/index.ts @@ -6,6 +6,7 @@ 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( @@ -19,6 +20,7 @@ export function decorateLanguageService( decorateFindReferences(ls, snapshotManager, logger); decorateCompletions(ls, logger); decorateGetDefinition(ls, snapshotManager, logger); + decorateGetImplementation(ls, snapshotManager, logger); return ls; }