Skip to content

Commit

Permalink
(feat) SvelteKit typings without types (#1918)
Browse files Browse the repository at this point in the history
This allows you to omit the ./$types imports and still get proper typings, by inserting them under the hood. Example:

```ts
// +page.ts
export function load(event) {}
--->
export function load(event: import('./$types').PageLoadEvent) {}
```

This works for TS files, in Svelte files, and for svelte-check.

Works in special SvelteKit files (+page/layout(.server).ts/js) for all exports (actions/csr/ssr/prerender/trailingSlash/load)
  • Loading branch information
dummdidumm authored Mar 7, 2023
1 parent 377fbc1 commit 0374000
Show file tree
Hide file tree
Showing 26 changed files with 1,495 additions and 88 deletions.
243 changes: 237 additions & 6 deletions packages/language-server/src/plugins/typescript/DocumentSnapshot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EncodedSourceMap, TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
import path from 'path';
import { walk } from 'svelte/compiler';
import { TemplateNode } from 'svelte/types/compiler/interfaces';
import { svelte2tsx, IExportedNames } from 'svelte2tsx';
Expand All @@ -23,11 +24,13 @@ import {
getScriptKindFromAttributes,
getScriptKindFromFileName,
isSvelteFilePath,
getTsCheckComment
getTsCheckComment,
findExports
} from './utils';
import { Logger } from '../../logger';
import { dirname, join, resolve } from 'path';
import { dirname, resolve } from 'path';
import { URI } from 'vscode-uri';
import { surroundWithIgnoreComments } from './features/utils';

/**
* An error which occurred while trying to parse/preprocess the svelte file contents.
Expand Down Expand Up @@ -394,17 +397,42 @@ export class SvelteDocumentSnapshot implements DocumentSnapshot {
}
}

export const kitPageFiles = new Set([
'+page.ts',
'+page.js',
'+layout.ts',
'+layout.js',
'+page.server.ts',
'+page.server.js',
'+layout.server.ts',
'+layout.server.js'
]);

/**
* A js/ts document snapshot suitable for the ts language service and the plugin.
* Since no mapping has to be done here, it also implements the mapper interface.
* If it's a SvelteKit file (e.g. +page.ts), types will be auto-added if not explicitly typed.
*/
export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSnapshot {
scriptKind = getScriptKindFromFileName(this.filePath);
scriptInfo = null;
private lineOffsets?: number[];
private internalLineOffsets?: number[];
private addedCode: Array<{
generatedPos: number;
originalPos: number;
length: number;
inserted: string;
total: number;
}> = [];
private originalText = this.text;
private kitFile = '';

constructor(public version: number, public readonly filePath: string, private text: string) {
super(pathToUrl(filePath));
const basename = path.basename(this.filePath);
this.kitFile = kitPageFiles.has(basename) ? basename : '';
this.adjustText();
}

getText(start: number, end: number) {
Expand All @@ -431,22 +459,65 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn
return offsetAt(position, this.text, this.getLineOffsets());
}

getGeneratedPosition(originalPosition: Position): Position {
if (!this.kitFile || this.addedCode.length === 0) {
return super.getGeneratedPosition(originalPosition);
}
const pos = this.originalOffsetAt(originalPosition);

let total = 0;
for (const added of this.addedCode) {
if (pos < added.generatedPos) break;
total += added.length;
}

return this.positionAt(pos + total);
}

getOriginalPosition(generatedPosition: Position): Position {
if (!this.kitFile || this.addedCode.length === 0) {
return super.getOriginalPosition(generatedPosition);
}
const pos = this.offsetAt(generatedPosition);

let total = 0;
let idx = 0;
for (; idx < this.addedCode.length; idx++) {
const added = this.addedCode[idx];
if (pos < added.generatedPos) break;
total += added.length;
}

if (idx > 0) {
const prev = this.addedCode[idx - 1];
// Special case: pos is in the middle of an added range
if (pos > prev.generatedPos && pos < prev.generatedPos + prev.length) {
total -= pos - prev.generatedPos;
}
}

return this.originalPositionAt(pos - total);
}

update(changes: TextDocumentContentChangeEvent[]): void {
for (const change of changes) {
let start = 0;
let end = 0;
if ('range' in change) {
start = this.offsetAt(change.range.start);
end = this.offsetAt(change.range.end);
start = this.originalOffsetAt(change.range.start);
end = this.originalOffsetAt(change.range.end);
} else {
end = this.getLength();
end = this.originalText.length;
}

this.text = this.text.slice(0, start) + change.text + this.text.slice(end);
this.originalText =
this.originalText.slice(0, start) + change.text + this.originalText.slice(end);
}

this.adjustText();
this.version++;
this.lineOffsets = undefined;
this.internalLineOffsets = undefined;
}

protected getLineOffsets() {
Expand All @@ -455,6 +526,166 @@ export class JSOrTSDocumentSnapshot extends IdentityMapper implements DocumentSn
}
return this.lineOffsets;
}

private originalOffsetAt(position: Position): number {
return offsetAt(position, this.originalText, this.getOriginalLineOffsets());
}

private originalPositionAt(offset: number): Position {
return positionAt(offset, this.originalText, this.getOriginalLineOffsets());
}

private getOriginalLineOffsets() {
if (!this.kitFile) {
return this.getLineOffsets();
}
if (!this.internalLineOffsets) {
this.internalLineOffsets = getLineOffsets(this.originalText);
}
return this.internalLineOffsets;
}

private adjustText() {
this.addedCode = [];

if (this.kitFile) {
const source = ts.createSourceFile(
this.filePath,
this.originalText,
ts.ScriptTarget.Latest,
true,
this.scriptKind
);

const insert = (pos: number, inserted: string) => {
const insertionIdx = this.addedCode.findIndex((c) => c.generatedPos > pos);
if (insertionIdx >= 0) {
for (let i = insertionIdx; i < this.addedCode.length; i++) {
this.addedCode[i].generatedPos += inserted.length;
this.addedCode[i].total += inserted.length;
}
const prevTotal = this.addedCode[insertionIdx - 1]?.total ?? 0;
this.addedCode.splice(insertionIdx, 0, {
generatedPos: pos + prevTotal,
originalPos: pos,
length: inserted.length,
inserted,
total: prevTotal + inserted.length
});
} else {
const prevTotal = this.addedCode[this.addedCode.length - 1]?.total ?? 0;
this.addedCode.push({
generatedPos: pos + prevTotal,
originalPos: pos,
length: inserted.length,
inserted,
total: prevTotal + inserted.length
});
}
};

const exports = findExports(source);
const isTsFile = this.filePath.endsWith('.ts');

// add type to load function if not explicitly typed
const load = exports.get('load');
if (
load?.type === 'function' &&
load.node.parameters.length === 1 &&
!load.node.parameters[0].type &&
(isTsFile || !ts.getJSDocType(load.node))
) {
const pos = load.node.parameters[0].getEnd();
const inserted = surroundWithIgnoreComments(
`: import('./$types').${this.kitFile.includes('layout') ? 'Layout' : 'Page'}${
this.kitFile.includes('server') ? 'Server' : ''
}LoadEvent`
);
insert(pos, inserted);
}

// add type to actions variable if not explicitly typed
const actions = exports.get('actions');
if (
actions?.type === 'var' &&
!actions.node.type &&
(isTsFile || !ts.getJSDocType(actions.node)) &&
actions.node.initializer &&
ts.isObjectLiteralExpression(actions.node.initializer)
) {
const pos = actions.node.initializer.getEnd();
const inserted = surroundWithIgnoreComments(
` satisfies import('./$types').Actions`
);
insert(pos, inserted);
}

// add type to prerender variable if not explicitly typed
const prerender = exports.get('prerender');
if (
prerender?.type === 'var' &&
!prerender.node.type &&
(isTsFile || !ts.getJSDocType(prerender.node)) &&
prerender.node.initializer
) {
const pos = prerender.node.name.getEnd();
const inserted = surroundWithIgnoreComments(
` : import('@sveltejs/kit').PrerenderOption`
);
insert(pos, inserted);
}

// add type to trailingSlash variable if not explicitly typed
const trailingSlash = exports.get('trailingSlash');
if (
trailingSlash?.type === 'var' &&
!trailingSlash.node.type &&
(isTsFile || !ts.getJSDocType(trailingSlash.node)) &&
trailingSlash.node.initializer
) {
const pos = trailingSlash.node.name.getEnd();
const inserted = surroundWithIgnoreComments(` : 'never' | 'always' | 'ignore'`); // TODO this should be exported from kit
insert(pos, inserted);
}

// add type to ssr variable if not explicitly typed
const ssr = exports.get('ssr');
if (
ssr?.type === 'var' &&
!ssr.node.type &&
(isTsFile || !ts.getJSDocType(ssr.node)) &&
ssr.node.initializer
) {
const pos = ssr.node.name.getEnd();
const inserted = surroundWithIgnoreComments(` : boolean`);
insert(pos, inserted);
}

// add type to csr variable if not explicitly typed
const csr = exports.get('csr');
if (
csr?.type === 'var' &&
!csr.node.type &&
(isTsFile || !ts.getJSDocType(csr.node)) &&
csr.node.initializer
) {
const pos = csr.node.name.getEnd();
const inserted = surroundWithIgnoreComments(` : boolean`);
insert(pos, inserted);
}

// construct generated text from internal text and addedCode array
let pos = 0;
this.text = '';
for (const added of this.addedCode) {
this.text += this.originalText.slice(pos, added.originalPos) + added.inserted;
pos = added.originalPos;
}
this.text += this.originalText.slice(pos);
} else {
this.text = this.originalText;
}
}
}

