Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) TypeScript plugin #978

Merged
merged 10 commits into from
May 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/typescript-plugin/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
src/**/*.js
src/**/*.d.ts
src/tsconfig.tsbuildinfo
dist
node_modules
tsconfig.tsbuildinfo
5 changes: 5 additions & 0 deletions packages/typescript-plugin/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/node_modules
/src
tsconfig.json
.gitignore
internal.md
35 changes: 35 additions & 0 deletions packages/typescript-plugin/README.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/typescript-plugin/internal.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion packages/typescript-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -19,5 +20,9 @@
"@tsconfig/node12": "^1.0.0",
"@types/node": "^13.9.0",
"typescript": "*"
},
"dependencies": {
"svelte2tsx": "*",
"sourcemap-codec": "^1.4.4"
}
}
52 changes: 49 additions & 3 deletions packages/typescript-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down
70 changes: 70 additions & 0 deletions packages/typescript-plugin/src/language-service/completions.ts
Original file line number Diff line number Diff line change
@@ -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, '');
};
}
44 changes: 44 additions & 0 deletions packages/typescript-plugin/src/language-service/definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type ts from 'typescript/lib/tsserverlibrary';
import { Logger } from '../logger';
import { SvelteSnapshotManager } from '../svelte-snapshots';
import { isNotNullOrUndefined, isSvelteFilePath } from '../utils';

export function decorateGetDefinition(
ls: ts.LanguageService,
snapshotManager: SvelteSnapshotManager,
logger: Logger
): void {
const getDefinitionAndBoundSpan = ls.getDefinitionAndBoundSpan;
ls.getDefinitionAndBoundSpan = (fileName, position) => {
const definition = getDefinitionAndBoundSpan(fileName, position);
if (!definition?.definitions) {
return definition;
}

return {
...definition,
definitions: definition.definitions
.map((def) => {
if (!isSvelteFilePath(def.fileName)) {
return def;
}

const textSpan = snapshotManager
.get(def.fileName)
?.getOriginalTextSpan(def.textSpan);
if (!textSpan) {
return undefined;
}
return {
...def,
textSpan,
// Spare the work for now
originalTextSpan: undefined,
contextSpan: undefined,
originalContextSpan: undefined
};
})
.filter(isNotNullOrUndefined)
};
};
}
45 changes: 45 additions & 0 deletions packages/typescript-plugin/src/language-service/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type ts from 'typescript/lib/tsserverlibrary';
import { Logger } from '../logger';
import { isSvelteFilePath } from '../utils';

export function decorateDiagnostics(ls: ts.LanguageService, logger: Logger): void {
decorateSyntacticDiagnostics(ls);
decorateSemanticDiagnostics(ls);
decorateSuggestionDiagnostics(ls);
}

function decorateSyntacticDiagnostics(ls: ts.LanguageService): void {
const getSyntacticDiagnostics = ls.getSyntacticDiagnostics;
ls.getSyntacticDiagnostics = (fileName: string) => {
// Diagnostics inside Svelte files are done
// by the svelte-language-server / Svelte for VS Code extension
if (isSvelteFilePath(fileName)) {
return [];
}
return getSyntacticDiagnostics(fileName);
};
}

function decorateSemanticDiagnostics(ls: ts.LanguageService): void {
const getSemanticDiagnostics = ls.getSemanticDiagnostics;
ls.getSemanticDiagnostics = (fileName: string) => {
// Diagnostics inside Svelte files are done
// by the svelte-language-server / Svelte for VS Code extension
if (isSvelteFilePath(fileName)) {
return [];
}
return getSemanticDiagnostics(fileName);
};
}

function decorateSuggestionDiagnostics(ls: ts.LanguageService): void {
const getSuggestionDiagnostics = ls.getSuggestionDiagnostics;
ls.getSuggestionDiagnostics = (fileName: string) => {
// Diagnostics inside Svelte files are done
// by the svelte-language-server / Svelte for VS Code extension
if (isSvelteFilePath(fileName)) {
return [];
}
return getSuggestionDiagnostics(fileName);
};
}
Loading