summaryrefslogtreecommitdiff
path: root/tools/astro-languageserver/src
diff options
context:
space:
mode:
Diffstat (limited to 'tools/astro-languageserver/src')
-rw-r--r--tools/astro-languageserver/src/core/config/ConfigManager.ts13
-rw-r--r--tools/astro-languageserver/src/core/config/index.ts1
-rw-r--r--tools/astro-languageserver/src/core/documents/Document.ts153
-rw-r--r--tools/astro-languageserver/src/core/documents/DocumentManager.ts94
-rw-r--r--tools/astro-languageserver/src/core/documents/index.ts2
-rw-r--r--tools/astro-languageserver/src/core/documents/parseAstro.ts77
-rw-r--r--tools/astro-languageserver/src/core/documents/parseHtml.ts141
-rw-r--r--tools/astro-languageserver/src/core/documents/utils.ts127
-rw-r--r--tools/astro-languageserver/src/index.ts106
-rw-r--r--tools/astro-languageserver/src/plugins/PluginHost.ts112
-rw-r--r--tools/astro-languageserver/src/plugins/astro/AstroPlugin.ts107
-rw-r--r--tools/astro-languageserver/src/plugins/html/HTMLPlugin.ts126
-rw-r--r--tools/astro-languageserver/src/plugins/index.ts5
-rw-r--r--tools/astro-languageserver/src/plugins/interfaces.ts167
-rw-r--r--tools/astro-languageserver/src/plugins/typescript/LanguageServiceManager.ts82
-rw-r--r--tools/astro-languageserver/src/plugins/typescript/SnapshotManager.ts303
-rw-r--r--tools/astro-languageserver/src/plugins/typescript/TypeScriptPlugin.ts69
-rw-r--r--tools/astro-languageserver/src/plugins/typescript/astro-sys.ts42
-rw-r--r--tools/astro-languageserver/src/plugins/typescript/features/CompletionsProvider.ts123
-rw-r--r--tools/astro-languageserver/src/plugins/typescript/languageService.ts168
-rw-r--r--tools/astro-languageserver/src/plugins/typescript/utils.ts174
-rw-r--r--tools/astro-languageserver/src/types/index.d.ts4
-rw-r--r--tools/astro-languageserver/src/utils.ts87
23 files changed, 2283 insertions, 0 deletions
diff --git a/tools/astro-languageserver/src/core/config/ConfigManager.ts b/tools/astro-languageserver/src/core/config/ConfigManager.ts
new file mode 100644
index 000000000..1e795ab96
--- /dev/null
+++ b/tools/astro-languageserver/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/astro-languageserver/src/core/config/index.ts b/tools/astro-languageserver/src/core/config/index.ts
new file mode 100644
index 000000000..cd869b795
--- /dev/null
+++ b/tools/astro-languageserver/src/core/config/index.ts
@@ -0,0 +1 @@
+export * from './ConfigManager';
diff --git a/tools/astro-languageserver/src/core/documents/Document.ts b/tools/astro-languageserver/src/core/documents/Document.ts
new file mode 100644
index 000000000..93217e891
--- /dev/null
+++ b/tools/astro-languageserver/src/core/documents/Document.ts
@@ -0,0 +1,153 @@
+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/astro-languageserver/src/core/documents/DocumentManager.ts b/tools/astro-languageserver/src/core/documents/DocumentManager.ts
new file mode 100644
index 000000000..7c9c168c1
--- /dev/null
+++ b/tools/astro-languageserver/src/core/documents/DocumentManager.ts
@@ -0,0 +1,94 @@
+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/astro-languageserver/src/core/documents/index.ts b/tools/astro-languageserver/src/core/documents/index.ts
new file mode 100644
index 000000000..708a040c9
--- /dev/null
+++ b/tools/astro-languageserver/src/core/documents/index.ts
@@ -0,0 +1,2 @@
+export * from './Document';
+export * from './DocumentManager';
diff --git a/tools/astro-languageserver/src/core/documents/parseAstro.ts b/tools/astro-languageserver/src/core/documents/parseAstro.ts
new file mode 100644
index 000000000..71c7764d8
--- /dev/null
+++ b/tools/astro-languageserver/src/core/documents/parseAstro.ts
@@ -0,0 +1,77 @@
+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/astro-languageserver/src/core/documents/parseHtml.ts b/tools/astro-languageserver/src/core/documents/parseHtml.ts
new file mode 100644
index 000000000..f5de5f292
--- /dev/null
+++ b/tools/astro-languageserver/src/core/documents/parseHtml.ts
@@ -0,0 +1,141 @@
+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/astro-languageserver/src/core/documents/utils.ts b/tools/astro-languageserver/src/core/documents/utils.ts
new file mode 100644
index 000000000..3d12f35a3
--- /dev/null
+++ b/tools/astro-languageserver/src/core/documents/utils.ts
@@ -0,0 +1,127 @@
+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/astro-languageserver/src/index.ts b/tools/astro-languageserver/src/index.ts
new file mode 100644
index 000000000..c834beaf9
--- /dev/null
+++ b/tools/astro-languageserver/src/index.ts
@@ -0,0 +1,106 @@
+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');
+
+/**
+ * Starts `astro-languageservice`
+ */
+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();
+}
diff --git a/tools/astro-languageserver/src/plugins/PluginHost.ts b/tools/astro-languageserver/src/plugins/PluginHost.ts
new file mode 100644
index 000000000..037dd6e07
--- /dev/null
+++ b/tools/astro-languageserver/src/plugins/PluginHost.ts
@@ -0,0 +1,112 @@
+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/astro-languageserver/src/plugins/astro/AstroPlugin.ts b/tools/astro-languageserver/src/plugins/astro/AstroPlugin.ts
new file mode 100644
index 000000000..6baf407a5
--- /dev/null
+++ b/tools/astro-languageserver/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/astro-languageserver/src/plugins/html/HTMLPlugin.ts b/tools/astro-languageserver/src/plugins/html/HTMLPlugin.ts
new file mode 100644
index 000000000..7e0ab4861
--- /dev/null
+++ b/tools/astro-languageserver/src/plugins/html/HTMLPlugin.ts
@@ -0,0 +1,126 @@
+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/astro-languageserver/src/plugins/index.ts b/tools/astro-languageserver/src/plugins/index.ts
new file mode 100644
index 000000000..c1b8a4062
--- /dev/null
+++ b/tools/astro-languageserver/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/astro-languageserver/src/plugins/interfaces.ts b/tools/astro-languageserver/src/plugins/interfaces.ts
new file mode 100644
index 000000000..b68100de1
--- /dev/null
+++ b/tools/astro-languageserver/src/plugins/interfaces.ts
@@ -0,0 +1,167 @@
+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/astro-languageserver/src/plugins/typescript/LanguageServiceManager.ts b/tools/astro-languageserver/src/plugins/typescript/LanguageServiceManager.ts
new file mode 100644
index 000000000..529ab2b4c
--- /dev/null
+++ b/tools/astro-languageserver/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/astro-languageserver/src/plugins/typescript/SnapshotManager.ts b/tools/astro-languageserver/src/plugins/typescript/SnapshotManager.ts
new file mode 100644
index 000000000..47d44838d
--- /dev/null
+++ b/tools/astro-languageserver/src/plugins/typescript/SnapshotManager.ts
@@ -0,0 +1,303 @@
+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/astro-languageserver/src/plugins/typescript/TypeScriptPlugin.ts b/tools/astro-languageserver/src/plugins/typescript/TypeScriptPlugin.ts
new file mode 100644
index 000000000..aab758bdb
--- /dev/null
+++ b/tools/astro-languageserver/src/plugins/typescript/TypeScriptPlugin.ts
@@ -0,0 +1,69 @@
+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/astro-languageserver/src/plugins/typescript/astro-sys.ts b/tools/astro-languageserver/src/plugins/typescript/astro-sys.ts
new file mode 100644
index 000000000..36d009eb6
--- /dev/null
+++ b/tools/astro-languageserver/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/astro-languageserver/src/plugins/typescript/features/CompletionsProvider.ts b/tools/astro-languageserver/src/plugins/typescript/features/CompletionsProvider.ts
new file mode 100644
index 000000000..348f3e4ae
--- /dev/null
+++ b/tools/astro-languageserver/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/astro-languageserver/src/plugins/typescript/languageService.ts b/tools/astro-languageserver/src/plugins/typescript/languageService.ts
new file mode 100644
index 000000000..098c335e7
--- /dev/null
+++ b/tools/astro-languageserver/src/plugins/typescript/languageService.ts
@@ -0,0 +1,168 @@
+/* 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/astro-languageserver/src/plugins/typescript/utils.ts b/tools/astro-languageserver/src/plugins/typescript/utils.ts
new file mode 100644
index 000000000..1f42e7d0a
--- /dev/null
+++ b/tools/astro-languageserver/src/plugins/typescript/utils.ts
@@ -0,0 +1,174 @@
+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/astro-languageserver/src/types/index.d.ts b/tools/astro-languageserver/src/types/index.d.ts
new file mode 100644
index 000000000..e048b1a0c
--- /dev/null
+++ b/tools/astro-languageserver/src/types/index.d.ts
@@ -0,0 +1,4 @@
+/**
+ * Starts `astro-languageservice`
+ */
+export function startServer(): void {}
diff --git a/tools/astro-languageserver/src/utils.ts b/tools/astro-languageserver/src/utils.ts
new file mode 100644
index 000000000..f9f1acf34
--- /dev/null
+++ b/tools/astro-languageserver/src/utils.ts
@@ -0,0 +1,87 @@
+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);
+ };
+}