const sourceMapCommentRegExp = /^\/\/[@#] source[M]appingURL=(.+)\r?\n?$/;
Expand Down
17 changes: 14 additions & 3 deletions packages/language-server/src/plugins/typescript/features/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,25 @@ export function isComponentAtPosition(
return !!getNodeIfIsInComponentStartTag(doc.html, doc.offsetAt(originalPosition));
}

export const IGNORE_START_COMMENT = '/*Ωignore_startΩ*/';
export const IGNORE_END_COMMENT = '/*Ωignore_endΩ*/';

/**
* Surrounds given string with a start/end comment which marks it
* to be ignored by tooling.
*/
export function surroundWithIgnoreComments(str: string): string {
return IGNORE_START_COMMENT + str + IGNORE_END_COMMENT;
}

/**
* Checks if this a section that should be completely ignored
* because it's purely generated.
*/
export function isInGeneratedCode(text: string, start: number, end: number = start) {
const lastStart = text.lastIndexOf('/*Ωignore_startΩ*/', start);
const lastEnd = text.lastIndexOf('/*Ωignore_endΩ*/', start);
const nextEnd = text.indexOf('/*Ωignore_endΩ*/', end);
const lastStart = text.lastIndexOf(IGNORE_START_COMMENT, start);
const lastEnd = text.lastIndexOf(IGNORE_END_COMMENT, start);
const nextEnd = text.indexOf(IGNORE_END_COMMENT, end);
// if lastEnd === nextEnd, this means that the str was found at the index
// up to which is searched for it
return (lastStart > lastEnd || lastEnd === nextEnd) && lastStart < nextEnd;
Expand Down
47 changes: 47 additions & 0 deletions packages/language-server/src/plugins/typescript/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,50 @@ export function hasTsExtensions(fileName: string) {
fileName.endsWith(ts.Extension.Ts)
);
}

/**
* Finds the top level const/let/function exports of a source file.
*/
export function findExports(source: ts.SourceFile) {
const exports = new Map<
string,
| {
type: 'function';
node: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression;
}
| { type: 'var'; node: ts.VariableDeclaration }
>();
// TODO handle indirect exports?
for (const statement of source.statements) {
if (
ts.isFunctionDeclaration(statement) &&
statement.name &&
ts.getModifiers(statement)?.[0]?.kind === ts.SyntaxKind.ExportKeyword
) {
// export function x ...
exports.set(statement.name.text, { type: 'function', node: statement });
}
if (
ts.isVariableStatement(statement) &&
statement.declarationList.declarations.length === 1 &&
ts.getModifiers(statement)?.[0]?.kind === ts.SyntaxKind.ExportKeyword
) {
// export const x = ...
const declaration = statement.declarationList.declarations[0];
if (
declaration.initializer &&
(ts.isFunctionExpression(declaration.initializer) ||
ts.isArrowFunction(declaration.initializer))
) {
exports.set(declaration.name.getText(), {
type: 'function',
node: declaration.initializer
});
} else {
exports.set(declaration.name.getText(), { type: 'var', node: declaration });
}
}
}

return exports;
}
Loading

0 comments on commit 0374000

Please sign in to comment.