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 },