summaryrefslogtreecommitdiff
path: root/tools/vscode/packages/server/src
diff options
context:
space:
mode:
Diffstat (limited to 'tools/vscode/packages/server/src')
-rw-r--r--tools/vscode/packages/server/src/core/config/ConfigManager.ts13
-rw-r--r--tools/vscode/packages/server/src/core/config/index.ts1
-rw-r--r--tools/vscode/packages/server/src/core/documents/Document.ts159
-rw-r--r--tools/vscode/packages/server/src/core/documents/DocumentManager.ts104
-rw-r--r--tools/vscode/packages/server/src/core/documents/index.ts2
-rw-r--r--tools/vscode/packages/server/src/core/documents/parseAstro.ts74
-rw-r--r--tools/vscode/packages/server/src/core/documents/parseHtml.ts169
-rw-r--r--tools/vscode/packages/server/src/core/documents/utils.ts139
-rw-r--r--tools/vscode/packages/server/src/index.ts104
-rw-r--r--tools/vscode/packages/server/src/plugins/PluginHost.ts166
-rw-r--r--tools/vscode/packages/server/src/plugins/astro/AstroPlugin.ts107
-rw-r--r--tools/vscode/packages/server/src/plugins/html/HTMLPlugin.ts135
-rw-r--r--tools/vscode/packages/server/src/plugins/index.ts5
-rw-r--r--tools/vscode/packages/server/src/plugins/interfaces.ts217
-rw-r--r--tools/vscode/packages/server/src/plugins/typescript/LanguageServiceManager.ts82
-rw-r--r--tools/vscode/packages/server/src/plugins/typescript/SnapshotManager.ts333
-rw-r--r--tools/vscode/packages/server/src/plugins/typescript/TypeScriptPlugin.ts89
-rw-r--r--tools/vscode/packages/server/src/plugins/typescript/astro-sys.ts42
-rw-r--r--tools/vscode/packages/server/src/plugins/typescript/features/CompletionsProvider.ts123
-rw-r--r--tools/vscode/packages/server/src/plugins/typescript/languageService.ts179
-rw-r--r--tools/vscode/packages/server/src/plugins/typescript/utils.ts182
-rw-r--r--tools/vscode/packages/server/src/utils.ts98
22 files changed, 2523 insertions, 0 deletions
diff --git a/tools/vscode/packages/server/src/core/config/ConfigManager.ts b/tools/vscode/packages/server/src/core/config/ConfigManager.ts
new file mode 100644
index 000000000..4c1c23b13
--- /dev/null
+++ b/tools/vscode/packages/server/src/core/config/ConfigManager.ts
@@ -0,0 +1,13 @@
+import { VSCodeEmmetConfig } from 'vscode-emmet-helper';
+
+export class ConfigManager {
+ private emmetConfig: VSCodeEmmetConfig = {};
+
+ updateEmmetConfig(config: VSCodeEmmetConfig): void {
+ this.emmetConfig = config || {};
+ }
+
+ getEmmetConfig(): VSCodeEmmetConfig {
+ return this.emmetConfig;
+ }
+}
diff --git a/tools/vscode/packages/server/src/core/config/index.ts b/tools/vscode/packages/server/src/core/config/index.ts
new file mode 100644
index 000000000..cd869b795
--- /dev/null
+++ b/tools/vscode/packages/server/src/core/config/index.ts
@@ -0,0 +1 @@
+export * from './ConfigManager';
diff --git a/tools/vscode/packages/server/src/core/documents/Document.ts b/tools/vscode/packages/server/src/core/documents/Document.ts
new file mode 100644
index 000000000..4f90813ee
--- /dev/null
+++ b/tools/vscode/packages/server/src/core/documents/Document.ts
@@ -0,0 +1,159 @@
+import { Position, Range } from 'vscode-languageserver';
+import { TextDocument } from 'vscode-languageserver-textdocument';
+import { HTMLDocument } from 'vscode-html-languageservice';
+
+import { clamp, urlToPath } from '../../utils';
+import { parseHtml } from './parseHtml';
+import { parseAstro, AstroDocument } from './parseAstro';
+
+export class Document implements TextDocument {
+
+ private content: string;
+
+ languageId = 'astro';
+ version = 0;
+ html!: HTMLDocument;
+ astro!: AstroDocument;
+
+ constructor(public uri: string, text: string) {
+ this.content = text;
+ this.updateDocInfo();
+ }
+
+ private updateDocInfo() {
+ this.html = parseHtml(this.content);
+ this.astro = parseAstro(this.content);
+ }
+
+ setText(text: string) {
+ this.content = text;
+ this.version++;
+ this.updateDocInfo();
+ }
+
+ /**
+ * Update the text between two positions.
+ * @param text The new text slice
+ * @param start Start offset of the new text
+ * @param end End offset of the new text
+ */
+ update(text: string, start: number, end: number): void {
+ const content = this.getText();
+ this.setText(content.slice(0, start) + text + content.slice(end));
+ }
+
+ getText(): string {
+ return this.content
+ }
+
+ /**
+ * Get the line and character based on the offset
+ * @param offset The index of the position
+ */
+ positionAt(offset: number): Position {
+ offset = clamp(offset, 0, this.getTextLength());
+
+ const lineOffsets = this.getLineOffsets();
+ let low = 0;
+ let high = lineOffsets.length;
+ if (high === 0) {
+ return Position.create(0, 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 Position.create(line, offset - lineOffsets[line]);
+ }
+
+ /**
+ * Get the index of the line and character position
+ * @param position Line and character position
+ */
+ offsetAt(position: Position): number {
+ const lineOffsets = this.getLineOffsets();
+
+ if (position.line >= lineOffsets.length) {
+ return this.getTextLength();
+ } else if (position.line < 0) {
+ return 0;
+ }
+
+ const lineOffset = lineOffsets[position.line];
+ const nextLineOffset =
+ position.line + 1 < lineOffsets.length
+ ? lineOffsets[position.line + 1]
+ : this.getTextLength();
+
+ return clamp(nextLineOffset, lineOffset, lineOffset + position.character);
+ }
+
+ getLineUntilOffset(offset: number): string {
+ const { line, character } = this.positionAt(offset);
+ return this.lines[line].slice(0, character);
+ }
+
+ private getLineOffsets() {
+ const lineOffsets = [];
+ const text = this.getText();
+ 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;
+ }
+
+ /**
+ * Get the length of the document's content
+ */
+ getTextLength(): number {
+ return this.getText().length;
+ }
+
+ /**
+ * Returns the file path if the url scheme is file
+ */
+ getFilePath(): string | null {
+ return urlToPath(this.uri);
+ }
+
+ /**
+ * Get URL file path.
+ */
+ getURL() {
+ return this.uri;
+ }
+
+
+ get lines(): string[] {
+ return this.getText().split(/\r?\n/);
+ }
+
+ get lineCount(): number {
+ return this.lines.length;
+ }
+
+}
diff --git a/tools/vscode/packages/server/src/core/documents/DocumentManager.ts b/tools/vscode/packages/server/src/core/documents/DocumentManager.ts
new file mode 100644
index 000000000..6195514d8
--- /dev/null
+++ b/tools/vscode/packages/server/src/core/documents/DocumentManager.ts
@@ -0,0 +1,104 @@
+import { EventEmitter } from 'events';
+import {
+ TextDocumentContentChangeEvent,
+ TextDocumentItem
+} from 'vscode-languageserver';
+import { Document } from './Document';
+import { normalizeUri } from '../../utils';
+
+export type DocumentEvent = 'documentOpen' | 'documentChange' | 'documentClose';
+
+export class DocumentManager {
+ private emitter = new EventEmitter();
+ private openedInClient = new Set<string>();
+ private documents: Map<string, Document> = new Map();
+ private locked = new Set<string>();
+ private deleteCandidates = new Set<string>();
+
+ constructor(
+ private createDocument: (textDocument: { uri: string, text: string }) => Document
+ ) {}
+
+ get(uri: string) {
+ return this.documents.get(normalizeUri(uri));
+ }
+
+ openDocument(textDocument: TextDocumentItem) {
+ let document: Document;
+ if (this.documents.has(textDocument.uri)) {
+ document = this.get(textDocument.uri) as Document;
+ document.setText(textDocument.text);
+ } else {
+ document = this.createDocument(textDocument);
+ this.documents.set(normalizeUri(textDocument.uri), document);
+ this.notify('documentOpen', document);
+ }
+
+ this.notify('documentChange', document);
+
+ return document;
+ }
+
+ closeDocument(uri: string) {
+ uri = normalizeUri(uri);
+
+ const document = this.documents.get(uri);
+ if (!document) {
+ throw new Error('Cannot call methods on an unopened document');
+ }
+
+ this.notify('documentClose', document);
+
+ // Some plugin may prevent a document from actually being closed.
+ if (!this.locked.has(uri)) {
+ this.documents.delete(uri);
+ } else {
+ this.deleteCandidates.add(uri);
+ }
+
+ this.openedInClient.delete(uri);
+ }
+
+ updateDocument(
+ uri: string,
+ changes: TextDocumentContentChangeEvent[]
+ ) {
+ const document = this.documents.get(normalizeUri(uri));
+ if (!document) {
+ throw new Error('Cannot call methods on an unopened document');
+ }
+
+ for (const change of changes) {
+ let start = 0;
+ let end = 0;
+ if ('range' in change) {
+ start = document.offsetAt(change.range.start);
+ end = document.offsetAt(change.range.end);
+ } else {
+ end = document.getTextLength();
+ }
+
+ document.update(change.text, start, end);
+ }
+
+ this.notify('documentChange', document);
+ }
+
+ markAsOpenedInClient(uri: string) {
+ this.openedInClient.add(normalizeUri(uri));
+ }
+
+ getAllOpenedByClient() {
+ return Array.from(this.documents.entries()).filter((doc) =>
+ this.openedInClient.has(doc[0])
+ );
+ }
+
+ on(name: DocumentEvent, listener: (document: Document) => void) {
+ this.emitter.on(name, listener);
+ }
+
+ private notify(name: DocumentEvent, document: Document) {
+ this.emitter.emit(name, document);
+ }
+}
diff --git a/tools/vscode/packages/server/src/core/documents/index.ts b/tools/vscode/packages/server/src/core/documents/index.ts
new file mode 100644
index 000000000..708a040c9
--- /dev/null
+++ b/tools/vscode/packages/server/src/core/documents/index.ts
@@ -0,0 +1,2 @@
+export * from './Document';
+export * from './DocumentManager';
diff --git a/tools/vscode/packages/server/src/core/documents/parseAstro.ts b/tools/vscode/packages/server/src/core/documents/parseAstro.ts
new file mode 100644
index 000000000..e4f71721a
--- /dev/null
+++ b/tools/vscode/packages/server/src/core/documents/parseAstro.ts
@@ -0,0 +1,74 @@
+import { getFirstNonWhitespaceIndex } from './utils';
+
+interface Frontmatter {
+ state: null | 'open' | 'closed';
+ startOffset: null | number;
+ endOffset: null | number;
+}
+
+interface Content {
+ firstNonWhitespaceOffset: null | number;
+}
+
+export interface AstroDocument {
+ frontmatter: Frontmatter
+ content: Content;
+}
+
+/** Parses a document to collect metadata about Astro features */
+export function parseAstro(content: string): AstroDocument {
+ const frontmatter = getFrontmatter(content)
+ return {
+ frontmatter,
+ content: getContent(content, frontmatter)
+ }
+}
+
+/** Get frontmatter metadata */
+function getFrontmatter(content: string): Frontmatter {
+ /** Quickly check how many `---` blocks are in the document */
+ function getFrontmatterState(): Frontmatter['state'] {
+ const parts = content.trim().split('---').length;
+ switch (parts) {
+ case 1: return null;
+ case 2: return 'open';
+ default: return 'closed';
+ }
+ }
+ const state = getFrontmatterState();
+
+ /** Construct a range containing the document's frontmatter */
+ function getFrontmatterOffsets(): [number|null, number|null] {
+ const startOffset = content.indexOf('---');
+ if (startOffset === -1) return [null, null];
+ const endOffset = content.slice(startOffset + 3).indexOf('---') + 3;
+ if (endOffset === -1) return [startOffset, null];
+ return [startOffset, endOffset];
+ }
+ const [startOffset, endOffset] = getFrontmatterOffsets();
+
+ return {
+ state,
+ startOffset,
+ endOffset
+ };
+}
+
+/** Get content metadata */
+function getContent(content: string, frontmatter: Frontmatter): Content {
+ switch (frontmatter.state) {
+ case null: {
+ const offset = getFirstNonWhitespaceIndex(content);
+ return { firstNonWhitespaceOffset: offset === -1 ? null : offset }
+ }
+ case 'open': {
+ return { firstNonWhitespaceOffset: null }
+ }
+ case 'closed': {
+ const { endOffset } = frontmatter;
+ const end = (endOffset ?? 0) + 3;
+ const offset = getFirstNonWhitespaceIndex(content.slice(end))
+ return { firstNonWhitespaceOffset: end + offset }
+ }
+ }
+}
diff --git a/tools/vscode/packages/server/src/core/documents/parseHtml.ts b/tools/vscode/packages/server/src/core/documents/parseHtml.ts
new file mode 100644
index 000000000..86af06008
--- /dev/null
+++ b/tools/vscode/packages/server/src/core/documents/parseHtml.ts
@@ -0,0 +1,169 @@
+import {
+ getLanguageService,
+ HTMLDocument,
+ TokenType,
+ ScannerState,
+ Scanner,
+ Node,
+ Position
+} from 'vscode-html-languageservice';
+import { Document } from './Document';
+import { isInsideExpression } from './utils';
+
+const parser = getLanguageService();
+
+/**
+ * Parses text as HTML
+ */
+export function parseHtml(text: string): HTMLDocument {
+ const preprocessed = preprocess(text);
+
+ // We can safely only set getText because only this is used for parsing
+ const parsedDoc = parser.parseHTMLDocument(<any>{ getText: () => preprocessed });
+
+ return parsedDoc;
+}
+
+const createScanner = parser.createScanner as (
+ input: string,
+ initialOffset?: number,
+ initialState?: ScannerState
+) => Scanner;
+
+/**
+ * scan the text and remove any `>` or `<` that cause the tag to end short,
+ */
+function preprocess(text: string) {
+ let scanner = createScanner(text);
+ let token = scanner.scan();
+ let currentStartTagStart: number | null = null;
+
+ while (token !== TokenType.EOS) {
+ const offset = scanner.getTokenOffset();
+
+ if (token === TokenType.StartTagOpen) {
+ currentStartTagStart = offset;
+ }
+
+ if (token === TokenType.StartTagClose) {
+ if (shouldBlankStartOrEndTagLike(offset)) {
+ blankStartOrEndTagLike(offset);
+ } else {
+ currentStartTagStart = null;
+ }
+ }
+
+ if (token === TokenType.StartTagSelfClose) {
+ currentStartTagStart = null;
+ }
+
+ // <Foo checked={a < 1}>
+ // https://github.com/microsoft/vscode-html-languageservice/blob/71806ef57be07e1068ee40900ef8b0899c80e68a/src/parser/htmlScanner.ts#L327
+ if (
+ token === TokenType.Unknown &&
+ scanner.getScannerState() === ScannerState.WithinTag &&
+ scanner.getTokenText() === '<' &&
+ shouldBlankStartOrEndTagLike(offset)
+ ) {
+ blankStartOrEndTagLike(offset);
+ }
+
+ token = scanner.scan();
+ }
+
+ return text;
+
+ function shouldBlankStartOrEndTagLike(offset: number) {
+ // not null rather than falsy, otherwise it won't work on first tag(0)
+ return (
+ currentStartTagStart !== null &&
+ isInsideExpression(text, currentStartTagStart, offset)
+ );
+ }
+
+ function blankStartOrEndTagLike(offset: number) {
+ text = text.substring(0, offset) + ' ' + text.substring(offset + 1);
+ scanner = createScanner(text, offset, ScannerState.WithinTag);
+ }
+}
+
+export interface AttributeContext {
+ name: string;
+ inValue: boolean;
+ valueRange?: [number, number];
+}
+
+export function getAttributeContextAtPosition(
+ document: Document,
+ position: Position
+): AttributeContext | null {
+ const offset = document.offsetAt(position);
+ const { html } = document;
+ const tag = html.findNodeAt(offset);
+
+ if (!inStartTag(offset, tag) || !tag.attributes) {
+ return null;
+ }
+
+ const text = document.getText();
+ const beforeStartTagEnd =
+ text.substring(0, tag.start) + preprocess(text.substring(tag.start, tag.startTagEnd));
+
+ const scanner = createScanner(beforeStartTagEnd, tag.start);
+
+ let token = scanner.scan();
+ let currentAttributeName: string | undefined;
+ const inTokenRange = () =>
+ scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd();
+ while (token != TokenType.EOS) {
+ // adopted from https://github.com/microsoft/vscode-html-languageservice/blob/2f7ae4df298ac2c299a40e9024d118f4a9dc0c68/src/services/htmlCompletion.ts#L402
+ if (token === TokenType.AttributeName) {
+ currentAttributeName = scanner.getTokenText();
+
+ if (inTokenRange()) {
+ return {
+ name: currentAttributeName,
+ inValue: false
+ };
+ }
+ } else if (token === TokenType.DelimiterAssign) {
+ if (scanner.getTokenEnd() === offset && currentAttributeName) {
+ const nextToken = scanner.scan();
+
+ return {
+ name: currentAttributeName,
+ inValue: true,
+ valueRange: [
+ offset,
+ nextToken === TokenType.AttributeValue ? scanner.getTokenEnd() : offset
+ ]
+ };
+ }
+ } else if (token === TokenType.AttributeValue) {
+ if (inTokenRange() && currentAttributeName) {
+ let start = scanner.getTokenOffset();
+ let end = scanner.getTokenEnd();
+ const char = text[start];
+
+ if (char === '"' || char === "'") {
+ start++;
+ end--;
+ }
+
+ return {
+ name: currentAttributeName,
+ inValue: true,
+ valueRange: [start, end]
+ };
+ }
+ currentAttributeName = undefined;
+ }
+ token = scanner.scan();
+ }
+
+ return null;
+}
+
+function inStartTag(offset: number, node: Node) {
+ return offset > node.start && node.startTagEnd != undefined && offset < node.startTagEnd;
+}
diff --git a/tools/vscode/packages/server/src/core/documents/utils.ts b/tools/vscode/packages/server/src/core/documents/utils.ts
new file mode 100644
index 000000000..6c69014d5
--- /dev/null
+++ b/tools/vscode/packages/server/src/core/documents/utils.ts
@@ -0,0 +1,139 @@
+import { Position } from 'vscode-html-languageservice';
+import { clamp } from '../../utils';
+
+/**
+ * Gets word range at position.
+ * Delimiter is by default a whitespace, but can be adjusted.
+ */
+export function getWordRangeAt(
+ str: string,
+ pos: number,
+ delimiterRegex = { left: /\S+$/, right: /\s/ }
+): { start: number; end: number } {
+ let start = str.slice(0, pos).search(delimiterRegex.left);
+ if (start < 0) {
+ start = pos;
+ }
+
+ let end = str.slice(pos).search(delimiterRegex.right);
+ if (end < 0) {
+ end = str.length;
+ } else {
+ end = end + pos;
+ }
+
+ return { start, end };
+}
+
+/**
+ * Gets word at position.
+ * Delimiter is by default a whitespace, but can be adjusted.
+ */
+export function getWordAt(
+ str: string,
+ pos: number,
+ delimiterRegex = { left: /\S+$/, right: /\s/ }
+): string {
+ const { start, end } = getWordRangeAt(str, pos, delimiterRegex);
+ return str.slice(start, end);
+}
+
+/**
+ * Gets index of first-non-whitespace character.
+ */
+export function getFirstNonWhitespaceIndex(str: string): number {
+ return str.length - str.trimStart().length;
+}
+
+/** checks if a position is currently inside of an expression */
+export function isInsideExpression(html: string, tagStart: number, position: number) {
+ const charactersInNode = html.substring(tagStart, position);
+ return charactersInNode.lastIndexOf('{') > charactersInNode.lastIndexOf('}');
+}
+
+/**
+ * Returns if a given offset is inside of the document frontmatter
+ */
+export function isInsideFrontmatter(
+ text: string,
+ offset: number
+): boolean {
+ let start = text.slice(0, offset).trim().split('---').length;
+ let end = text.slice(offset).trim().split('---').length;
+
+ return start > 1 && start < 3 && end >= 1;
+}
+
+/**
+ * Get the line and character based on the offset
+ * @param offset The index of the position
+ * @param text The text for which the position should be retrived
+ */
+export function positionAt(offset: number, text: string): Position {
+ offset = clamp(offset, 0, text.length);
+
+ const lineOffsets = getLineOffsets(text);
+ let low = 0;
+ let high = lineOffsets.length;
+ if (high === 0) {
+ return Position.create(0, 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 Position.create(line, offset - lineOffsets[line]);
+}
+
+/**
+ * Get the offset of the line and character position
+ * @param position Line and character position
+ * @param text The text for which the offset should be retrived
+ */
+export function offsetAt(position: Position, text: string): number {
+ const lineOffsets = getLineOffsets(text);
+
+ if (position.line >= lineOffsets.length) {
+ return text.length;
+ } else if (position.line < 0) {
+ return 0;
+ }
+
+ const lineOffset = lineOffsets[position.line];
+ const nextLineOffset =
+ position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : text.length;
+
+ return clamp(nextLineOffset, lineOffset, lineOffset + position.character);
+}
+
+function getLineOffsets(text: string) {
+ const lineOffsets = [];
+ 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;
+}
diff --git a/tools/vscode/packages/server/src/index.ts b/tools/vscode/packages/server/src/index.ts
new file mode 100644
index 000000000..f72ad550b
--- /dev/null
+++ b/tools/vscode/packages/server/src/index.ts
@@ -0,0 +1,104 @@
+import { RequestType, TextDocumentPositionParams, createConnection, ProposedFeatures, TextDocumentSyncKind, TextDocumentIdentifier } from 'vscode-languageserver';
+import { Document, DocumentManager } from './core/documents';
+import { ConfigManager } from './core/config';
+import { PluginHost, HTMLPlugin, TypeScriptPlugin, AppCompletionItem, AstroPlugin } from './plugins';
+import { urlToPath } from './utils';
+
+const TagCloseRequest: RequestType<TextDocumentPositionParams, string | null, any> = new RequestType('html/tag');
+
+/** */
+export function startServer() {
+ let connection = createConnection(ProposedFeatures.all);
+
+ const docManager = new DocumentManager(({ uri, text }: { uri: string; text: string }) => new Document(uri, text));
+ const configManager = new ConfigManager();
+ const pluginHost = new PluginHost(docManager);
+
+ connection.onInitialize((evt) => {
+ const workspaceUris = evt.workspaceFolders?.map((folder) => folder.uri.toString()) ?? [evt.rootUri ?? ''];
+
+ pluginHost.register(new AstroPlugin(docManager, configManager));
+ pluginHost.register(new HTMLPlugin(docManager, configManager));
+ pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspaceUris));
+ configManager.updateEmmetConfig(evt.initializationOptions?.configuration?.emmet || evt.initializationOptions?.emmetConfig || {});
+
+ return {
+ capabilities: {
+ textDocumentSync: TextDocumentSyncKind.Incremental,
+ foldingRangeProvider: true,
+ completionProvider: {
+ resolveProvider: false,
+ triggerCharacters: [
+ '.',
+ '"',
+ "'",
+ '`',
+ '/',
+ '@',
+ '<',
+
+ // Emmet
+ '>',
+ '*',
+ '#',
+ '$',
+ '+',
+ '^',
+ '(',
+ '[',
+ '@',
+ '-',
+ // No whitespace because
+ // it makes for weird/too many completions
+ // of other completion providers
+
+ // Astro
+ ':',
+ ],
+ },
+ },
+ };
+ });
+
+ // Documents
+ connection.onDidOpenTextDocument((evt) => {
+ docManager.openDocument(evt.textDocument);
+ docManager.markAsOpenedInClient(evt.textDocument.uri);
+ });
+
+ connection.onDidCloseTextDocument((evt) => docManager.closeDocument(evt.textDocument.uri));
+
+ connection.onDidChangeTextDocument((evt) => docManager.updateDocument(evt.textDocument.uri, evt.contentChanges));
+
+ connection.onDidChangeWatchedFiles((evt) => {
+ const params = evt.changes.map(change => ({
+ fileName: urlToPath(change.uri),
+ changeType: change.type
+ })).filter(change => !!change.fileName)
+
+ pluginHost.onWatchFileChanges(params);
+ });
+
+ // Config
+ connection.onDidChangeConfiguration(({ settings }) => {
+ configManager.updateEmmetConfig(settings.emmet);
+ });
+
+ // Features
+ connection.onCompletion((evt) => pluginHost.getCompletions(evt.textDocument, evt.position, evt.context));
+ connection.onCompletionResolve((completionItem) => {
+ const data = (completionItem as AppCompletionItem).data as TextDocumentIdentifier;
+
+ if (!data) {
+ return completionItem;
+ }
+
+ return pluginHost.resolveCompletion(data, completionItem);
+ });
+ connection.onFoldingRanges((evt) => pluginHost.getFoldingRanges(evt.textDocument));
+ connection.onRequest(TagCloseRequest, (evt: any) => pluginHost.doTagComplete(evt.textDocument, evt.position));
+
+ connection.listen();
+}
+
+startServer();
diff --git a/tools/vscode/packages/server/src/plugins/PluginHost.ts b/tools/vscode/packages/server/src/plugins/PluginHost.ts
new file mode 100644
index 000000000..72f098ca1
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/PluginHost.ts
@@ -0,0 +1,166 @@
+
+import {
+ CompletionContext,
+ CompletionItem,
+ CompletionList,
+ Position,
+ TextDocumentIdentifier,
+} from 'vscode-languageserver';
+import type { DocumentManager } from '../core/documents';
+import type * as d from './interfaces';
+import { flatten } from '../utils';
+import { FoldingRange } from 'vscode-languageserver-types';
+
+// eslint-disable-next-line no-shadow
+enum ExecuteMode {
+ None,
+ FirstNonNull,
+ Collect
+}
+
+export class PluginHost {
+ private plugins: d.Plugin[] = [];
+
+ constructor(private documentsManager: DocumentManager) {}
+
+ register(plugin: d.Plugin) {
+ this.plugins.push(plugin);
+ }
+
+ async getCompletions(
+ textDocument: TextDocumentIdentifier,
+ position: Position,
+ completionContext?: CompletionContext
+ ): Promise<CompletionList> {
+ const document = this.getDocument(textDocument.uri);
+ if (!document) {
+ throw new Error('Cannot call methods on an unopened document');
+ }
+
+ const completions = (
+ await this.execute<CompletionList>(
+ 'getCompletions',
+ [document, position, completionContext],
+ ExecuteMode.Collect
+ )
+ ).filter((completion) => completion != null);
+
+ let flattenedCompletions = flatten(completions.map((completion) => completion.items));
+ const isIncomplete = completions.reduce(
+ (incomplete, completion) => incomplete || completion.isIncomplete,
+ false as boolean
+ );
+
+ return CompletionList.create(flattenedCompletions, isIncomplete);
+ }
+
+ async resolveCompletion(
+ textDocument: TextDocumentIdentifier,
+ completionItem: d.AppCompletionItem
+ ): Promise<CompletionItem> {
+ const document = this.getDocument(textDocument.uri);
+
+ if (!document) {
+ throw new Error('Cannot call methods on an unopened document');
+ }
+
+ const result = await this.execute<CompletionItem>(
+ 'resolveCompletion',
+ [document, completionItem],
+ ExecuteMode.FirstNonNull
+ );
+
+ return result ?? completionItem;
+ }
+
+ async doTagComplete(
+ textDocument: TextDocumentIdentifier,
+ position: Position
+ ): Promise<string | null> {
+ const document = this.getDocument(textDocument.uri);
+ if (!document) {
+ throw new Error('Cannot call methods on an unopened document');
+ }
+
+ return this.execute<string | null>(
+ 'doTagComplete',
+ [document, position],
+ ExecuteMode.FirstNonNull
+ );
+ }
+
+ async getFoldingRanges(
+ textDocument: TextDocumentIdentifier
+ ): Promise<FoldingRange[]|null> {
+ const document = this.getDocument(textDocument.uri);
+ if (!document) {
+ throw new Error('Cannot call methods on an unopened document');
+ }
+
+ const foldingRanges = flatten(await this.execute<FoldingRange[]>(
+ 'getFoldingRanges',
+ [document],
+ ExecuteMode.Collect
+ )).filter((completion) => completion != null)
+
+ return foldingRanges;
+ }
+
+ onWatchFileChanges(onWatchFileChangesParams: any[]): void {
+ for (const support of this.plugins) {
+ support.onWatchFileChanges?.(onWatchFileChangesParams);
+ }
+ }
+
+ private getDocument(uri: string) {
+ return this.documentsManager.get(uri);
+ }
+
+ private execute<T>(
+ name: keyof d.LSProvider,
+ args: any[],
+ mode: ExecuteMode.FirstNonNull
+ ): Promise<T | null>;
+ private execute<T>(
+ name: keyof d.LSProvider,
+ args: any[],
+ mode: ExecuteMode.Collect
+ ): Promise<T[]>;
+ private execute(name: keyof d.LSProvider, args: any[], mode: ExecuteMode.None): Promise<void>;
+ private async execute<T>(
+ name: keyof d.LSProvider,
+ args: any[],
+ mode: ExecuteMode
+ ): Promise<(T | null) | T[] | void> {
+ const plugins = this.plugins.filter((plugin) => typeof plugin[name] === 'function');
+
+ switch (mode) {
+ case ExecuteMode.FirstNonNull:
+ for (const plugin of plugins) {
+ const res = await this.tryExecutePlugin(plugin, name, args, null);
+ if (res != null) {
+ return res;
+ }
+ }
+ return null;
+ case ExecuteMode.Collect:
+ return Promise.all(
+ plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, []))
+ );
+ case ExecuteMode.None:
+ await Promise.all(
+ plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, null))
+ );
+ return;
+ }
+ }
+
+ private async tryExecutePlugin(plugin: any, fnName: string, args: any[], failValue: any) {
+ try {
+ return await plugin[fnName](...args);
+ } catch (e) {
+ console.error(e);
+ return failValue;
+ }
+ }
+}
diff --git a/tools/vscode/packages/server/src/plugins/astro/AstroPlugin.ts b/tools/vscode/packages/server/src/plugins/astro/AstroPlugin.ts
new file mode 100644
index 000000000..0696504fc
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/astro/AstroPlugin.ts
@@ -0,0 +1,107 @@
+import type { Document, DocumentManager } from '../../core/documents';
+import type { ConfigManager } from '../../core/config';
+import type { CompletionsProvider, AppCompletionItem, AppCompletionList, FoldingRangeProvider } from '../interfaces';
+import { CompletionContext, Position, CompletionList, CompletionItem, CompletionItemKind, InsertTextFormat, FoldingRange, TextEdit } from 'vscode-languageserver';
+import { isPossibleClientComponent } from '../../utils';
+import { FoldingRangeKind } from 'vscode-languageserver-types';
+
+export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
+ private readonly docManager: DocumentManager;
+ private readonly configManager: ConfigManager;
+
+ constructor(docManager: DocumentManager, configManager: ConfigManager) {
+ this.docManager = docManager;
+ this.configManager = configManager;
+ }
+
+ async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise<AppCompletionList | null> {
+ const doc = this.docManager.get(document.uri);
+ if (!doc) return null;
+
+ let items: CompletionItem[] = [];
+
+ if (completionContext?.triggerCharacter === '-') {
+ const frontmatter = this.getComponentScriptCompletion(doc, position, completionContext);
+ if (frontmatter) items.push(frontmatter);
+ }
+
+ if (completionContext?.triggerCharacter === ':') {
+ const clientHint = this.getClientHintCompletion(doc, position, completionContext);
+ if (clientHint) items.push(...clientHint);
+ }
+
+ return CompletionList.create(items, true);
+ }
+
+ async getFoldingRanges(document: Document): Promise<FoldingRange[]> {
+ const foldingRanges: FoldingRange[] = [];
+ const { frontmatter } = document.astro;
+
+ // Currently editing frontmatter, don't fold
+ if (frontmatter.state !== 'closed') return foldingRanges;
+
+ const start = document.positionAt(frontmatter.startOffset as number);
+ const end = document.positionAt((frontmatter.endOffset as number) - 3);
+ return [
+ {
+ startLine: start.line,
+ startCharacter: start.character,
+ endLine: end.line,
+ endCharacter: end.character,
+ kind: FoldingRangeKind.Imports,
+ }
+ ];
+ }
+
+ private getClientHintCompletion(document: Document, position: Position, completionContext?: CompletionContext): CompletionItem[] | null {
+ const node = document.html.findNodeAt(document.offsetAt(position));
+ if (!isPossibleClientComponent(node)) return null;
+
+ return [
+ {
+ label: ':load',
+ insertText: 'load',
+ commitCharacters: ['l'],
+ },
+ {
+ label: ':idle',
+ insertText: 'idle',
+ commitCharacters: ['i'],
+ },
+ {
+ label: ':visible',
+ insertText: 'visible',
+ commitCharacters: ['v'],
+ },
+ ];
+ }
+
+ private getComponentScriptCompletion(document: Document, position: Position, completionContext?: CompletionContext): CompletionItem | null {
+ const base = {
+ kind: CompletionItemKind.Snippet,
+ label: '---',
+ sortText: '\0',
+ preselect: true,
+ detail: 'Component script',
+ insertTextFormat: InsertTextFormat.Snippet,
+ commitCharacters: ['-'],
+ };
+ const prefix = document.getLineUntilOffset(document.offsetAt(position));
+
+ if (document.astro.frontmatter.state === null) {
+ return {
+ ...base,
+ insertText: '---\n$0\n---',
+ textEdit: prefix.match(/^\s*\-+/) ? TextEdit.replace({ start: { ...position, character: 0 }, end: position }, '---\n$0\n---') : undefined,
+ };
+ }
+ if (document.astro.frontmatter.state === 'open') {
+ return {
+ ...base,
+ insertText: '---',
+ textEdit: prefix.match(/^\s*\-+/) ? TextEdit.replace({ start: { ...position, character: 0 }, end: position }, '---') : undefined,
+ };
+ }
+ return null;
+ }
+}
diff --git a/tools/vscode/packages/server/src/plugins/html/HTMLPlugin.ts b/tools/vscode/packages/server/src/plugins/html/HTMLPlugin.ts
new file mode 100644
index 000000000..5114eda1c
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/html/HTMLPlugin.ts
@@ -0,0 +1,135 @@
+import { CompletionsProvider, FoldingRangeProvider } from '../interfaces';
+import { getEmmetCompletionParticipants, VSCodeEmmetConfig } from 'vscode-emmet-helper';
+import { getLanguageService, HTMLDocument, CompletionItem as HtmlCompletionItem, Node, FoldingRange } from 'vscode-html-languageservice';
+import { CompletionList, Position, CompletionItem, CompletionItemKind, TextEdit } from 'vscode-languageserver';
+import type { Document, DocumentManager } from '../../core/documents';
+import { isInsideExpression, isInsideFrontmatter } from '../../core/documents/utils';
+import type { ConfigManager } from '../../core/config';
+
+export class HTMLPlugin implements CompletionsProvider, FoldingRangeProvider {
+ private lang = getLanguageService();
+ private documents = new WeakMap<Document, HTMLDocument>();
+ private styleScriptTemplate = new Set(['template', 'style', 'script']);
+ private configManager: ConfigManager;
+
+ constructor(docManager: DocumentManager, configManager: ConfigManager) {
+ docManager.on('documentChange', (document) => {
+ this.documents.set(document, document.html);
+ });
+ this.configManager = configManager;
+ }
+
+ getCompletions(document: Document, position: Position): CompletionList | null {
+ const html = this.documents.get(document);
+
+ if (!html) {
+ return null;
+ }
+
+ if (this.isInsideFrontmatter(document, position) || this.isInsideExpression(html, document, position)) {
+ return null;
+ }
+
+ const emmetResults: CompletionList = {
+ isIncomplete: true,
+ items: [],
+ };
+ this.lang.setCompletionParticipants([
+ getEmmetCompletionParticipants(
+ document,
+ position,
+ 'html',
+ this.configManager.getEmmetConfig(),
+ emmetResults
+ )
+ ]);
+
+ const results = this.lang.doComplete(document, position, html);
+ const items = this.toCompletionItems(results.items);
+
+ return CompletionList.create(
+ [...this.toCompletionItems(items), ...this.getLangCompletions(items), ...emmetResults.items],
+ // Emmet completions change on every keystroke, so they are never complete
+ emmetResults.items.length > 0
+ );
+ }
+
+ getFoldingRanges(document: Document): FoldingRange[]|null {
+ const html = this.documents.get(document);
+ if (!html) {
+ return null;
+ }
+
+ return this.lang.getFoldingRanges(document);
+
+ }
+
+ doTagComplete(document: Document, position: Position): string | null {
+ const html = this.documents.get(document);
+ if (!html) {
+ return null;
+ }
+
+ if (this.isInsideFrontmatter(document, position) || this.isInsideExpression(html, document, position)) {
+ return null;
+ }
+
+ return this.lang.doTagComplete(document, position, html);
+ }
+
+ /**
+ * The HTML language service uses newer types which clash
+ * without the stable ones. Transform to the stable types.
+ */
+ private toCompletionItems(items: HtmlCompletionItem[]): CompletionItem[] {
+ return items.map((item) => {
+ if (!item.textEdit || TextEdit.is(item.textEdit)) {
+ return item as CompletionItem;
+ }
+ return {
+ ...item,
+ textEdit: TextEdit.replace(item.textEdit.replace, item.textEdit.newText),
+ };
+ });
+ }
+
+ private getLangCompletions(completions: CompletionItem[]): CompletionItem[] {
+ const styleScriptTemplateCompletions = completions.filter((completion) => completion.kind === CompletionItemKind.Property && this.styleScriptTemplate.has(completion.label));
+ const langCompletions: CompletionItem[] = [];
+ addLangCompletion('style', ['scss', 'sass']);
+ return langCompletions;
+
+ /** Add language completions */
+ function addLangCompletion(tag: string, languages: string[]) {
+ const existingCompletion = styleScriptTemplateCompletions.find((completion) => completion.label === tag);
+ if (!existingCompletion) {
+ return;
+ }
+
+ languages.forEach((lang) =>
+ langCompletions.push({
+ ...existingCompletion,
+ label: `${tag} (lang="${lang}")`,
+ insertText: existingCompletion.insertText && `${existingCompletion.insertText} lang="${lang}"`,
+ textEdit:
+ existingCompletion.textEdit && TextEdit.is(existingCompletion.textEdit)
+ ? {
+ range: existingCompletion.textEdit.range,
+ newText: `${existingCompletion.textEdit.newText} lang="${lang}"`,
+ }
+ : undefined,
+ })
+ );
+ }
+ }
+
+ private isInsideExpression(html: HTMLDocument, document: Document, position: Position) {
+ const offset = document.offsetAt(position);
+ const node = html.findNodeAt(offset);
+ return isInsideExpression(document.getText(), node.start, offset);
+ }
+
+ private isInsideFrontmatter(document: Document, position: Position) {
+ return isInsideFrontmatter(document.getText(), document.offsetAt(position));
+ }
+}
diff --git a/tools/vscode/packages/server/src/plugins/index.ts b/tools/vscode/packages/server/src/plugins/index.ts
new file mode 100644
index 000000000..c1b8a4062
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/index.ts
@@ -0,0 +1,5 @@
+export * from './PluginHost';
+export * from './astro/AstroPlugin';
+export * from './html/HTMLPlugin';
+export * from './typescript/TypeScriptPlugin';
+export * from './interfaces';
diff --git a/tools/vscode/packages/server/src/plugins/interfaces.ts b/tools/vscode/packages/server/src/plugins/interfaces.ts
new file mode 100644
index 000000000..31aafdc3e
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/interfaces.ts
@@ -0,0 +1,217 @@
+import {
+ CompletionContext,
+ FileChangeType,
+ LinkedEditingRanges,
+ SemanticTokens,
+ SignatureHelpContext,
+ TextDocumentContentChangeEvent
+} from 'vscode-languageserver';
+import {
+ CodeAction,
+ CodeActionContext,
+ Color,
+ ColorInformation,
+ ColorPresentation,
+ CompletionItem,
+ CompletionList,
+ DefinitionLink,
+ Diagnostic,
+ FormattingOptions,
+ Hover,
+ Location,
+ Position,
+ Range,
+ ReferenceContext,
+ SymbolInformation,
+ TextDocumentIdentifier,
+ TextEdit,
+ WorkspaceEdit,
+ SelectionRange,
+ SignatureHelp,
+ FoldingRange
+} from 'vscode-languageserver-types';
+import { Document } from '../core/documents';
+
+export type Resolvable<T> = T | Promise<T>;
+
+export interface AppCompletionItem<T extends TextDocumentIdentifier = any> extends CompletionItem {
+ data?: T;
+}
+
+export interface AppCompletionList<T extends TextDocumentIdentifier = any> extends CompletionList {
+ items: Array<AppCompletionItem<T>>;
+}
+
+export interface DiagnosticsProvider {
+ getDiagnostics(document: Document): Resolvable<Diagnostic[]>;
+}
+
+export interface HoverProvider {
+ doHover(document: Document, position: Position): Resolvable<Hover | null>;
+}
+
+export interface FoldingRangeProvider {
+ getFoldingRanges(document: Document): Resolvable<FoldingRange[]|null>;
+}
+
+export interface CompletionsProvider<T extends TextDocumentIdentifier = any> {
+ getCompletions(
+ document: Document,
+ position: Position,
+ completionContext?: CompletionContext
+ ): Resolvable<AppCompletionList<T> | null>;
+
+ resolveCompletion?(
+ document: Document,
+ completionItem: AppCompletionItem<T>
+ ): Resolvable<AppCompletionItem<T>>;
+}
+
+export interface FormattingProvider {
+ formatDocument(document: Document, options: FormattingOptions): Resolvable<TextEdit[]>;
+}
+
+export interface TagCompleteProvider {
+ doTagComplete(document: Document, position: Position): Resolvable<string | null>;
+}
+
+export interface DocumentColorsProvider {
+ getDocumentColors(document: Document): Resolvable<ColorInformation[]>;
+}
+
+export interface ColorPresentationsProvider {
+ getColorPresentations(
+ document: Document,
+ range: Range,
+ color: Color
+ ): Resolvable<ColorPresentation[]>;
+}
+
+export interface DocumentSymbolsProvider {
+ getDocumentSymbols(document: Document): Resolvable<SymbolInformation[]>;
+}
+
+export interface DefinitionsProvider {
+ getDefinitions(document: Document, position: Position): Resolvable<DefinitionLink[]>;
+}
+
+export interface BackwardsCompatibleDefinitionsProvider {
+ getDefinitions(
+ document: Document,
+ position: Position
+ ): Resolvable<DefinitionLink[] | Location[]>;
+}
+
+export interface CodeActionsProvider {
+ getCodeActions(
+ document: Document,
+ range: Range,
+ context: CodeActionContext
+ ): Resolvable<CodeAction[]>;
+ executeCommand?(
+ document: Document,
+ command: string,
+ args?: any[]
+ ): Resolvable<WorkspaceEdit | string | null>;
+}
+
+export interface FileRename {
+ oldUri: string;
+ newUri: string;
+}
+
+export interface UpdateImportsProvider {
+ updateImports(fileRename: FileRename): Resolvable<WorkspaceEdit | null>;
+}
+
+export interface RenameProvider {
+ rename(
+ document: Document,
+ position: Position,
+ newName: string
+ ): Resolvable<WorkspaceEdit | null>;
+ prepareRename(document: Document, position: Position): Resolvable<Range | null>;
+}
+
+export interface FindReferencesProvider {
+ findReferences(
+ document: Document,
+ position: Position,
+ context: ReferenceContext
+ ): Promise<Location[] | null>;
+}
+
+export interface SignatureHelpProvider {
+ getSignatureHelp(
+ document: Document,
+ position: Position,
+ context: SignatureHelpContext | undefined
+ ): Resolvable<SignatureHelp | null>;
+}
+
+export interface SelectionRangeProvider {
+ getSelectionRange(document: Document, position: Position): Resolvable<SelectionRange | null>;
+}
+
+export interface SemanticTokensProvider {
+ getSemanticTokens(textDocument: Document, range?: Range): Resolvable<SemanticTokens | null>;
+}
+
+export interface LinkedEditingRangesProvider {
+ getLinkedEditingRanges(
+ document: Document,
+ position: Position
+ ): Resolvable<LinkedEditingRanges | null>;
+}
+
+export interface OnWatchFileChangesPara {
+ fileName: string;
+ changeType: FileChangeType;
+}
+
+export interface OnWatchFileChanges {
+ onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void;
+}
+
+export interface UpdateTsOrJsFile {
+ updateTsOrJsFile(fileName: string, changes: TextDocumentContentChangeEvent[]): void;
+}
+
+type ProviderBase = DiagnosticsProvider &
+ HoverProvider &
+ CompletionsProvider &
+ FormattingProvider &
+ FoldingRangeProvider &
+ TagCompleteProvider &
+ DocumentColorsProvider &
+ ColorPresentationsProvider &
+ DocumentSymbolsProvider &
+ UpdateImportsProvider &
+ CodeActionsProvider &
+ FindReferencesProvider &
+ RenameProvider &
+ SignatureHelpProvider &
+ SemanticTokensProvider &
+ LinkedEditingRangesProvider;
+
+export type LSProvider = ProviderBase & BackwardsCompatibleDefinitionsProvider;
+
+export interface LSPProviderConfig {
+ /**
+ * Whether or not completion lists that are marked as imcomplete
+ * should be filtered server side.
+ */
+ filterIncompleteCompletions: boolean;
+ /**
+ * Whether or not getDefinitions supports the LocationLink interface.
+ */
+ definitionLinkSupport: boolean;
+}
+
+export type Plugin = Partial<
+ ProviderBase &
+ DefinitionsProvider &
+ OnWatchFileChanges &
+ SelectionRangeProvider &
+ UpdateTsOrJsFile
+>;
diff --git a/tools/vscode/packages/server/src/plugins/typescript/LanguageServiceManager.ts b/tools/vscode/packages/server/src/plugins/typescript/LanguageServiceManager.ts
new file mode 100644
index 000000000..60dec606c
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/typescript/LanguageServiceManager.ts
@@ -0,0 +1,82 @@
+import * as ts from 'typescript';
+import type { Document, DocumentManager } from '../../core/documents';
+import type { ConfigManager } from '../../core/config';
+import { urlToPath, pathToUrl, debounceSameArg } from '../../utils';
+import { getLanguageService, getLanguageServiceForDocument, LanguageServiceContainer, LanguageServiceDocumentContext } from './languageService';
+import { DocumentSnapshot, SnapshotManager } from './SnapshotManager';
+
+export class LanguageServiceManager {
+ private readonly docManager: DocumentManager;
+ private readonly configManager: ConfigManager;
+ private readonly workspaceUris: string[];
+ private docContext: LanguageServiceDocumentContext;
+
+ constructor(docManager: DocumentManager, configManager: ConfigManager, workspaceUris: string[]) {
+ this.docManager = docManager;
+ this.configManager = configManager;
+ this.workspaceUris = workspaceUris;
+ this.docContext = {
+ getWorkspaceRoot: (fileName: string) => this.getWorkspaceRoot(fileName),
+ createDocument: this.createDocument,
+ };
+
+ const handleDocumentChange = (document: Document) => {
+ // This refreshes the document in the ts language service
+ this.getTypeScriptDoc(document);
+ };
+
+ docManager.on(
+ 'documentChange',
+ debounceSameArg(handleDocumentChange, (newDoc, prevDoc) => newDoc.uri === prevDoc?.uri, 1000)
+ );
+ docManager.on('documentOpen', handleDocumentChange);
+ }
+
+ private getWorkspaceRoot(fileName: string) {
+ if (this.workspaceUris.length === 1) return urlToPath(this.workspaceUris[0]) as string;
+ return this.workspaceUris.reduce((found, curr) => {
+ const url = urlToPath(curr) as string;
+ if (fileName.startsWith(url) && curr.length < url.length) return url;
+ return found;
+ }, '')
+ }
+
+ private createDocument = (fileName: string, content: string) => {
+ const uri = pathToUrl(fileName);
+ const document = this.docManager.openDocument({
+ languageId: 'astro',
+ version: 0,
+ text: content,
+ uri,
+ });
+ return document;
+ };
+
+ async getSnapshot(document: Document): Promise<DocumentSnapshot>;
+ async getSnapshot(pathOrDoc: string | Document): Promise<DocumentSnapshot>;
+ async getSnapshot(pathOrDoc: string | Document) {
+ const filePath = typeof pathOrDoc === 'string' ? pathOrDoc : pathOrDoc.getFilePath() || '';
+ const tsService = await this.getTypeScriptLanguageService(filePath);
+ return tsService.updateDocument(pathOrDoc);
+ }
+
+ async getTypeScriptDoc(
+ document: Document
+ ): Promise<{
+ tsDoc: DocumentSnapshot;
+ lang: ts.LanguageService;
+ }> {
+ const lang = await getLanguageServiceForDocument(document, this.workspaceUris, this.docContext);
+ const tsDoc = await this.getSnapshot(document);
+
+ return { tsDoc, lang };
+ }
+
+ async getSnapshotManager(filePath: string): Promise<SnapshotManager> {
+ return (await this.getTypeScriptLanguageService(filePath)).snapshotManager;
+ }
+
+ private getTypeScriptLanguageService(filePath: string): Promise<LanguageServiceContainer> {
+ return getLanguageService(filePath, this.workspaceUris, this.docContext);
+ }
+}
diff --git a/tools/vscode/packages/server/src/plugins/typescript/SnapshotManager.ts b/tools/vscode/packages/server/src/plugins/typescript/SnapshotManager.ts
new file mode 100644
index 000000000..aac26d96e
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/typescript/SnapshotManager.ts
@@ -0,0 +1,333 @@
+import * as ts from 'typescript';
+import { TextDocumentContentChangeEvent, Position } from 'vscode-languageserver';
+import { Document } from '../../core/documents';
+import { positionAt, offsetAt } from '../../core/documents/utils';
+import { pathToUrl } from '../../utils';
+import { getScriptKindFromFileName, isAstroFilePath, toVirtualAstroFilePath } from './utils';
+
+export interface TsFilesSpec {
+ include?: readonly string[];
+ exclude?: readonly string[];
+}
+
+export class SnapshotManager {
+ private documents: Map<string, DocumentSnapshot> = new Map();
+ private lastLogged = new Date(new Date().getTime() - 60_001);
+
+ private readonly watchExtensions = [
+ ts.Extension.Dts,
+ ts.Extension.Js,
+ ts.Extension.Jsx,
+ ts.Extension.Ts,
+ ts.Extension.Tsx,
+ ts.Extension.Json
+ ];
+
+ constructor(
+ private projectFiles: string[],
+ private fileSpec: TsFilesSpec,
+ private workspaceRoot: string
+ ) {
+
+ }
+
+ updateProjectFiles() {
+ const { include, exclude } = this.fileSpec;
+
+ if (include?.length === 0) return;
+
+ const projectFiles = ts.sys.readDirectory(
+ this.workspaceRoot,
+ this.watchExtensions,
+ exclude,
+ include
+ );
+
+ this.projectFiles = Array.from(new Set([...this.projectFiles, ...projectFiles]));
+ }
+
+ updateProjectFile(fileName: string, changes?: TextDocumentContentChangeEvent[]): void {
+ const previousSnapshot = this.get(fileName);
+
+ if (changes) {
+ if (!(previousSnapshot instanceof TypeScriptDocumentSnapshot)) {
+ return;
+ }
+ previousSnapshot.update(changes);
+ } else {
+ const newSnapshot = createDocumentSnapshot(fileName);
+
+ if (previousSnapshot) {
+ newSnapshot.version = previousSnapshot.version + 1;
+ } else {
+ // ensure it's greater than initial version
+ // so that ts server picks up the change
+ newSnapshot.version += 1;
+ }
+ this.set(fileName, newSnapshot);
+ }
+ }
+
+ has(fileName: string) {
+ return this.projectFiles.includes(fileName) || this.getFileNames().includes(fileName);
+ }
+
+ get(fileName: string) {
+ return this.documents.get(fileName);
+ }
+
+ set(fileName: string, snapshot: DocumentSnapshot) {
+ // const prev = this.get(fileName);
+ this.logStatistics();
+ return this.documents.set(fileName, snapshot);
+ }
+
+ delete(fileName: string) {
+ this.projectFiles = this.projectFiles.filter((s) => s !== fileName);
+ return this.documents.delete(fileName);
+ }
+
+ getFileNames() {
+ return Array.from(this.documents.keys()).map(fileName => toVirtualAstroFilePath(fileName));
+ }
+
+ getProjectFileNames() {
+ return [...this.projectFiles];
+ }
+
+ private logStatistics() {
+ const date = new Date();
+ // Don't use setInterval because that will keep tests running forever
+ if (date.getTime() - this.lastLogged.getTime() > 60_000) {
+ this.lastLogged = date;
+
+ const projectFiles = this.getProjectFileNames();
+ const allFiles = Array.from(new Set([...projectFiles, ...this.getFileNames()]));
+ console.log(
+ 'SnapshotManager File Statistics:\n' +
+ `Project files: ${projectFiles.length}\n` +
+ `Astro files: ${
+ allFiles.filter((name) => name.endsWith('.astro')).length
+ }\n` +
+ `From node_modules: ${
+ allFiles.filter((name) => name.includes('node_modules')).length
+ }\n` +
+ `Total: ${allFiles.length}`
+ );
+ }
+ }
+}
+
+export interface DocumentSnapshot extends ts.IScriptSnapshot {
+ version: number;
+ filePath: string;
+ scriptKind: ts.ScriptKind;
+ positionAt(offset: number): Position;
+ /**
+ * Instantiates a source mapper.
+ * `destroyFragment` needs to be called when
+ * it's no longer needed / the class should be cleaned up
+ * in order to prevent memory leaks.
+ */
+ getFragment(): Promise<DocumentFragmentSnapshot>;
+ /**
+ * Needs to be called when source mapper
+ * is no longer needed / the class should be cleaned up
+ * in order to prevent memory leaks.
+ */
+ destroyFragment(): void;
+ /**
+ * Convenience function for getText(0, getLength())
+ */
+ getFullText(): string;
+}
+
+export const createDocumentSnapshot = (filePath: string, createDocument?: (_filePath: string, text: string) => Document): DocumentSnapshot => {
+ const text = ts.sys.readFile(filePath) ?? '';
+
+ if (isAstroFilePath(filePath)) {
+ if (!createDocument) throw new Error('Astro documents require the "createDocument" utility to be provided');
+ const snapshot = new AstroDocumentSnapshot(createDocument(filePath, text));
+ return snapshot;
+ }
+
+ return new TypeScriptDocumentSnapshot(0, filePath, text);
+
+}
+
+class AstroDocumentSnapshot implements DocumentSnapshot {
+
+ version = this.doc.version;
+ scriptKind = ts.ScriptKind.Unknown;
+
+ constructor(private doc: Document) {}
+
+ async getFragment(): Promise<DocumentFragmentSnapshot> {
+ return new DocumentFragmentSnapshot(this.doc);
+ }
+
+ async destroyFragment() {
+ return;
+ }
+
+ get text() {
+ return this.doc.getText();
+ }
+
+ get filePath() {
+ return this.doc.getFilePath() || '';
+ }
+
+ getText(start: number, end: number) {
+ return this.text.substring(start, end);
+ }
+
+ getLength() {
+ return this.text.length;
+ }
+
+ getFullText() {
+ return this.text;
+ }
+
+ getChangeRange() {
+ return undefined;
+ }
+
+ positionAt(offset: number) {
+ return positionAt(offset, this.text);
+ }
+
+ getLineContainingOffset(offset: number) {
+ const chunks = this.getText(0, offset).split('\n');
+ return chunks[chunks.length - 1];
+ }
+
+ offsetAt(position: Position) {
+ return offsetAt(position, this.text);
+ }
+
+}
+
+class DocumentFragmentSnapshot implements Omit<DocumentSnapshot, 'getFragment'|'destroyFragment'> {
+
+ version: number;
+ filePath: string;
+ url: string;
+ text: string;
+
+ scriptKind = ts.ScriptKind.TSX;
+ scriptInfo = null;
+
+ constructor(
+ private doc: Document
+ ) {
+ const filePath = doc.getFilePath();
+ if (!filePath) throw new Error('Cannot create a document fragment from a non-local document');
+ const text = doc.getText();
+ this.version = doc.version;
+ this.filePath = toVirtualAstroFilePath(filePath);
+ this.url = toVirtualAstroFilePath(filePath);
+ this.text = this.transformContent(text);
+ }
+
+ /** @internal */
+ private transformContent(content: string) {
+ return content.replace(/---/g, '///');
+ }
+
+ getText(start: number, end: number) {
+ return this.text.substring(start, end);
+ }
+
+ getLength() {
+ return this.text.length;
+ }
+
+ getFullText() {
+ return this.text;
+ }
+
+ getChangeRange() {
+ return undefined;
+ }
+
+ positionAt(offset: number) {
+ return positionAt(offset, this.text);
+ }
+
+ getLineContainingOffset(offset: number) {
+ const chunks = this.getText(0, offset).split('\n');
+ return chunks[chunks.length - 1];
+ }
+
+ offsetAt(position: Position): number {
+ return offsetAt(position, this.text);
+ }
+}
+
+class TypeScriptDocumentSnapshot implements DocumentSnapshot {
+
+ scriptKind = getScriptKindFromFileName(this.filePath);
+ scriptInfo = null;
+ url: string;
+
+
+ constructor(public version: number, public readonly filePath: string, private text: string) {
+ this.url = pathToUrl(filePath)
+ }
+
+ getText(start: number, end: number) {
+ return this.text.substring(start, end);
+ }
+
+ getLength() {
+ return this.text.length;
+ }
+
+ getFullText() {
+ return this.text;
+ }
+
+ getChangeRange() {
+ return undefined;
+ }
+
+ positionAt(offset: number) {
+ return positionAt(offset, this.text);
+ }
+
+ offsetAt(position: Position): number {
+ return offsetAt(position, this.text);
+ }
+
+ async getFragment(): Promise<DocumentFragmentSnapshot> {
+ return this as unknown as any;
+ }
+
+ destroyFragment() {
+ // nothing to clean up
+ }
+
+ getLineContainingOffset(offset: number) {
+ const chunks = this.getText(0, offset).split('\n');
+ return chunks[chunks.length - 1];
+ }
+
+ 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);
+ } else {
+ end = this.getLength();
+ }
+
+ this.text = this.text.slice(0, start) + change.text + this.text.slice(end);
+ }
+
+ this.version++;
+ }
+}
diff --git a/tools/vscode/packages/server/src/plugins/typescript/TypeScriptPlugin.ts b/tools/vscode/packages/server/src/plugins/typescript/TypeScriptPlugin.ts
new file mode 100644
index 000000000..018e8bfda
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/typescript/TypeScriptPlugin.ts
@@ -0,0 +1,89 @@
+import type { Document, DocumentManager } from '../../core/documents';
+import type { ConfigManager } from '../../core/config';
+import type { CompletionsProvider, AppCompletionItem, AppCompletionList } from '../interfaces';
+import {
+ CompletionContext,
+ Position,
+ FileChangeType
+} from 'vscode-languageserver';
+import * as ts from 'typescript';
+import { CompletionsProviderImpl, CompletionEntryWithIdentifer } from './features/CompletionsProvider';
+import { LanguageServiceManager } from './LanguageServiceManager';
+import { SnapshotManager } from './SnapshotManager';
+import { getScriptKindFromFileName } from './utils';
+
+export class TypeScriptPlugin implements CompletionsProvider {
+ private readonly docManager: DocumentManager;
+ private readonly configManager: ConfigManager;
+ private readonly languageServiceManager: LanguageServiceManager;
+
+ private readonly completionProvider: CompletionsProviderImpl;
+
+ constructor(
+ docManager: DocumentManager,
+ configManager: ConfigManager,
+ workspaceUris: string[]
+ ) {
+ this.docManager = docManager;
+ this.configManager = configManager;
+ this.languageServiceManager = new LanguageServiceManager(docManager, configManager, workspaceUris);
+
+ this.completionProvider = new CompletionsProviderImpl(this.languageServiceManager);
+ }
+
+ async getCompletions(
+ document: Document,
+ position: Position,
+ completionContext?: CompletionContext
+ ): Promise<AppCompletionList<CompletionEntryWithIdentifer> | null> {
+ const completions = await this.completionProvider.getCompletions(
+ document,
+ position,
+ completionContext
+ );
+
+ return completions;
+ }
+
+ async resolveCompletion(
+ document: Document,
+ completionItem: AppCompletionItem<CompletionEntryWithIdentifer>
+ ): Promise<AppCompletionItem<CompletionEntryWithIdentifer>> {
+ return this.completionProvider.resolveCompletion(document, completionItem);
+ }
+
+ async onWatchFileChanges(onWatchFileChangesParams: any[]): Promise<void> {
+ const doneUpdateProjectFiles = new Set<SnapshotManager>();
+
+ for (const { fileName, changeType } of onWatchFileChangesParams) {
+ const scriptKind = getScriptKindFromFileName(fileName);
+
+ if (scriptKind === ts.ScriptKind.Unknown) {
+ // We don't deal with svelte files here
+ continue;
+ }
+
+ const snapshotManager = await this.getSnapshotManager(fileName);
+ if (changeType === FileChangeType.Created) {
+ if (!doneUpdateProjectFiles.has(snapshotManager)) {
+ snapshotManager.updateProjectFiles();
+ doneUpdateProjectFiles.add(snapshotManager);
+ }
+ } else if (changeType === FileChangeType.Deleted) {
+ snapshotManager.delete(fileName);
+ return;
+ }
+
+ snapshotManager.updateProjectFile(fileName);
+ }
+ }
+
+ /**
+ *
+ * @internal
+ */
+ public async getSnapshotManager(fileName: string) {
+ return this.languageServiceManager.getSnapshotManager(fileName);
+ }
+}
+
diff --git a/tools/vscode/packages/server/src/plugins/typescript/astro-sys.ts b/tools/vscode/packages/server/src/plugins/typescript/astro-sys.ts
new file mode 100644
index 000000000..0459528c5
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/typescript/astro-sys.ts
@@ -0,0 +1,42 @@
+import * as ts from 'typescript';
+import { DocumentSnapshot } from './SnapshotManager';
+import { ensureRealAstroFilePath, isAstroFilePath, isVirtualAstroFilePath, toRealAstroFilePath } from './utils';
+
+/**
+ * This should only be accessed by TS Astro module resolution.
+ */
+export function createAstroSys(getSnapshot: (fileName: string) => DocumentSnapshot) {
+ const AstroSys: ts.System = {
+ ...ts.sys,
+ fileExists(path: string) {
+ if (isAstroFilePath(path) || isVirtualAstroFilePath(path)) {
+ console.log('fileExists', path, ts.sys.fileExists(ensureRealAstroFilePath(path)));
+ }
+ return ts.sys.fileExists(ensureRealAstroFilePath(path));
+ },
+ readFile(path: string) {
+ if (isAstroFilePath(path) || isVirtualAstroFilePath(path)) {
+ console.log('readFile', path);
+ }
+ const snapshot = getSnapshot(path);
+ return snapshot.getFullText();
+ },
+ readDirectory(path, extensions, exclude, include, depth) {
+ const extensionsWithAstro = (extensions ?? []).concat(...['.astro']);
+ const result = ts.sys.readDirectory(path, extensionsWithAstro, exclude, include, depth);;
+ return result;
+ }
+ };
+
+ if (ts.sys.realpath) {
+ const realpath = ts.sys.realpath;
+ AstroSys.realpath = function (path) {
+ if (isVirtualAstroFilePath(path)) {
+ return realpath(toRealAstroFilePath(path)) + '.ts';
+ }
+ return realpath(path);
+ };
+ }
+
+ return AstroSys;
+}
diff --git a/tools/vscode/packages/server/src/plugins/typescript/features/CompletionsProvider.ts b/tools/vscode/packages/server/src/plugins/typescript/features/CompletionsProvider.ts
new file mode 100644
index 000000000..ebbc16e31
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/typescript/features/CompletionsProvider.ts
@@ -0,0 +1,123 @@
+import { isInsideFrontmatter } from '../../../core/documents/utils';
+import { Document } from '../../../core/documents';
+import * as ts from 'typescript';
+import { CompletionContext, CompletionList, CompletionItem, Position, TextDocumentIdentifier, TextEdit, MarkupKind, MarkupContent } from 'vscode-languageserver';
+import { AppCompletionItem, AppCompletionList, CompletionsProvider } from '../../interfaces';
+import type { LanguageServiceManager } from '../LanguageServiceManager';
+import { scriptElementKindToCompletionItemKind, getCommitCharactersForScriptElement } from '../utils';
+
+export interface CompletionEntryWithIdentifer extends ts.CompletionEntry, TextDocumentIdentifier {
+ position: Position;
+}
+
+export class CompletionsProviderImpl implements CompletionsProvider<CompletionEntryWithIdentifer> {
+ constructor(private lang: LanguageServiceManager) {}
+
+ async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise<AppCompletionList<CompletionEntryWithIdentifer> | null> {
+ // TODO: handle inside expression
+ if (!isInsideFrontmatter(document.getText(), document.offsetAt(position))) {
+ return null;
+ }
+
+ const filePath = document.getFilePath();
+ if (!filePath) throw new Error();
+
+ const { tsDoc, lang } = await this.lang.getTypeScriptDoc(document);
+ const fragment = await tsDoc.getFragment();
+
+ const { entries } = lang.getCompletionsAtPosition(fragment.filePath, document.offsetAt(position), {}) ?? { entries: [] };
+ const completionItems = entries
+ .map((entry: ts.CompletionEntry) => this.toCompletionItem(fragment, entry, document.uri, position, new Set()))
+ .filter((i) => i) as CompletionItem[];
+
+ return CompletionList.create(completionItems, true);
+ }
+
+ async resolveCompletion(document: Document, completionItem: AppCompletionItem<CompletionEntryWithIdentifer>): Promise<AppCompletionItem<CompletionEntryWithIdentifer>> {
+ const { data: comp } = completionItem;
+ const { tsDoc, lang } = await this.lang.getTypeScriptDoc(document);
+
+ const filePath = tsDoc.filePath;
+
+ if (!comp || !filePath) {
+ return completionItem;
+ }
+
+ const fragment = await tsDoc.getFragment();
+ const detail = lang.getCompletionEntryDetails(filePath, fragment.offsetAt(comp.position), comp.name, {}, comp.source, {});
+
+ if (detail) {
+ const { detail: itemDetail, documentation: itemDocumentation } = this.getCompletionDocument(detail);
+
+ completionItem.detail = itemDetail;
+ completionItem.documentation = itemDocumentation;
+ }
+
+ // const actions = detail?.codeActions;
+ // const isImport = !!detail?.source;
+
+ // TODO: handle actions
+ // if (actions) {
+ // const edit: TextEdit[] = [];
+
+ // for (const action of actions) {
+ // for (const change of action.changes) {
+ // edit.push(
+ // ...this.codeActionChangesToTextEdit(
+ // document,
+ // fragment,
+ // change,
+ // isImport,
+ // isInsideFrontmatter(fragment.getFullText(), fragment.offsetAt(comp.position))
+ // )
+ // );
+ // }
+ // }
+
+ // completionItem.additionalTextEdits = edit;
+ // }
+
+ return completionItem;
+ }
+
+ private toCompletionItem(
+ fragment: any,
+ comp: ts.CompletionEntry,
+ uri: string,
+ position: Position,
+ existingImports: Set<string>
+ ): AppCompletionItem<CompletionEntryWithIdentifer> | null {
+ return {
+ label: comp.name,
+ insertText: comp.insertText,
+ kind: scriptElementKindToCompletionItemKind(comp.kind),
+ commitCharacters: getCommitCharactersForScriptElement(comp.kind),
+ // Make sure svelte component takes precedence
+ sortText: comp.sortText,
+ preselect: comp.isRecommended,
+ // pass essential data for resolving completion
+ data: {
+ ...comp,
+ uri,
+ position
+ },
+ };
+ }
+
+ private getCompletionDocument(compDetail: ts.CompletionEntryDetails) {
+ const { source, documentation: tsDocumentation, displayParts, tags } = compDetail;
+ let detail: string = ts.displayPartsToString(displayParts);
+
+ if (source) {
+ const importPath = ts.displayPartsToString(source);
+ detail = `Auto import from ${importPath}\n${detail}`;
+ }
+
+ const documentation: MarkupContent | undefined = tsDocumentation ? { value: tsDocumentation.join('\n'), kind: MarkupKind.Markdown } : undefined;
+
+ return {
+ documentation,
+ detail,
+ };
+ }
+}
diff --git a/tools/vscode/packages/server/src/plugins/typescript/languageService.ts b/tools/vscode/packages/server/src/plugins/typescript/languageService.ts
new file mode 100644
index 000000000..4de703b2a
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/typescript/languageService.ts
@@ -0,0 +1,179 @@
+/* eslint-disable require-jsdoc */
+
+import * as ts from 'typescript';
+import { basename } from 'path';
+import { ensureRealAstroFilePath, findTsConfigPath, isAstroFilePath, toVirtualAstroFilePath } from './utils';
+import { Document } from '../../core/documents';
+import { createDocumentSnapshot, SnapshotManager, DocumentSnapshot } from './SnapshotManager';
+import { createAstroSys } from './astro-sys';
+
+const services = new Map<string, Promise<LanguageServiceContainer>>();
+
+export interface LanguageServiceContainer {
+ readonly tsconfigPath: string;
+ readonly snapshotManager: SnapshotManager;
+ getService(): ts.LanguageService;
+ updateDocument(documentOrFilePath: Document | string): ts.IScriptSnapshot;
+ deleteDocument(filePath: string): void;
+}
+
+export interface LanguageServiceDocumentContext {
+ getWorkspaceRoot(fileName: string): string;
+ createDocument: (fileName: string, content: string) => Document;
+}
+
+export async function getLanguageService(path: string, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise<LanguageServiceContainer> {
+ const tsconfigPath = findTsConfigPath(path, workspaceUris);
+ const workspaceRoot = docContext.getWorkspaceRoot(path);
+
+ let service: LanguageServiceContainer;
+ if (services.has(tsconfigPath)) {
+ service = (await services.get(tsconfigPath)) as LanguageServiceContainer;
+ } else {
+ const newService = createLanguageService(tsconfigPath, workspaceRoot, docContext);
+ services.set(tsconfigPath, newService);
+ service = await newService;
+ }
+
+ return service;
+}
+
+export async function getLanguageServiceForDocument(document: Document, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise<ts.LanguageService> {
+ return getLanguageServiceForPath(document.getFilePath() || '', workspaceUris, docContext);
+}
+
+export async function getLanguageServiceForPath(path: string, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise<ts.LanguageService> {
+ return (await getLanguageService(path, workspaceUris, docContext)).getService();
+}
+
+async function createLanguageService(tsconfigPath: string, workspaceRoot: string, docContext: LanguageServiceDocumentContext): Promise<LanguageServiceContainer> {
+ const parseConfigHost: ts.ParseConfigHost = {
+ ...ts.sys,
+ readDirectory: (path, extensions, exclude, include, depth) => {
+ return ts.sys.readDirectory(path, [...extensions, '.vue', '.svelte', '.astro', '.js', '.jsx'], exclude, include, depth);
+ },
+ };
+
+ let configJson = (tsconfigPath && ts.readConfigFile(tsconfigPath, ts.sys.readFile).config) || getDefaultJsConfig();
+ if (!configJson.extends) {
+ configJson = Object.assign(
+ {
+ exclude: getDefaultExclude()
+ },
+ configJson
+ );
+ }
+
+ const project = ts.parseJsonConfigFileContent(
+ configJson,
+ parseConfigHost,
+ workspaceRoot,
+ {},
+ basename(tsconfigPath),
+ undefined,
+ [
+ { extension: '.vue', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred },
+ { extension: '.svelte', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred },
+ { extension: '.astro', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }
+ ]
+ );
+
+ let projectVersion = 0;
+ const snapshotManager = new SnapshotManager(project.fileNames, { exclude: ['node_modules', 'dist'], include: ['astro'] }, workspaceRoot || process.cwd());
+ const astroSys = createAstroSys(updateDocument);
+
+ const host: ts.LanguageServiceHost = {
+ getNewLine: () => ts.sys.newLine,
+ useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
+ readFile: astroSys.readFile,
+ writeFile: astroSys.writeFile,
+ fileExists: astroSys.fileExists,
+ directoryExists: astroSys.directoryExists,
+ getDirectories: astroSys.getDirectories,
+ readDirectory: astroSys.readDirectory,
+ realpath: astroSys.realpath,
+
+ getCompilationSettings: () => project.options,
+ getCurrentDirectory: () => workspaceRoot,
+ getDefaultLibFileName: () => ts.getDefaultLibFilePath(project.options),
+
+ getProjectVersion: () => `${projectVersion}`,
+ getScriptFileNames: () => Array.from(new Set([...snapshotManager.getFileNames(), ...snapshotManager.getProjectFileNames()])),
+ getScriptSnapshot,
+ getScriptVersion: (fileName: string) => getScriptSnapshot(fileName).version.toString()
+ };
+
+ const languageService = ts.createLanguageService(host);
+ const languageServiceProxy = new Proxy(languageService, {
+ get(target, prop) {
+ return Reflect.get(target, prop);
+ }
+ })
+
+ return {
+ tsconfigPath,
+ snapshotManager,
+ getService: () => languageServiceProxy,
+ updateDocument,
+ deleteDocument,
+ };
+
+ function deleteDocument(filePath: string) {
+ snapshotManager.delete(filePath);
+ }
+
+ function updateDocument(documentOrFilePath: Document | string) {
+ const filePath = ensureRealAstroFilePath(typeof documentOrFilePath === 'string' ? documentOrFilePath : documentOrFilePath.getFilePath() || '');
+ const document = typeof documentOrFilePath === 'string' ? undefined : documentOrFilePath;
+
+ if (!filePath) {
+ throw new Error(`Unable to find document`);
+ }
+
+ const previousSnapshot = snapshotManager.get(filePath);
+ if (document && previousSnapshot?.version.toString() === `${document.version}`) {
+ return previousSnapshot;
+ }
+
+ const snapshot = createDocumentSnapshot(filePath, docContext.createDocument);
+ snapshotManager.set(filePath, snapshot);
+ return snapshot;
+ }
+
+ function getScriptSnapshot(fileName: string): DocumentSnapshot {
+ fileName = ensureRealAstroFilePath(fileName);
+
+ let doc = snapshotManager.get(fileName);
+ if (doc) {
+ return doc;
+ }
+
+ doc = createDocumentSnapshot(
+ fileName,
+ docContext.createDocument,
+ );
+ snapshotManager.set(fileName, doc);
+ return doc;
+ }
+}
+
+/**
+ * This should only be used when there's no jsconfig/tsconfig at all
+ */
+function getDefaultJsConfig(): {
+ compilerOptions: ts.CompilerOptions;
+ include: string[];
+} {
+ return {
+ compilerOptions: {
+ maxNodeModuleJsDepth: 2,
+ allowSyntheticDefaultImports: true,
+ allowJs: true
+ },
+ include: ['astro'],
+ };
+}
+
+function getDefaultExclude() {
+ return ['dist', 'node_modules'];
+}
diff --git a/tools/vscode/packages/server/src/plugins/typescript/utils.ts b/tools/vscode/packages/server/src/plugins/typescript/utils.ts
new file mode 100644
index 000000000..058868474
--- /dev/null
+++ b/tools/vscode/packages/server/src/plugins/typescript/utils.ts
@@ -0,0 +1,182 @@
+import * as ts from 'typescript';
+import { CompletionItemKind, DiagnosticSeverity } from 'vscode-languageserver';
+import { dirname } from 'path';
+import { pathToUrl } from '../../utils';
+
+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;
+}
+
+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 isAstroFilePath(filePath: string) {
+ return filePath.endsWith('.astro');
+}
+
+export function isVirtualAstroFilePath(filePath: string) {
+ return filePath.endsWith('.astro.ts');
+}
+
+export function toVirtualAstroFilePath(filePath: string) {
+ return `${filePath}.ts`;
+}
+
+export function toRealAstroFilePath(filePath: string) {
+ return filePath.slice(0, -'.ts'.length);
+}
+
+export function ensureRealAstroFilePath(filePath: string) {
+ return isVirtualAstroFilePath(filePath) ? toRealAstroFilePath(filePath) : filePath;
+}
+
+export function findTsConfigPath(fileName: string, rootUris: string[]) {
+ const searchDir = dirname(fileName);
+ const path =
+ ts.findConfigFile(searchDir, ts.sys.fileExists, 'tsconfig.json') ||
+ ts.findConfigFile(searchDir, ts.sys.fileExists, 'jsconfig.json') ||
+ '';
+ // Don't return config files that exceed the current workspace context.
+ return !!path && rootUris.some((rootUri) => isSubPath(rootUri, path)) ? path : '';
+}
+
+/** */
+export function isSubPath(uri: string, possibleSubPath: string): boolean {
+ return pathToUrl(possibleSubPath).startsWith(uri);
+}
+
+
+/** Substitutes */
+export function substituteWithWhitespace(result: string, start: number, end: number, oldContent: string, before: string, after: string) {
+ let accumulatedWS = 0;
+ result += before;
+ for (let i = start + before.length; i < end; i++) {
+ let ch = oldContent[i];
+ if (ch === '\n' || ch === '\r') {
+ // only write new lines, skip the whitespace
+ accumulatedWS = 0;
+ result += ch;
+ } else {
+ accumulatedWS++;
+ }
+ }
+ result = append(result, ' ', accumulatedWS - after.length);
+ result += after;
+ return result;
+}
+
+function append(result: string, str: string, n: number): string {
+ while (n > 0) {
+ if (n & 1) {
+ result += str;
+ }
+ n >>= 1;
+ str += str;
+ }
+ return result;
+}
diff --git a/tools/vscode/packages/server/src/utils.ts b/tools/vscode/packages/server/src/utils.ts
new file mode 100644
index 000000000..c764aae13
--- /dev/null
+++ b/tools/vscode/packages/server/src/utils.ts
@@ -0,0 +1,98 @@
+import { URI } from 'vscode-uri';
+import { Position, Range } from 'vscode-languageserver';
+import { Node } from 'vscode-html-languageservice';
+
+/** Normalizes a document URI */
+export function normalizeUri(uri: string): string {
+ return URI.parse(uri).toString();
+}
+
+/** Turns a URL into a normalized FS Path */
+export function urlToPath(stringUrl: string): string | null {
+ const url = URI.parse(stringUrl);
+ if (url.scheme !== 'file') {
+ return null;
+ }
+ return url.fsPath.replace(/\\/g, '/');
+}
+
+/** Converts a path to a URL */
+export function pathToUrl(path: string) {
+ return URI.file(path).toString();
+}
+
+
+/**
+*
+* The language service is case insensitive, and would provide
+* hover info for Svelte components like `Option` which have
+* the same name like a html tag.
+*/
+export function isPossibleComponent(node: Node): boolean {
+ return !!node.tag?.[0].match(/[A-Z]/);
+}
+
+/**
+*
+* The language service is case insensitive, and would provide
+* hover info for Svelte components like `Option` which have
+* the same name like a html tag.
+*/
+export function isPossibleClientComponent(node: Node): boolean {
+ return isPossibleComponent(node) && (node.tag?.indexOf(':') ?? -1) > -1;
+}
+
+/** Flattens an array */
+export function flatten<T>(arr: T[][]): T[] {
+ return arr.reduce((all, item) => [...all, ...item], []);
+}
+
+/** Clamps a number between min and max */
+export function clamp(num: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, num));
+}
+
+/** Checks if a position is inside range */
+export function isInRange(positionToTest: Position, range: Range): boolean {
+ return (
+ isBeforeOrEqualToPosition(range.end, positionToTest) &&
+ isBeforeOrEqualToPosition(positionToTest, range.start)
+ );
+}
+
+/** */
+export function isBeforeOrEqualToPosition(position: Position, positionToTest: Position): boolean {
+ return (
+ positionToTest.line < position.line ||
+ (positionToTest.line === position.line && positionToTest.character <= position.character)
+ );
+}
+
+/**
+ * Debounces a function but cancels previous invocation only if
+ * a second function determines it should.
+ *
+ * @param fn The function with it's argument
+ * @param determineIfSame The function which determines if the previous invocation should be canceld or not
+ * @param miliseconds Number of miliseconds to debounce
+ */
+export function debounceSameArg<T>(
+ fn: (arg: T) => void,
+ shouldCancelPrevious: (newArg: T, prevArg?: T) => boolean,
+ miliseconds: number
+): (arg: T) => void {
+ let timeout: any;
+ let prevArg: T | undefined;
+
+ return (arg: T) => {
+ if (shouldCancelPrevious(arg, prevArg)) {
+ clearTimeout(timeout);
+ }
+
+ prevArg = arg;
+ timeout = setTimeout(() => {
+ fn(arg);
+ prevArg = undefined;
+ }, miliseconds);
+ };
+}