summaryrefslogtreecommitdiff
path: root/tools/language-server/src
diff options
context:
space:
mode:
authorGravatar Nate Moore <natemoo-re@users.noreply.github.com> 2021-06-16 13:20:29 -0500
committerGravatar GitHub <noreply@github.com> 2021-06-16 13:20:29 -0500
commit0dd278810e4353799c7239463f156b358ea30871 (patch)
tree964a62a13467950509b446ebeaa9b668e5874665 /tools/language-server/src
parent382868abfc4b46a0c582ec4bf868719d4e87bbd2 (diff)
downloadastro-0dd278810e4353799c7239463f156b358ea30871.tar.gz
astro-0dd278810e4353799c7239463f156b358ea30871.tar.zst
astro-0dd278810e4353799c7239463f156b358ea30871.zip
Fix VS Code extension (#467)
* chore: astro-languageserver => @astrojs/language-server * chore: astro-vscode => vscode * chore: move devDeps to deps * chore: bump language-server to 0.5.0-next.0 * chore: remove astro-docs * chore: update changelog * fix: expose `astro-ls` bin * fix: vscode extension * chore: update changelog
Diffstat (limited to 'tools/language-server/src')
-rw-r--r--tools/language-server/src/core/config/ConfigManager.ts13
-rw-r--r--tools/language-server/src/core/config/index.ts1
-rw-r--r--tools/language-server/src/core/documents/Document.ts160
-rw-r--r--tools/language-server/src/core/documents/DocumentBase.ts141
-rw-r--r--tools/language-server/src/core/documents/DocumentManager.ts94
-rw-r--r--tools/language-server/src/core/documents/DocumentMapper.ts317
-rw-r--r--tools/language-server/src/core/documents/index.ts5
-rw-r--r--tools/language-server/src/core/documents/parseAstro.ts77
-rw-r--r--tools/language-server/src/core/documents/parseHtml.ts141
-rw-r--r--tools/language-server/src/core/documents/utils.ts250
-rw-r--r--tools/language-server/src/index.ts115
-rw-r--r--tools/language-server/src/plugins/PluginHost.ts139
-rw-r--r--tools/language-server/src/plugins/astro/AstroPlugin.ts199
-rw-r--r--tools/language-server/src/plugins/css/CSSDocument.ts95
-rw-r--r--tools/language-server/src/plugins/css/CSSPlugin.ts118
-rw-r--r--tools/language-server/src/plugins/css/StyleAttributeDocument.ts72
-rw-r--r--tools/language-server/src/plugins/css/features/getIdClassCompletion.ts67
-rw-r--r--tools/language-server/src/plugins/css/service.ts48
-rw-r--r--tools/language-server/src/plugins/html/HTMLPlugin.ts126
-rw-r--r--tools/language-server/src/plugins/index.ts6
-rw-r--r--tools/language-server/src/plugins/interfaces.ts167
-rw-r--r--tools/language-server/src/plugins/typescript/DocumentSnapshot.ts242
-rw-r--r--tools/language-server/src/plugins/typescript/LanguageServiceManager.ts81
-rw-r--r--tools/language-server/src/plugins/typescript/SnapshotManager.ts95
-rw-r--r--tools/language-server/src/plugins/typescript/TypeScriptPlugin.ts113
-rw-r--r--tools/language-server/src/plugins/typescript/astro-sys.ts42
-rw-r--r--tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts134
-rw-r--r--tools/language-server/src/plugins/typescript/features/utils.ts54
-rw-r--r--tools/language-server/src/plugins/typescript/languageService.ts184
-rw-r--r--tools/language-server/src/plugins/typescript/module-loader.ts110
-rw-r--r--tools/language-server/src/plugins/typescript/utils.ts236
-rw-r--r--tools/language-server/src/types/index.d.ts4
-rw-r--r--tools/language-server/src/utils.ts91
33 files changed, 3737 insertions, 0 deletions
diff --git a/tools/language-server/src/core/config/ConfigManager.ts b/tools/language-server/src/core/config/ConfigManager.ts
new file mode 100644
index 000000000..1e795ab96
--- /dev/null
+++ b/tools/language-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/language-server/src/core/config/index.ts b/tools/language-server/src/core/config/index.ts
new file mode 100644
index 000000000..cd869b795
--- /dev/null
+++ b/tools/language-server/src/core/config/index.ts
@@ -0,0 +1 @@
+export * from './ConfigManager';
diff --git a/tools/language-server/src/core/documents/Document.ts b/tools/language-server/src/core/documents/Document.ts
new file mode 100644
index 000000000..04a460a08
--- /dev/null
+++ b/tools/language-server/src/core/documents/Document.ts
@@ -0,0 +1,160 @@
+import type { TagInformation } from './utils';
+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';
+import { extractStyleTag } from './utils';
+
+export class Document implements TextDocument {
+ private content: string;
+
+ languageId = 'astro';
+ version = 0;
+ html!: HTMLDocument;
+ astro!: AstroDocument;
+ styleInfo: TagInformation | null = null;
+
+ constructor(public uri: string, text: string) {
+ this.content = text;
+ this.updateDocInfo();
+ }
+
+ private updateDocInfo() {
+ this.html = parseHtml(this.content);
+ this.astro = parseAstro(this.content);
+ this.styleInfo = extractStyleTag(this.content, this.html);
+ if (this.styleInfo) {
+ this.styleInfo.attributes.lang = 'css';
+ }
+ }
+
+ 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/language-server/src/core/documents/DocumentBase.ts b/tools/language-server/src/core/documents/DocumentBase.ts
new file mode 100644
index 000000000..299feeb62
--- /dev/null
+++ b/tools/language-server/src/core/documents/DocumentBase.ts
@@ -0,0 +1,141 @@
+import { clamp } from '../../utils';
+import { Position, TextDocument } from 'vscode-languageserver';
+
+/**
+ * Represents a textual document.
+ */
+export abstract class ReadableDocument implements TextDocument {
+ /**
+ * Get the text content of the document
+ */
+ abstract getText(): string;
+
+ /**
+ * Returns the url of the document
+ */
+ abstract getURL(): string;
+
+ /**
+ * Returns the file path if the url scheme is file
+ */
+ abstract getFilePath(): string | null;
+
+ /**
+ * Current version of the document.
+ */
+ public version = 0;
+
+ /**
+ * Get the length of the document's content
+ */
+ getTextLength(): number {
+ return this.getText().length;
+ }
+
+ /**
+ * 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);
+ }
+
+ 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;
+ }
+
+ /**
+ * Implements TextDocument
+ */
+ get uri(): string {
+ return this.getURL();
+ }
+
+ get lineCount(): number {
+ return this.getText().split(/\r?\n/).length;
+ }
+
+ abstract languageId: string;
+}
+
+/**
+ * Represents a textual document that can be manipulated.
+ */
+export abstract class WritableDocument extends ReadableDocument {
+ /**
+ * Set the text content of the document
+ * @param text The new text content
+ */
+ abstract setText(text: string): void;
+
+ /**
+ * 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));
+ }
+}
diff --git a/tools/language-server/src/core/documents/DocumentManager.ts b/tools/language-server/src/core/documents/DocumentManager.ts
new file mode 100644
index 000000000..7c9c168c1
--- /dev/null
+++ b/tools/language-server/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/language-server/src/core/documents/DocumentMapper.ts b/tools/language-server/src/core/documents/DocumentMapper.ts
new file mode 100644
index 000000000..8a6a6ef29
--- /dev/null
+++ b/tools/language-server/src/core/documents/DocumentMapper.ts
@@ -0,0 +1,317 @@
+import {
+ Position,
+ Range,
+ CompletionItem,
+ Hover,
+ Diagnostic,
+ ColorPresentation,
+ SymbolInformation,
+ LocationLink,
+ TextDocumentEdit,
+ CodeAction,
+ SelectionRange,
+ TextEdit,
+ InsertReplaceEdit,
+} from 'vscode-languageserver';
+import { TagInformation, offsetAt, positionAt } from './utils';
+import { SourceMapConsumer } from 'source-map';
+
+export interface DocumentMapper {
+ /**
+ * Map the generated position to the original position
+ * @param generatedPosition Position in fragment
+ */
+ getOriginalPosition(generatedPosition: Position): Position;
+
+ /**
+ * Map the original position to the generated position
+ * @param originalPosition Position in parent
+ */
+ getGeneratedPosition(originalPosition: Position): Position;
+
+ /**
+ * Returns true if the given original position is inside of the generated map
+ * @param pos Position in original
+ */
+ isInGenerated(pos: Position): boolean;
+
+ /**
+ * Get document URL
+ */
+ getURL(): string;
+
+ /**
+ * Implement this if you need teardown logic before this mapper gets cleaned up.
+ */
+ destroy?(): void;
+}
+
+/**
+ * Does not map, returns positions as is.
+ */
+export class IdentityMapper implements DocumentMapper {
+ constructor(private url: string, private parent?: DocumentMapper) {}
+
+ getOriginalPosition(generatedPosition: Position): Position {
+ if (this.parent) {
+ generatedPosition = this.getOriginalPosition(generatedPosition);
+ }
+
+ return generatedPosition;
+ }
+
+ getGeneratedPosition(originalPosition: Position): Position {
+ if (this.parent) {
+ originalPosition = this.getGeneratedPosition(originalPosition);
+ }
+
+ return originalPosition;
+ }
+
+ isInGenerated(position: Position): boolean {
+ if (this.parent && !this.parent.isInGenerated(position)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ getURL(): string {
+ return this.url;
+ }
+
+ destroy() {
+ this.parent?.destroy?.();
+ }
+}
+
+/**
+ * Maps positions in a fragment relative to a parent.
+ */
+export class FragmentMapper implements DocumentMapper {
+ constructor(private originalText: string, private tagInfo: TagInformation, private url: string) {}
+
+ getOriginalPosition(generatedPosition: Position): Position {
+ const parentOffset = this.offsetInParent(offsetAt(generatedPosition, this.tagInfo.content));
+ return positionAt(parentOffset, this.originalText);
+ }
+
+ private offsetInParent(offset: number): number {
+ return this.tagInfo.start + offset;
+ }
+
+ getGeneratedPosition(originalPosition: Position): Position {
+ const fragmentOffset = offsetAt(originalPosition, this.originalText) - this.tagInfo.start;
+ return positionAt(fragmentOffset, this.tagInfo.content);
+ }
+
+ isInGenerated(pos: Position): boolean {
+ const offset = offsetAt(pos, this.originalText);
+ return offset >= this.tagInfo.start && offset <= this.tagInfo.end;
+ }
+
+ getURL(): string {
+ return this.url;
+ }
+}
+
+export class SourceMapDocumentMapper implements DocumentMapper {
+ constructor(protected consumer: SourceMapConsumer, protected sourceUri: string, private parent?: DocumentMapper) {}
+
+ getOriginalPosition(generatedPosition: Position): Position {
+ if (this.parent) {
+ generatedPosition = this.parent.getOriginalPosition(generatedPosition);
+ }
+
+ if (generatedPosition.line < 0) {
+ return { line: -1, character: -1 };
+ }
+
+ const mapped = this.consumer.originalPositionFor({
+ line: generatedPosition.line + 1,
+ column: generatedPosition.character,
+ });
+
+ if (!mapped) {
+ return { line: -1, character: -1 };
+ }
+
+ if (mapped.line === 0) {
+ console.log('Got 0 mapped line from', generatedPosition, 'col was', mapped.column);
+ }
+
+ return {
+ line: (mapped.line || 0) - 1,
+ character: mapped.column || 0,
+ };
+ }
+
+ getGeneratedPosition(originalPosition: Position): Position {
+ if (this.parent) {
+ originalPosition = this.parent.getGeneratedPosition(originalPosition);
+ }
+
+ const mapped = this.consumer.generatedPositionFor({
+ line: originalPosition.line + 1,
+ column: originalPosition.character,
+ source: this.sourceUri,
+ });
+
+ if (!mapped) {
+ return { line: -1, character: -1 };
+ }
+
+ const result = {
+ line: (mapped.line || 0) - 1,
+ character: mapped.column || 0,
+ };
+
+ if (result.line < 0) {
+ return result;
+ }
+
+ return result;
+ }
+
+ isInGenerated(position: Position): boolean {
+ if (this.parent && !this.isInGenerated(position)) {
+ return false;
+ }
+
+ const generated = this.getGeneratedPosition(position);
+ return generated.line >= 0;
+ }
+
+ getURL(): string {
+ return this.sourceUri;
+ }
+
+ /**
+ * Needs to be called when source mapper is no longer needed in order to prevent memory leaks.
+ */
+ destroy() {
+ this.parent?.destroy?.();
+ this.consumer.destroy();
+ }
+}
+
+export function mapRangeToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, range: Range): Range {
+ // DON'T use Range.create here! Positions might not be mapped
+ // and therefore return negative numbers, which makes Range.create throw.
+ // These invalid position need to be handled
+ // on a case-by-case basis in the calling functions.
+ const originalRange = {
+ start: fragment.getOriginalPosition(range.start),
+ end: fragment.getOriginalPosition(range.end),
+ };
+
+ // Range may be mapped one character short - reverse that for "in the same line" cases
+ if (
+ originalRange.start.line === originalRange.end.line &&
+ range.start.line === range.end.line &&
+ originalRange.end.character - originalRange.start.character === range.end.character - range.start.character - 1
+ ) {
+ originalRange.end.character += 1;
+ }
+
+ return originalRange;
+}
+
+export function mapRangeToGenerated(fragment: DocumentMapper, range: Range): Range {
+ return Range.create(fragment.getGeneratedPosition(range.start), fragment.getGeneratedPosition(range.end));
+}
+
+export function mapCompletionItemToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, item: CompletionItem): CompletionItem {
+ if (!item.textEdit) {
+ return item;
+ }
+
+ return {
+ ...item,
+ textEdit: mapEditToOriginal(fragment, item.textEdit),
+ };
+}
+
+export function mapHoverToParent(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, hover: Hover): Hover {
+ if (!hover.range) {
+ return hover;
+ }
+
+ return { ...hover, range: mapRangeToOriginal(fragment, hover.range) };
+}
+
+export function mapObjWithRangeToOriginal<T extends { range: Range }>(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, objWithRange: T): T {
+ return { ...objWithRange, range: mapRangeToOriginal(fragment, objWithRange.range) };
+}
+
+export function mapInsertReplaceEditToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, edit: InsertReplaceEdit): InsertReplaceEdit {
+ return {
+ ...edit,
+ insert: mapRangeToOriginal(fragment, edit.insert),
+ replace: mapRangeToOriginal(fragment, edit.replace),
+ };
+}
+
+export function mapEditToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, edit: TextEdit | InsertReplaceEdit): TextEdit | InsertReplaceEdit {
+ return TextEdit.is(edit) ? mapObjWithRangeToOriginal(fragment, edit) : mapInsertReplaceEditToOriginal(fragment, edit);
+}
+
+export function mapDiagnosticToGenerated(fragment: DocumentMapper, diagnostic: Diagnostic): Diagnostic {
+ return { ...diagnostic, range: mapRangeToGenerated(fragment, diagnostic.range) };
+}
+
+export function mapColorPresentationToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, presentation: ColorPresentation): ColorPresentation {
+ const item = {
+ ...presentation,
+ };
+
+ if (item.textEdit) {
+ item.textEdit = mapObjWithRangeToOriginal(fragment, item.textEdit);
+ }
+
+ if (item.additionalTextEdits) {
+ item.additionalTextEdits = item.additionalTextEdits.map((edit) => mapObjWithRangeToOriginal(fragment, edit));
+ }
+
+ return item;
+}
+
+export function mapSymbolInformationToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, info: SymbolInformation): SymbolInformation {
+ return { ...info, location: mapObjWithRangeToOriginal(fragment, info.location) };
+}
+
+export function mapLocationLinkToOriginal(fragment: DocumentMapper, def: LocationLink): LocationLink {
+ return LocationLink.create(
+ def.targetUri,
+ fragment.getURL() === def.targetUri ? mapRangeToOriginal(fragment, def.targetRange) : def.targetRange,
+ fragment.getURL() === def.targetUri ? mapRangeToOriginal(fragment, def.targetSelectionRange) : def.targetSelectionRange,
+ def.originSelectionRange ? mapRangeToOriginal(fragment, def.originSelectionRange) : undefined
+ );
+}
+
+export function mapTextDocumentEditToOriginal(fragment: DocumentMapper, edit: TextDocumentEdit) {
+ if (edit.textDocument.uri !== fragment.getURL()) {
+ return edit;
+ }
+
+ return TextDocumentEdit.create(
+ edit.textDocument,
+ edit.edits.map((textEdit) => mapObjWithRangeToOriginal(fragment, textEdit))
+ );
+}
+
+export function mapCodeActionToOriginal(fragment: DocumentMapper, codeAction: CodeAction) {
+ return CodeAction.create(
+ codeAction.title,
+ {
+ documentChanges: codeAction.edit!.documentChanges!.map((edit) => mapTextDocumentEditToOriginal(fragment, edit as TextDocumentEdit)),
+ },
+ codeAction.kind
+ );
+}
+
+export function mapSelectionRangeToParent(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, selectionRange: SelectionRange): SelectionRange {
+ const { range, parent } = selectionRange;
+
+ return SelectionRange.create(mapRangeToOriginal(fragment, range), parent && mapSelectionRangeToParent(fragment, parent));
+}
diff --git a/tools/language-server/src/core/documents/index.ts b/tools/language-server/src/core/documents/index.ts
new file mode 100644
index 000000000..5dc0eb61f
--- /dev/null
+++ b/tools/language-server/src/core/documents/index.ts
@@ -0,0 +1,5 @@
+export * from './Document';
+export * from './DocumentBase';
+export * from './DocumentManager';
+export * from './DocumentMapper';
+export * from './utils';
diff --git a/tools/language-server/src/core/documents/parseAstro.ts b/tools/language-server/src/core/documents/parseAstro.ts
new file mode 100644
index 000000000..71c7764d8
--- /dev/null
+++ b/tools/language-server/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/language-server/src/core/documents/parseHtml.ts b/tools/language-server/src/core/documents/parseHtml.ts
new file mode 100644
index 000000000..f5de5f292
--- /dev/null
+++ b/tools/language-server/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/language-server/src/core/documents/utils.ts b/tools/language-server/src/core/documents/utils.ts
new file mode 100644
index 000000000..eb9d2060d
--- /dev/null
+++ b/tools/language-server/src/core/documents/utils.ts
@@ -0,0 +1,250 @@
+import { HTMLDocument, Node, Position } from 'vscode-html-languageservice';
+import { Range } from 'vscode-languageserver';
+import { clamp, isInRange } from '../../utils';
+import { parseHtml } from './parseHtml';
+
+export interface TagInformation {
+ content: string;
+ attributes: Record<string, string>;
+ start: number;
+ end: number;
+ startPos: Position;
+ endPos: Position;
+ container: { start: number; end: number };
+}
+
+function parseAttributes(rawAttrs: Record<string, string | null> | undefined): Record<string, string> {
+ const attrs: Record<string, string> = {};
+ if (!rawAttrs) {
+ return attrs;
+ }
+
+ Object.keys(rawAttrs).forEach((attrName) => {
+ const attrValue = rawAttrs[attrName];
+ attrs[attrName] = attrValue === null ? attrName : removeOuterQuotes(attrValue);
+ });
+ return attrs;
+
+ function removeOuterQuotes(attrValue: string) {
+ if ((attrValue.startsWith('"') && attrValue.endsWith('"')) || (attrValue.startsWith("'") && attrValue.endsWith("'"))) {
+ return attrValue.slice(1, attrValue.length - 1);
+ }
+ return attrValue;
+ }
+}
+
+/**
+ * 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;
+}
+
+export function isInTag(position: Position, tagInfo: TagInformation | null): tagInfo is TagInformation {
+ return !!tagInfo && isInRange(position, Range.create(tagInfo.startPos, tagInfo.endPos));
+}
+
+/**
+ * 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;
+}
+
+export function* walk(node: Node): Generator<Node, void, unknown> {
+ for (let child of node.children) {
+ yield* walk(child);
+ }
+ yield node;
+}
+
+/*
+export function* walk(node: Node, startIndex = 0) {
+ let skip, tmp;
+ let depth = 0;
+ let index = startIndex;
+
+ // Always start with the initial element.
+ do {
+ if ( !skip && (tmp = node.firstChild) ) {
+ depth++;
+ callback('child', node, tmp, index);
+ index++;
+ } else if ( tmp = node.nextSibling ) {
+ skip = false;
+ callback('sibling', node, tmp, index);
+ index++;
+ } else {
+ tmp = node.parentNode;
+ depth--;
+ skip = true;
+ }
+ node = tmp;
+ } while ( depth > 0 );
+};
+*/
+
+/**
+ * Extracts a tag (style or script) from the given text
+ * and returns its start, end and the attributes on that tag.
+ *
+ * @param source text content to extract tag from
+ * @param tag the tag to extract
+ */
+function extractTags(text: string, tag: 'script' | 'style' | 'template', html?: HTMLDocument): TagInformation[] {
+ const rootNodes = html?.roots || parseHtml(text).roots;
+ const matchedNodes = rootNodes.filter((node) => node.tag === tag);
+
+ if (tag === 'style' && !matchedNodes.length && rootNodes.length && rootNodes[0].tag === 'html') {
+ for (let child of walk(rootNodes[0])) {
+ if (child.tag === 'style') {
+ matchedNodes.push(child);
+ }
+ }
+ }
+
+ return matchedNodes.map(transformToTagInfo);
+
+ function transformToTagInfo(matchedNode: Node) {
+ const start = matchedNode.startTagEnd ?? matchedNode.start;
+ const end = matchedNode.endTagStart ?? matchedNode.end;
+ const startPos = positionAt(start, text);
+ const endPos = positionAt(end, text);
+ const container = {
+ start: matchedNode.start,
+ end: matchedNode.end,
+ };
+ const content = text.substring(start, end);
+
+ return {
+ content,
+ attributes: parseAttributes(matchedNode.attributes),
+ start,
+ end,
+ startPos,
+ endPos,
+ container,
+ };
+ }
+}
+
+export function extractStyleTag(source: string, html?: HTMLDocument): TagInformation | null {
+ const styles = extractTags(source, 'style', html);
+ if (!styles.length) {
+ return null;
+ }
+
+ // There can only be one style tag
+ return styles[0];
+}
diff --git a/tools/language-server/src/index.ts b/tools/language-server/src/index.ts
new file mode 100644
index 000000000..5e4c736a2
--- /dev/null
+++ b/tools/language-server/src/index.ts
@@ -0,0 +1,115 @@
+import { RequestType, TextDocumentPositionParams, createConnection, ProposedFeatures, TextDocumentSyncKind, TextDocumentIdentifier } from 'vscode-languageserver';
+import { Document, DocumentManager } from './core/documents';
+import { ConfigManager } from './core/config';
+import { PluginHost, CSSPlugin, 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.initialize({
+ filterIncompleteCompletions: !evt.initializationOptions?.dontFilterIncompleteCompletions,
+ definitionLinkSupport: !!evt.capabilities.textDocument?.definition?.linkSupport,
+ });
+ pluginHost.register(new AstroPlugin(docManager, configManager, workspaceUris));
+ pluginHost.register(new HTMLPlugin(docManager, configManager));
+ pluginHost.register(new CSSPlugin(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,
+ definitionProvider: true,
+ completionProvider: {
+ resolveProvider: true,
+ 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.onDefinition((evt) => pluginHost.getDefinitions(evt.textDocument, evt.position));
+ connection.onFoldingRanges((evt) => pluginHost.getFoldingRanges(evt.textDocument));
+ connection.onRequest(TagCloseRequest, (evt: any) => pluginHost.doTagComplete(evt.textDocument, evt.position));
+
+ connection.listen();
+}
diff --git a/tools/language-server/src/plugins/PluginHost.ts b/tools/language-server/src/plugins/PluginHost.ts
new file mode 100644
index 000000000..3741845c4
--- /dev/null
+++ b/tools/language-server/src/plugins/PluginHost.ts
@@ -0,0 +1,139 @@
+import { CompletionContext, CompletionItem, CompletionList, DefinitionLink, Location, 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';
+
+enum ExecuteMode {
+ None,
+ FirstNonNull,
+ Collect,
+}
+
+interface PluginHostConfig {
+ filterIncompleteCompletions: boolean;
+ definitionLinkSupport: boolean;
+}
+
+export class PluginHost {
+ private plugins: d.Plugin[] = [];
+ private pluginHostConfig: PluginHostConfig = {
+ filterIncompleteCompletions: true,
+ definitionLinkSupport: false,
+ };
+
+ constructor(private documentsManager: DocumentManager) {}
+
+ initialize(pluginHostConfig: PluginHostConfig) {
+ this.pluginHostConfig = pluginHostConfig;
+ }
+
+ 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;
+ }
+
+ async getDefinitions(textDocument: TextDocumentIdentifier, position: Position): Promise<DefinitionLink[] | Location[]> {
+ const document = this.getDocument(textDocument.uri);
+ if (!document) {
+ throw new Error('Cannot call methods on an unopened document');
+ }
+
+ const definitions = flatten(await this.execute<DefinitionLink[]>('getDefinitions', [document, position], ExecuteMode.Collect));
+
+ if (this.pluginHostConfig.definitionLinkSupport) {
+ return definitions;
+ } else {
+ return definitions.map((def) => <Location>{ range: def.targetSelectionRange, uri: def.targetUri });
+ }
+ }
+
+ 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/language-server/src/plugins/astro/AstroPlugin.ts b/tools/language-server/src/plugins/astro/AstroPlugin.ts
new file mode 100644
index 000000000..535375eeb
--- /dev/null
+++ b/tools/language-server/src/plugins/astro/AstroPlugin.ts
@@ -0,0 +1,199 @@
+import { DefinitionLink } from 'vscode-languageserver';
+import type { Document, DocumentManager } from '../../core/documents';
+import type { ConfigManager } from '../../core/config';
+import type { CompletionsProvider, AppCompletionList, FoldingRangeProvider } from '../interfaces';
+import {
+ CompletionContext,
+ Position,
+ CompletionList,
+ CompletionItem,
+ CompletionItemKind,
+ InsertTextFormat,
+ LocationLink,
+ FoldingRange,
+ Range,
+ TextEdit,
+} from 'vscode-languageserver';
+import { Node } from 'vscode-html-languageservice';
+import { isPossibleClientComponent, pathToUrl, urlToPath } from '../../utils';
+import { isInsideFrontmatter } from '../../core/documents/utils';
+import * as ts from 'typescript';
+import { LanguageServiceManager as TypeScriptLanguageServiceManager } from '../typescript/LanguageServiceManager';
+import { ensureRealFilePath } from '../typescript/utils';
+import { FoldingRangeKind } from 'vscode-languageserver-types';
+
+export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
+ private readonly docManager: DocumentManager;
+ private readonly configManager: ConfigManager;
+ private readonly tsLanguageServiceManager: TypeScriptLanguageServiceManager;
+
+ constructor(docManager: DocumentManager, configManager: ConfigManager, workspaceUris: string[]) {
+ this.docManager = docManager;
+ this.configManager = configManager;
+ this.tsLanguageServiceManager = new TypeScriptLanguageServiceManager(docManager, configManager, workspaceUris);
+ }
+
+ 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,
+ },
+ ];
+ }
+
+ async getDefinitions(document: Document, position: Position): Promise<DefinitionLink[]> {
+ if (this.isInsideFrontmatter(document, position)) {
+ return [];
+ }
+
+ const offset = document.offsetAt(position);
+ const html = document.html;
+
+ const node = html.findNodeAt(offset);
+ if (!this.isComponentTag(node)) {
+ return [];
+ }
+
+ const [componentName] = node.tag!.split(':');
+
+ const filePath = urlToPath(document.uri);
+ const tsFilePath = filePath + '.ts';
+
+ const { lang, tsDoc } = await this.tsLanguageServiceManager.getTypeScriptDoc(document);
+
+ const sourceFile = lang.getProgram()?.getSourceFile(tsFilePath);
+ if (!sourceFile) {
+ return [];
+ }
+
+ const specifier = this.getImportSpecifierForIdentifier(sourceFile, componentName);
+ if (!specifier) {
+ return [];
+ }
+
+ const defs = lang.getDefinitionAtPosition(tsFilePath, specifier.getStart());
+ if (!defs) {
+ return [];
+ }
+
+ const tsFragment = await tsDoc.getFragment();
+ const startRange: Range = Range.create(Position.create(0, 0), Position.create(0, 0));
+ const links = defs.map((def) => {
+ const defFilePath = ensureRealFilePath(def.fileName);
+ return LocationLink.create(pathToUrl(defFilePath), startRange, startRange);
+ });
+
+ return links;
+ }
+
+ 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;
+ }
+
+ private isInsideFrontmatter(document: Document, position: Position) {
+ return isInsideFrontmatter(document.getText(), document.offsetAt(position));
+ }
+
+ private isComponentTag(node: Node): boolean {
+ if (!node.tag) {
+ return false;
+ }
+ const firstChar = node.tag[0];
+ return /[A-Z]/.test(firstChar);
+ }
+
+ private getImportSpecifierForIdentifier(sourceFile: ts.SourceFile, identifier: string): ts.Expression | undefined {
+ let importSpecifier: ts.Expression | undefined = undefined;
+ ts.forEachChild(sourceFile, (tsNode) => {
+ if (ts.isImportDeclaration(tsNode)) {
+ if (tsNode.importClause) {
+ const { name } = tsNode.importClause;
+ if (name && name.getText() === identifier) {
+ importSpecifier = tsNode.moduleSpecifier;
+ return true;
+ }
+ }
+ }
+ });
+ return importSpecifier;
+ }
+}
diff --git a/tools/language-server/src/plugins/css/CSSDocument.ts b/tools/language-server/src/plugins/css/CSSDocument.ts
new file mode 100644
index 000000000..9f1839678
--- /dev/null
+++ b/tools/language-server/src/plugins/css/CSSDocument.ts
@@ -0,0 +1,95 @@
+import { Stylesheet, TextDocument } from 'vscode-css-languageservice';
+import { Position } from 'vscode-languageserver';
+import { getLanguageService } from './service';
+import { Document, DocumentMapper, ReadableDocument, TagInformation } from '../../core/documents/index';
+
+export interface CSSDocumentBase extends DocumentMapper, TextDocument {
+ languageId: string;
+ stylesheet: Stylesheet;
+}
+
+export class CSSDocument extends ReadableDocument implements DocumentMapper {
+ private styleInfo: Pick<TagInformation, 'attributes' | 'start' | 'end'>;
+ readonly version = this.parent.version;
+
+ public stylesheet: Stylesheet;
+ public languageId: string;
+
+ constructor(private parent: Document) {
+ super();
+
+ if (this.parent.styleInfo) {
+ this.styleInfo = this.parent.styleInfo;
+ } else {
+ this.styleInfo = {
+ attributes: {},
+ start: -1,
+ end: -1,
+ };
+ }
+
+ this.languageId = this.language;
+ this.stylesheet = getLanguageService(this.language).parseStylesheet(this);
+ }
+
+ /**
+ * Get the fragment position relative to the parent
+ * @param pos Position in fragment
+ */
+ getOriginalPosition(pos: Position): Position {
+ const parentOffset = this.styleInfo.start + this.offsetAt(pos);
+ return this.parent.positionAt(parentOffset);
+ }
+
+ /**
+ * Get the position relative to the start of the fragment
+ * @param pos Position in parent
+ */
+ getGeneratedPosition(pos: Position): Position {
+ const fragmentOffset = this.parent.offsetAt(pos) - this.styleInfo.start;
+ return this.positionAt(fragmentOffset);
+ }
+
+ /**
+ * Returns true if the given parent position is inside of this fragment
+ * @param pos Position in parent
+ */
+ isInGenerated(pos: Position): boolean {
+ const offset = this.parent.offsetAt(pos);
+ return offset >= this.styleInfo.start && offset <= this.styleInfo.end;
+ }
+
+ /**
+ * Get the fragment text from the parent
+ */
+ getText(): string {
+ return this.parent.getText().slice(this.styleInfo.start, this.styleInfo.end);
+ }
+
+ /**
+ * Returns the length of the fragment as calculated from the start and end positon
+ */
+ getTextLength(): number {
+ return this.styleInfo.end - this.styleInfo.start;
+ }
+
+ /**
+ * Return the parent file path
+ */
+ getFilePath(): string | null {
+ return this.parent.getFilePath();
+ }
+
+ getURL() {
+ return this.parent.getURL();
+ }
+
+ getAttributes() {
+ return this.styleInfo.attributes;
+ }
+
+ private get language() {
+ const attrs = this.getAttributes();
+ return attrs.lang || attrs.type || 'css';
+ }
+}
diff --git a/tools/language-server/src/plugins/css/CSSPlugin.ts b/tools/language-server/src/plugins/css/CSSPlugin.ts
new file mode 100644
index 000000000..26c90ac66
--- /dev/null
+++ b/tools/language-server/src/plugins/css/CSSPlugin.ts
@@ -0,0 +1,118 @@
+import type { CompletionsProvider } from '../interfaces';
+import type { Document, DocumentManager } from '../../core/documents';
+import type { ConfigManager } from '../../core/config';
+import { getEmmetCompletionParticipants, doComplete as doEmmetComplete } from 'vscode-emmet-helper';
+import { CompletionContext, CompletionList, CompletionTriggerKind, Position } from 'vscode-languageserver';
+import { isInsideFrontmatter } from '../../core/documents/utils';
+import { CSSDocument, CSSDocumentBase } from './CSSDocument';
+import { getLanguage, getLanguageService } from './service';
+import { StyleAttributeDocument } from './StyleAttributeDocument';
+import { mapCompletionItemToOriginal } from '../../core/documents';
+import { AttributeContext, getAttributeContextAtPosition } from '../../core/documents/parseHtml';
+import { getIdClassCompletion } from './features/getIdClassCompletion';
+
+export class CSSPlugin implements CompletionsProvider {
+ private docManager: DocumentManager;
+ private configManager: ConfigManager;
+ private documents = new WeakMap<Document, CSSDocument>();
+ private triggerCharacters = new Set(['.', ':', '-', '/']);
+
+ constructor(docManager: DocumentManager, configManager: ConfigManager) {
+ this.docManager = docManager;
+ this.configManager = configManager;
+
+ this.docManager.on('documentChange', (document) => {
+ this.documents.set(document, new CSSDocument(document));
+ });
+ }
+
+ getCompletions(document: Document, position: Position, completionContext?: CompletionContext): CompletionList | null {
+ const triggerCharacter = completionContext?.triggerCharacter;
+ const triggerKind = completionContext?.triggerKind;
+ const isCustomTriggerCharacter = triggerKind === CompletionTriggerKind.TriggerCharacter;
+
+ if (isCustomTriggerCharacter && triggerCharacter && !this.triggerCharacters.has(triggerCharacter)) {
+ return null;
+ }
+
+ if (this.isInsideFrontmatter(document, position)) {
+ return null;
+ }
+
+ const cssDocument = this.getCSSDoc(document);
+
+ if (cssDocument.isInGenerated(position)) {
+ return this.getCompletionsInternal(document, position, cssDocument);
+ }
+
+ const attributeContext = getAttributeContextAtPosition(document, position);
+ if (!attributeContext) {
+ return null;
+ }
+
+ if (this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText())) {
+ const [start, end] = attributeContext.valueRange;
+ return this.getCompletionsInternal(document, position, new StyleAttributeDocument(document, start, end));
+ } else {
+ return getIdClassCompletion(cssDocument, attributeContext);
+ }
+ }
+
+ private getCompletionsInternal(document: Document, position: Position, cssDocument: CSSDocumentBase) {
+ if (isSASS(cssDocument)) {
+ // the css language service does not support sass, still we can use
+ // the emmet helper directly to at least get emmet completions
+ return doEmmetComplete(document, position, 'sass', this.configManager.getEmmetConfig());
+ }
+
+ const type = extractLanguage(cssDocument);
+
+ const lang = getLanguageService(type);
+ const emmetResults: CompletionList = {
+ isIncomplete: true,
+ items: [],
+ };
+ if (false /* this.configManager.getConfig().css.completions.emmet */) {
+ lang.setCompletionParticipants([
+ getEmmetCompletionParticipants(cssDocument, cssDocument.getGeneratedPosition(position), getLanguage(type), this.configManager.getEmmetConfig(), emmetResults),
+ ]);
+ }
+ const results = lang.doComplete(cssDocument, cssDocument.getGeneratedPosition(position), cssDocument.stylesheet);
+ return CompletionList.create(
+ [...(results ? results.items : []), ...emmetResults.items].map((completionItem) => mapCompletionItemToOriginal(cssDocument, completionItem)),
+ // Emmet completions change on every keystroke, so they are never complete
+ emmetResults.items.length > 0
+ );
+ }
+
+ private inStyleAttributeWithoutInterpolation(attrContext: AttributeContext, text: string): attrContext is Required<AttributeContext> {
+ return attrContext.name === 'style' && !!attrContext.valueRange && !text.substring(attrContext.valueRange[0], attrContext.valueRange[1]).includes('{');
+ }
+
+ private getCSSDoc(document: Document) {
+ let cssDoc = this.documents.get(document);
+ if (!cssDoc || cssDoc.version < document.version) {
+ cssDoc = new CSSDocument(document);
+ this.documents.set(document, cssDoc);
+ }
+ return cssDoc;
+ }
+
+ private isInsideFrontmatter(document: Document, position: Position) {
+ return isInsideFrontmatter(document.getText(), document.offsetAt(position));
+ }
+}
+
+function isSASS(document: CSSDocumentBase) {
+ switch (extractLanguage(document)) {
+ case 'sass':
+ return true;
+ default:
+ return false;
+ }
+}
+
+function extractLanguage(document: CSSDocumentBase): string {
+ const lang = document.languageId;
+ return lang.replace(/^text\//, '');
+}
diff --git a/tools/language-server/src/plugins/css/StyleAttributeDocument.ts b/tools/language-server/src/plugins/css/StyleAttributeDocument.ts
new file mode 100644
index 000000000..e00398037
--- /dev/null
+++ b/tools/language-server/src/plugins/css/StyleAttributeDocument.ts
@@ -0,0 +1,72 @@
+import { Stylesheet } from 'vscode-css-languageservice';
+import { Position } from 'vscode-languageserver';
+import { getLanguageService } from './service';
+import { Document, DocumentMapper, ReadableDocument } from '../../core/documents';
+
+const PREFIX = '__ {';
+const SUFFIX = '}';
+
+export class StyleAttributeDocument extends ReadableDocument implements DocumentMapper {
+ readonly version = this.parent.version;
+
+ public stylesheet: Stylesheet;
+ public languageId = 'css';
+
+ constructor(private readonly parent: Document, private readonly attrStart: number, private readonly attrEnd: number) {
+ super();
+
+ this.stylesheet = getLanguageService(this.languageId).parseStylesheet(this);
+ }
+
+ /**
+ * Get the fragment position relative to the parent
+ * @param pos Position in fragment
+ */
+ getOriginalPosition(pos: Position): Position {
+ const parentOffset = this.attrStart + this.offsetAt(pos) - PREFIX.length;
+ return this.parent.positionAt(parentOffset);
+ }
+
+ /**
+ * Get the position relative to the start of the fragment
+ * @param pos Position in parent
+ */
+ getGeneratedPosition(pos: Position): Position {
+ const fragmentOffset = this.parent.offsetAt(pos) - this.attrStart + PREFIX.length;
+ return this.positionAt(fragmentOffset);
+ }
+
+ /**
+ * Returns true if the given parent position is inside of this fragment
+ * @param pos Position in parent
+ */
+ isInGenerated(pos: Position): boolean {
+ const offset = this.parent.offsetAt(pos);
+ return offset >= this.attrStart && offset <= this.attrEnd;
+ }
+
+ /**
+ * Get the fragment text from the parent
+ */
+ getText(): string {
+ return PREFIX + this.parent.getText().slice(this.attrStart, this.attrEnd) + SUFFIX;
+ }
+
+ /**
+ * Returns the length of the fragment as calculated from the start and end position
+ */
+ getTextLength(): number {
+ return PREFIX.length + this.attrEnd - this.attrStart + SUFFIX.length;
+ }
+
+ /**
+ * Return the parent file path
+ */
+ getFilePath(): string | null {
+ return this.parent.getFilePath();
+ }
+
+ getURL() {
+ return this.parent.getURL();
+ }
+}
diff --git a/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts b/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts
new file mode 100644
index 000000000..45acb5ad6
--- /dev/null
+++ b/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts
@@ -0,0 +1,67 @@
+import { CompletionItem, CompletionItemKind, CompletionList } from 'vscode-languageserver';
+import { AttributeContext } from '../../../core/documents/parseHtml';
+import { CSSDocument } from '../CSSDocument';
+
+export function getIdClassCompletion(cssDoc: CSSDocument, attributeContext: AttributeContext): CompletionList | null {
+ const collectingType = getCollectingType(attributeContext);
+
+ if (!collectingType) {
+ return null;
+ }
+ const items = collectSelectors(cssDoc.stylesheet as CSSNode, collectingType);
+
+ console.log('getIdClassCompletion items', items.length);
+ return CompletionList.create(items);
+}
+
+function getCollectingType(attributeContext: AttributeContext): number | undefined {
+ if (attributeContext.inValue) {
+ if (attributeContext.name === 'class') {
+ return NodeType.ClassSelector;
+ }
+ if (attributeContext.name === 'id') {
+ return NodeType.IdentifierSelector;
+ }
+ } else if (attributeContext.name.startsWith('class:')) {
+ return NodeType.ClassSelector;
+ }
+}
+
+/**
+ * incomplete see
+ * https://github.com/microsoft/vscode-css-languageservice/blob/master/src/parser/cssNodes.ts#L14
+ * The enum is not exported. we have to update this whenever it changes
+ */
+export enum NodeType {
+ ClassSelector = 14,
+ IdentifierSelector = 15,
+}
+
+export type CSSNode = {
+ type: number;
+ children: CSSNode[] | undefined;
+ getText(): string;
+};
+
+export function collectSelectors(stylesheet: CSSNode, type: number) {
+ const result: CSSNode[] = [];
+ walk(stylesheet, (node) => {
+ if (node.type === type) {
+ result.push(node);
+ }
+ });
+
+ return result.map(
+ (node): CompletionItem => ({
+ label: node.getText().substring(1),
+ kind: CompletionItemKind.Keyword,
+ })
+ );
+}
+
+function walk(node: CSSNode, callback: (node: CSSNode) => void) {
+ callback(node);
+ if (node.children) {
+ node.children.forEach((node) => walk(node, callback));
+ }
+}
diff --git a/tools/language-server/src/plugins/css/service.ts b/tools/language-server/src/plugins/css/service.ts
new file mode 100644
index 000000000..78b11296e
--- /dev/null
+++ b/tools/language-server/src/plugins/css/service.ts
@@ -0,0 +1,48 @@
+import { getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService, LanguageService, ICSSDataProvider } from 'vscode-css-languageservice';
+
+const customDataProvider: ICSSDataProvider = {
+ providePseudoClasses() {
+ return [];
+ },
+ provideProperties() {
+ return [];
+ },
+ provideAtDirectives() {
+ return [];
+ },
+ providePseudoElements() {
+ return [];
+ },
+};
+
+const [css, scss, less] = [getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService].map((getService) =>
+ getService({
+ customDataProviders: [customDataProvider],
+ })
+);
+
+const langs = {
+ css,
+ scss,
+ less,
+};
+
+export function getLanguage(kind?: string) {
+ switch (kind) {
+ case 'scss':
+ case 'text/scss':
+ return 'scss' as const;
+ case 'less':
+ case 'text/less':
+ return 'less' as const;
+ case 'css':
+ case 'text/css':
+ default:
+ return 'css' as const;
+ }
+}
+
+export function getLanguageService(kind?: string): LanguageService {
+ const lang = getLanguage(kind);
+ return langs[lang];
+}
diff --git a/tools/language-server/src/plugins/html/HTMLPlugin.ts b/tools/language-server/src/plugins/html/HTMLPlugin.ts
new file mode 100644
index 000000000..7e0ab4861
--- /dev/null
+++ b/tools/language-server/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/language-server/src/plugins/index.ts b/tools/language-server/src/plugins/index.ts
new file mode 100644
index 000000000..bb73cbe5e
--- /dev/null
+++ b/tools/language-server/src/plugins/index.ts
@@ -0,0 +1,6 @@
+export * from './PluginHost';
+export * from './astro/AstroPlugin';
+export * from './html/HTMLPlugin';
+export * from './typescript/TypeScriptPlugin';
+export * from './interfaces';
+export * from './css/CSSPlugin';
diff --git a/tools/language-server/src/plugins/interfaces.ts b/tools/language-server/src/plugins/interfaces.ts
new file mode 100644
index 000000000..b68100de1
--- /dev/null
+++ b/tools/language-server/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/language-server/src/plugins/typescript/DocumentSnapshot.ts b/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts
new file mode 100644
index 000000000..9e2e778c6
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts
@@ -0,0 +1,242 @@
+import * as ts from 'typescript';
+import { TextDocumentContentChangeEvent, Position } from 'vscode-languageserver';
+import { Document, DocumentMapper, IdentityMapper } from '../../core/documents';
+import { isInTag, positionAt, offsetAt } from '../../core/documents/utils';
+import { pathToUrl } from '../../utils';
+import { getScriptKindFromFileName, isAstroFilePath, toVirtualAstroFilePath } from './utils';
+
+/**
+ * The mapper to get from original snapshot positions to generated and vice versa.
+ */
+export interface SnapshotFragment extends DocumentMapper {
+ positionAt(offset: number): Position;
+ offsetAt(position: Position): number;
+}
+
+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, currentText: string | null, createDocument?: (_filePath: string, text: string) => Document): DocumentSnapshot => {
+ const text = currentText || (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> {
+ const uri = pathToUrl(this.filePath);
+ const mapper = await this.getMapper(uri);
+ return new DocumentFragmentSnapshot(mapper, 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);
+ }
+
+ private getMapper(uri: string) {
+ return new IdentityMapper(uri);
+ }
+}
+
+export class DocumentFragmentSnapshot implements Omit<DocumentSnapshot, 'getFragment' | 'destroyFragment'>, SnapshotFragment {
+ version: number;
+ filePath: string;
+ url: string;
+ text: string;
+
+ scriptKind = ts.ScriptKind.TSX;
+ scriptInfo = null;
+
+ constructor(private mapper: any, private parent: Document) {
+ const filePath = parent.getFilePath();
+ if (!filePath) throw new Error('Cannot create a document fragment from a non-local document');
+ const text = parent.getText();
+ this.version = parent.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);
+ }
+
+ getOriginalPosition(pos: Position): Position {
+ return this.mapper.getOriginalPosition(pos);
+ }
+
+ getGeneratedPosition(pos: Position): Position {
+ return this.mapper.getGeneratedPosition(pos);
+ }
+
+ isInGenerated(pos: Position): boolean {
+ return !isInTag(pos, this.parent.styleInfo);
+ }
+
+ getURL(): string {
+ return this.url;
+ }
+}
+
+export 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/language-server/src/plugins/typescript/LanguageServiceManager.ts b/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts
new file mode 100644
index 000000000..3ebcfdd77
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts
@@ -0,0 +1,81 @@
+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 { SnapshotManager } from './SnapshotManager';
+import { DocumentSnapshot } from './DocumentSnapshot';
+
+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/language-server/src/plugins/typescript/SnapshotManager.ts b/tools/language-server/src/plugins/typescript/SnapshotManager.ts
new file mode 100644
index 000000000..5a406b945
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/SnapshotManager.ts
@@ -0,0 +1,95 @@
+import * as ts from 'typescript';
+import { TextDocumentContentChangeEvent } from 'vscode-languageserver';
+import { toVirtualAstroFilePath } from './utils';
+import { DocumentSnapshot, TypeScriptDocumentSnapshot, createDocumentSnapshot } from './DocumentSnapshot';
+
+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, null);
+
+ 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}`
+ );
+ }
+ }
+}
diff --git a/tools/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/tools/language-server/src/plugins/typescript/TypeScriptPlugin.ts
new file mode 100644
index 000000000..10b94cb83
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/TypeScriptPlugin.ts
@@ -0,0 +1,113 @@
+import { Document, DocumentManager, isInsideFrontmatter } from '../../core/documents';
+import type { ConfigManager } from '../../core/config';
+import type { CompletionsProvider, AppCompletionItem, AppCompletionList } from '../interfaces';
+import { CompletionContext, DefinitionLink, FileChangeType, Position, LocationLink } from 'vscode-languageserver';
+import * as ts from 'typescript';
+import { CompletionsProviderImpl, CompletionEntryWithIdentifer } from './features/CompletionsProvider';
+import { LanguageServiceManager } from './LanguageServiceManager';
+import { SnapshotManager } from './SnapshotManager';
+import { convertToLocationRange, isVirtualFilePath, getScriptKindFromFileName } from './utils';
+import { isNoTextSpanInGeneratedCode, SnapshotFragmentMap } from './features/utils';
+import { isNotNullOrUndefined, pathToUrl } 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 getDefinitions(document: Document, position: Position): Promise<DefinitionLink[]> {
+ if (!this.isInsideFrontmatter(document, position)) {
+ return [];
+ }
+
+ const { lang, tsDoc } = await this.languageServiceManager.getTypeScriptDoc(document);
+ const mainFragment = await tsDoc.getFragment();
+
+ const filePath = tsDoc.filePath;
+ const tsFilePath = filePath.endsWith('.ts') ? filePath : filePath + '.ts';
+
+ const defs = lang.getDefinitionAndBoundSpan(tsFilePath, mainFragment.offsetAt(mainFragment.getGeneratedPosition(position)));
+
+ if (!defs || !defs.definitions) {
+ return [];
+ }
+
+ const docs = new SnapshotFragmentMap(this.languageServiceManager);
+ docs.set(tsDoc.filePath, { fragment: mainFragment, snapshot: tsDoc });
+
+ const result = await Promise.all(
+ defs.definitions.map(async (def) => {
+ const { fragment, snapshot } = await docs.retrieve(def.fileName);
+
+ if (isNoTextSpanInGeneratedCode(snapshot.getFullText(), def.textSpan)) {
+ const fileName = isVirtualFilePath(def.fileName) ? def.fileName.substr(0, def.fileName.length - 3) : def.fileName;
+ return LocationLink.create(
+ pathToUrl(fileName),
+ convertToLocationRange(fragment, def.textSpan),
+ convertToLocationRange(fragment, def.textSpan),
+ convertToLocationRange(mainFragment, defs.textSpan)
+ );
+ }
+ })
+ );
+ return result.filter(isNotNullOrUndefined);
+ }
+
+ 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);
+ }
+
+ private isInsideFrontmatter(document: Document, position: Position) {
+ return isInsideFrontmatter(document.getText(), document.offsetAt(position));
+ }
+}
diff --git a/tools/language-server/src/plugins/typescript/astro-sys.ts b/tools/language-server/src/plugins/typescript/astro-sys.ts
new file mode 100644
index 000000000..57cd3b497
--- /dev/null
+++ b/tools/language-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', '.svelte', '.vue']);
+ 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/language-server/src/plugins/typescript/features/CompletionsProvider.ts b/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts
new file mode 100644
index 000000000..d13269c5c
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts
@@ -0,0 +1,134 @@
+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 offset = document.offsetAt(position);
+ const entries =
+ lang.getCompletionsAtPosition(fragment.filePath, offset, {
+ importModuleSpecifierPreference: 'relative',
+ importModuleSpecifierEnding: 'js',
+ quotePreference: 'single',
+ })?.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);
+
+ let filePath = tsDoc.filePath;
+
+ if (!comp || !filePath) {
+ return completionItem;
+ }
+
+ if (filePath.endsWith('.astro')) {
+ filePath = filePath + '.ts';
+ }
+
+ const fragment = await tsDoc.getFragment();
+ const detail = lang.getCompletionEntryDetails(filePath, fragment.offsetAt(comp.position), comp.name, {}, comp.source, {}, undefined);
+
+ 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/language-server/src/plugins/typescript/features/utils.ts b/tools/language-server/src/plugins/typescript/features/utils.ts
new file mode 100644
index 000000000..8c87dc5f4
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/features/utils.ts
@@ -0,0 +1,54 @@
+import type { SnapshotFragment, DocumentSnapshot } from '../DocumentSnapshot';
+import type { LanguageServiceManager } from '../LanguageServiceManager';
+
+/**
+ * Checks if this a section that should be completely ignored
+ * because it's purely generated.
+ */
+export function isInGeneratedCode(text: string, start: number, end: number) {
+ const lineStart = text.lastIndexOf('\n', start);
+ const lineEnd = text.indexOf('\n', end);
+ const lastStart = text.substring(lineStart, start).lastIndexOf('/*Ωignore_startΩ*/');
+ const lastEnd = text.substring(lineStart, start).lastIndexOf('/*Ωignore_endΩ*/');
+ return lastStart > lastEnd && text.substring(end, lineEnd).includes('/*Ωignore_endΩ*/');
+}
+
+/**
+ * Checks that this isn't a text span that should be completely ignored
+ * because it's purely generated.
+ */
+export function isNoTextSpanInGeneratedCode(text: string, span: ts.TextSpan) {
+ return !isInGeneratedCode(text, span.start, span.start + span.length);
+}
+
+export class SnapshotFragmentMap {
+ private map = new Map<string, { fragment: SnapshotFragment; snapshot: DocumentSnapshot }>();
+ constructor(private languageServiceManager: LanguageServiceManager) {}
+
+ set(fileName: string, content: { fragment: SnapshotFragment; snapshot: DocumentSnapshot }) {
+ this.map.set(fileName, content);
+ }
+
+ get(fileName: string) {
+ return this.map.get(fileName);
+ }
+
+ getFragment(fileName: string) {
+ return this.map.get(fileName)?.fragment;
+ }
+
+ async retrieve(fileName: string) {
+ let snapshotFragment = this.get(fileName);
+ if (!snapshotFragment) {
+ const snapshot = await this.languageServiceManager.getSnapshot(fileName);
+ const fragment = await snapshot.getFragment();
+ snapshotFragment = { fragment, snapshot };
+ this.set(fileName, snapshotFragment);
+ }
+ return snapshotFragment;
+ }
+
+ async retrieveFragment(fileName: string) {
+ return (await this.retrieve(fileName)).fragment;
+ }
+}
diff --git a/tools/language-server/src/plugins/typescript/languageService.ts b/tools/language-server/src/plugins/typescript/languageService.ts
new file mode 100644
index 000000000..22e2b1cdd
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/languageService.ts
@@ -0,0 +1,184 @@
+/* eslint-disable require-jsdoc */
+
+import * as ts from 'typescript';
+import { basename } from 'path';
+import { ensureRealAstroFilePath, findTsConfigPath } from './utils';
+import { Document } from '../../core/documents';
+import { SnapshotManager } from './SnapshotManager';
+import { createDocumentSnapshot, DocumentSnapshot } from './DocumentSnapshot';
+import { createAstroModuleLoader } from './module-loader';
+
+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 newServicePromise = createLanguageService(tsconfigPath, workspaceRoot, docContext);
+ services.set(tsconfigPath, newServicePromise);
+ service = await newServicePromise;
+ }
+
+ 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: ['src'],
+ },
+ workspaceRoot || process.cwd()
+ );
+
+ const astroModuleLoader = createAstroModuleLoader(getScriptSnapshot, {});
+
+ const host: ts.LanguageServiceHost = {
+ getNewLine: () => ts.sys.newLine,
+ useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
+ readFile: astroModuleLoader.readFile,
+ writeFile: astroModuleLoader.writeFile,
+ fileExists: astroModuleLoader.fileExists,
+ directoryExists: astroModuleLoader.directoryExists,
+ getDirectories: astroModuleLoader.getDirectories,
+ readDirectory: astroModuleLoader.readDirectory,
+ realpath: astroModuleLoader.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.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 onProjectUpdated() {
+ projectVersion++;
+ }
+
+ 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 currentText = document ? document.getText() : null;
+ const snapshot = createDocumentSnapshot(filePath, currentText, docContext.createDocument);
+ snapshotManager.set(filePath, snapshot);
+ onProjectUpdated();
+ return snapshot;
+ }
+
+ function getScriptSnapshot(fileName: string): DocumentSnapshot {
+ fileName = ensureRealAstroFilePath(fileName);
+
+ let doc = snapshotManager.get(fileName);
+ if (doc) {
+ return doc;
+ }
+
+ doc = createDocumentSnapshot(fileName, null, 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: ['src'],
+ };
+}
+
+function getDefaultExclude() {
+ return ['dist', 'node_modules'];
+}
diff --git a/tools/language-server/src/plugins/typescript/module-loader.ts b/tools/language-server/src/plugins/typescript/module-loader.ts
new file mode 100644
index 000000000..2bcb206e7
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/module-loader.ts
@@ -0,0 +1,110 @@
+import ts from 'typescript';
+import type { DocumentSnapshot } from './SnapshotManager';
+import { isVirtualAstroFilePath, ensureRealAstroFilePath, getExtensionFromScriptKind } from './utils';
+import { createAstroSys } from './astro-sys';
+
+/**
+ * Caches resolved modules.
+ */
+class ModuleResolutionCache {
+ private cache = new Map<string, ts.ResolvedModule>();
+
+ /**
+ * Tries to get a cached module.
+ */
+ get(moduleName: string, containingFile: string): ts.ResolvedModule | undefined {
+ return this.cache.get(this.getKey(moduleName, containingFile));
+ }
+
+ /**
+ * Caches resolved module, if it is not undefined.
+ */
+ set(moduleName: string, containingFile: string, resolvedModule: ts.ResolvedModule | undefined) {
+ if (!resolvedModule) {
+ return;
+ }
+ this.cache.set(this.getKey(moduleName, containingFile), resolvedModule);
+ }
+
+ /**
+ * Deletes module from cache. Call this if a file was deleted.
+ * @param resolvedModuleName full path of the module
+ */
+ delete(resolvedModuleName: string): void {
+ this.cache.forEach((val, key) => {
+ if (val.resolvedFileName === resolvedModuleName) {
+ this.cache.delete(key);
+ }
+ });
+ }
+
+ private getKey(moduleName: string, containingFile: string) {
+ return containingFile + ':::' + ensureRealAstroFilePath(moduleName);
+ }
+}
+
+/**
+ * Creates a module loader specifically for `.astro` files.
+ *
+ * The typescript language service tries to look up other files that are referenced in the currently open astro file.
+ * For `.ts`/`.js` files this works, for `.astro` files it does not by default.
+ * Reason: The typescript language service does not know about the `.astro` file ending,
+ * so it assumes it's a normal typescript file and searches for files like `../Component.astro.ts`, which is wrong.
+ * In order to fix this, we need to wrap typescript's module resolution and reroute all `.astro.ts` file lookups to .astro.
+ *
+ * @param getSnapshot A function which returns a (in case of astro file fully preprocessed) typescript/javascript snapshot
+ * @param compilerOptions The typescript compiler options
+ */
+export function createAstroModuleLoader(getSnapshot: (fileName: string) => DocumentSnapshot, compilerOptions: ts.CompilerOptions) {
+ const astroSys = createAstroSys(getSnapshot);
+ const moduleCache = new ModuleResolutionCache();
+
+ return {
+ fileExists: astroSys.fileExists,
+ readFile: astroSys.readFile,
+ writeFile: astroSys.writeFile,
+ readDirectory: astroSys.readDirectory,
+ directoryExists: astroSys.directoryExists,
+ getDirectories: astroSys.getDirectories,
+ realpath: astroSys.realpath,
+ deleteFromModuleCache: (path: string) => moduleCache.delete(path),
+ resolveModuleNames,
+ };
+
+ function resolveModuleNames(moduleNames: string[], containingFile: string): Array<ts.ResolvedModule | undefined> {
+ return moduleNames.map((moduleName) => {
+ const cachedModule = moduleCache.get(moduleName, containingFile);
+ if (cachedModule) {
+ return cachedModule;
+ }
+
+ const resolvedModule = resolveModuleName(moduleName, containingFile);
+ moduleCache.set(moduleName, containingFile, resolvedModule);
+ return resolvedModule;
+ });
+ }
+
+ function resolveModuleName(name: string, containingFile: string): ts.ResolvedModule | undefined {
+ // Delegate to the TS resolver first.
+ // If that does not bring up anything, try the Astro Module loader
+ // which is able to deal with .astro files.
+ const tsResolvedModule = ts.resolveModuleName(name, containingFile, compilerOptions, ts.sys).resolvedModule;
+ if (tsResolvedModule && !isVirtualAstroFilePath(tsResolvedModule.resolvedFileName)) {
+ return tsResolvedModule;
+ }
+
+ const astroResolvedModule = ts.resolveModuleName(name, containingFile, compilerOptions, astroSys).resolvedModule;
+ if (!astroResolvedModule || !isVirtualAstroFilePath(astroResolvedModule.resolvedFileName)) {
+ return astroResolvedModule;
+ }
+
+ const resolvedFileName = ensureRealAstroFilePath(astroResolvedModule.resolvedFileName);
+ const snapshot = getSnapshot(resolvedFileName);
+
+ const resolvedastroModule: ts.ResolvedModuleFull = {
+ extension: getExtensionFromScriptKind(snapshot && snapshot.scriptKind),
+ resolvedFileName,
+ };
+ return resolvedastroModule;
+ }
+}
diff --git a/tools/language-server/src/plugins/typescript/utils.ts b/tools/language-server/src/plugins/typescript/utils.ts
new file mode 100644
index 000000000..9acbe2ed8
--- /dev/null
+++ b/tools/language-server/src/plugins/typescript/utils.ts
@@ -0,0 +1,236 @@
+import * as ts from 'typescript';
+import { CompletionItemKind, DiagnosticSeverity, Position, Range } from 'vscode-languageserver';
+import { dirname } from 'path';
+import { pathToUrl } from '../../utils';
+import { mapRangeToOriginal } from '../../core/documents';
+import { SnapshotFragment } from './DocumentSnapshot';
+
+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 getExtensionFromScriptKind(kind: ts.ScriptKind | undefined): ts.Extension {
+ switch (kind) {
+ case ts.ScriptKind.JSX:
+ return ts.Extension.Jsx;
+ case ts.ScriptKind.TS:
+ return ts.Extension.Ts;
+ case ts.ScriptKind.TSX:
+ return ts.Extension.Tsx;
+ case ts.ScriptKind.JSON:
+ return ts.Extension.Json;
+ case ts.ScriptKind.JS:
+ default:
+ return ts.Extension.Js;
+ }
+}
+
+export function convertRange(document: { positionAt: (offset: number) => Position }, range: { start?: number; length?: number }): Range {
+ return Range.create(document.positionAt(range.start || 0), document.positionAt((range.start || 0) + (range.length || 0)));
+}
+
+export function convertToLocationRange(defDoc: SnapshotFragment, textSpan: ts.TextSpan): Range {
+ const range = mapRangeToOriginal(defDoc, convertRange(defDoc, textSpan));
+ // Some definition like the svelte component class definition don't exist in the original, so we map to 0,1
+ if (range.start.line < 0) {
+ range.start.line = 0;
+ range.start.character = 1;
+ }
+ if (range.end.line < 0) {
+ range.end = range.start;
+ }
+
+ return range;
+}
+
+type FrameworkExt = 'astro' | 'vue' | 'jsx' | 'tsx' | 'svelte';
+
+export function isVirtualFrameworkFilePath(ext: FrameworkExt, filePath: string) {
+ return filePath.endsWith('.' + ext + '.ts');
+}
+
+export function isAstroFilePath(filePath: string) {
+ return filePath.endsWith('.astro');
+}
+
+export function isVirtualAstroFilePath(filePath: string) {
+ return isVirtualFrameworkFilePath('astro', filePath);
+}
+
+export function isVirtualVueFilePath(filePath: string) {
+ return isVirtualFrameworkFilePath('vue', filePath);
+}
+
+export function isVirtualJsxFilePath(filePath: string) {
+ return isVirtualFrameworkFilePath('jsx', filePath) || isVirtualFrameworkFilePath('tsx', filePath);
+}
+
+export function isVirtualSvelteFilePath(filePath: string) {
+ return isVirtualFrameworkFilePath('svelte', filePath);
+}
+
+export function isVirtualFilePath(filePath: string) {
+ return isVirtualAstroFilePath(filePath) || isVirtualVueFilePath(filePath) || isVirtualSvelteFilePath(filePath) || isVirtualJsxFilePath(filePath);
+}
+
+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 ensureRealFilePath(filePath: string) {
+ return isVirtualFilePath(filePath) ? filePath.slice(0, 3) : 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/language-server/src/types/index.d.ts b/tools/language-server/src/types/index.d.ts
new file mode 100644
index 000000000..e048b1a0c
--- /dev/null
+++ b/tools/language-server/src/types/index.d.ts
@@ -0,0 +1,4 @@
+/**
+ * Starts `astro-languageservice`
+ */
+export function startServer(): void {}
diff --git a/tools/language-server/src/utils.ts b/tools/language-server/src/utils.ts
new file mode 100644
index 000000000..ba3d9366e
--- /dev/null
+++ b/tools/language-server/src/utils.ts
@@ -0,0 +1,91 @@
+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));
+}
+
+export function isNotNullOrUndefined<T>(val: T | undefined | null): val is T {
+ return val !== undefined && val !== null;
+}
+
+/** 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);
+ };
+}