diff options
Diffstat (limited to 'tools/language-server/src')
36 files changed, 0 insertions, 4322 deletions
diff --git a/tools/language-server/src/core/config/ConfigManager.ts b/tools/language-server/src/core/config/ConfigManager.ts deleted file mode 100644 index 1e795ab96..000000000 --- a/tools/language-server/src/core/config/ConfigManager.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index cd869b795..000000000 --- a/tools/language-server/src/core/config/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ConfigManager'; diff --git a/tools/language-server/src/core/documents/Document.ts b/tools/language-server/src/core/documents/Document.ts deleted file mode 100644 index 04a460a08..000000000 --- a/tools/language-server/src/core/documents/Document.ts +++ /dev/null @@ -1,160 +0,0 @@ -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 deleted file mode 100644 index 299feeb62..000000000 --- a/tools/language-server/src/core/documents/DocumentBase.ts +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 7c9c168c1..000000000 --- a/tools/language-server/src/core/documents/DocumentManager.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 8a6a6ef29..000000000 --- a/tools/language-server/src/core/documents/DocumentMapper.ts +++ /dev/null @@ -1,317 +0,0 @@ -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 deleted file mode 100644 index 5dc0eb61f..000000000 --- a/tools/language-server/src/core/documents/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 71c7764d8..000000000 --- a/tools/language-server/src/core/documents/parseAstro.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index f5de5f292..000000000 --- a/tools/language-server/src/core/documents/parseHtml.ts +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index eb9d2060d..000000000 --- a/tools/language-server/src/core/documents/utils.ts +++ /dev/null @@ -1,250 +0,0 @@ -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 deleted file mode 100644 index e029684cb..000000000 --- a/tools/language-server/src/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -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 HTMLPlugin(docManager, configManager)); - pluginHost.register(new CSSPlugin(docManager, configManager)); - pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspaceUris)); - pluginHost.register(new AstroPlugin(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 - ':', - ], - }, - hoverProvider: true, - signatureHelpProvider: { - triggerCharacters: ['(', ',', '<'], - retriggerCharacters: [')'], - }, - }, - }; - }); - - // 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.onHover((evt) => pluginHost.doHover(evt.textDocument, evt.position)); - 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.onSignatureHelp((evt, cancellationToken) => pluginHost.getSignatureHelp(evt.textDocument, evt.position, evt.context, cancellationToken)); - - connection.listen(); -} diff --git a/tools/language-server/src/plugins/PluginHost.ts b/tools/language-server/src/plugins/PluginHost.ts deleted file mode 100644 index 3ad21a21c..000000000 --- a/tools/language-server/src/plugins/PluginHost.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { - CancellationToken, - CompletionContext, - CompletionItem, - DefinitionLink, - Location, - Position, - SignatureHelp, - SignatureHelpContext, - TextDocumentIdentifier, -} from 'vscode-languageserver'; -import type { DocumentManager } from '../core/documents'; -import type * as d from './interfaces'; -import { flatten } from '../utils'; -import { CompletionList } from 'vscode-languageserver'; -import { Hover, 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 doHover(textDocument: TextDocumentIdentifier, position: Position): Promise<Hover | null> { - const document = this.getDocument(textDocument.uri); - if (!document) { - throw new Error('Cannot call methods on an unopened document'); - } - - return this.execute<Hover>('doHover', [document, position], ExecuteMode.FirstNonNull); - } - - 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 }); - } - } - - async getSignatureHelp( - textDocument: TextDocumentIdentifier, - position: Position, - context: SignatureHelpContext | undefined, - cancellationToken: CancellationToken - ): Promise<SignatureHelp | null> { - const document = this.getDocument(textDocument.uri); - if (!document) { - throw new Error('Cannot call methods on an unopened document'); - } - - return await this.execute<any>('getSignatureHelp', [document, position, context, cancellationToken], ExecuteMode.FirstNonNull); - } - - 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) => { - let ret = this.tryExecutePlugin(plugin, name, args, []); - return ret; - }) - ); - 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 deleted file mode 100644 index 495f1859d..000000000 --- a/tools/language-server/src/plugins/astro/AstroPlugin.ts +++ /dev/null @@ -1,339 +0,0 @@ -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, - CompletionTriggerKind, - InsertTextFormat, - LocationLink, - FoldingRange, - MarkupContent, - MarkupKind, - Range, - TextEdit, -} from 'vscode-languageserver'; -import { Node } from 'vscode-html-languageservice'; -import { isPossibleClientComponent, pathToUrl, urlToPath } from '../../utils'; -import { toVirtualAstroFilePath } from '../typescript/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; - public pluginName = 'Astro'; - - 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); - } - - if (!this.isInsideFrontmatter(document, position)) { - const props = await this.getPropCompletions(document, position, completionContext); - if (props.length) { - items.push(...props); - } - } - - 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 { lang } = await this.tsLanguageServiceManager.getTypeScriptDoc(document); - const defs = this.getDefinitionsForComponentName(document, lang, componentName); - - if (!defs) { - return []; - } - - 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 async getPropCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise<CompletionItem[]> { - const offset = document.offsetAt(position); - const html = document.html; - - const node = html.findNodeAt(offset); - if (!this.isComponentTag(node)) { - return []; - } - const inAttribute = node.start + node.tag!.length < offset; - if (!inAttribute) { - return []; - } - - // If inside of attributes, skip. - if (completionContext && completionContext.triggerKind === CompletionTriggerKind.TriggerCharacter && completionContext.triggerCharacter === '"') { - return []; - } - - const componentName = node.tag!; - const { lang: thisLang } = await this.tsLanguageServiceManager.getTypeScriptDoc(document); - - const defs = this.getDefinitionsForComponentName(document, thisLang, componentName); - - if (!defs) { - return []; - } - - const defFilePath = ensureRealFilePath(defs[0].fileName); - - const lang = await this.tsLanguageServiceManager.getTypeScriptLangForPath(defFilePath); - const program = lang.getProgram(); - const sourceFile = program?.getSourceFile(toVirtualAstroFilePath(defFilePath)); - const typeChecker = program?.getTypeChecker(); - - if (!sourceFile || !typeChecker) { - return []; - } - - let propsNode = this.getPropsNode(sourceFile); - if (!propsNode) { - return []; - } - - const completionItems: CompletionItem[] = []; - - for (let type of typeChecker.getBaseTypes(propsNode as unknown as ts.InterfaceType)) { - type.symbol.members!.forEach((mem) => { - let item: CompletionItem = { - label: mem.name, - insertText: mem.name, - commitCharacters: [], - }; - - mem.getDocumentationComment(typeChecker); - let description = mem - .getDocumentationComment(typeChecker) - .map((val) => val.text) - .join('\n'); - - if (description) { - let docs: MarkupContent = { - kind: MarkupKind.Markdown, - value: description, - }; - item.documentation = docs; - } - completionItems.push(item); - }); - } - - for (let member of propsNode.members) { - if (!member.name) continue; - - let name = member.name.getText(); - let symbol = typeChecker.getSymbolAtLocation(member.name); - if (!symbol) continue; - let description = symbol - .getDocumentationComment(typeChecker) - .map((val) => val.text) - .join('\n'); - - let item: CompletionItem = { - label: name, - insertText: name, - commitCharacters: [], - }; - - if (description) { - let docs: MarkupContent = { - kind: MarkupKind.Markdown, - value: description, - }; - item.documentation = docs; - } - - completionItems.push(item); - } - - return completionItems; - } - - 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 getDefinitionsForComponentName(document: Document, lang: ts.LanguageService, componentName: string): readonly ts.DefinitionInfo[] | undefined { - const filePath = urlToPath(document.uri); - const tsFilePath = toVirtualAstroFilePath(filePath!); - - const sourceFile = lang.getProgram()?.getSourceFile(tsFilePath); - if (!sourceFile) { - return undefined; - } - - const specifier = this.getImportSpecifierForIdentifier(sourceFile, componentName); - if (!specifier) { - return []; - } - - const defs = lang.getDefinitionAtPosition(tsFilePath, specifier.getStart()); - if (!defs) { - return undefined; - } - - return defs; - } - - 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; - } - - private getPropsNode(sourceFile: ts.SourceFile): ts.InterfaceDeclaration | null { - let found: ts.InterfaceDeclaration | null = null; - ts.forEachChild(sourceFile, (node) => { - if (isNodeExported(node)) { - if (ts.isInterfaceDeclaration(node)) { - if (ts.getNameOfDeclaration(node)?.getText() === 'Props') { - found = node; - } - } - } - }); - - return found; - } -} - -function isNodeExported(node: ts.Node): boolean { - return (ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export) !== 0 || (!!node.parent && node.parent.kind === ts.SyntaxKind.SourceFile); -} diff --git a/tools/language-server/src/plugins/css/CSSDocument.ts b/tools/language-server/src/plugins/css/CSSDocument.ts deleted file mode 100644 index 9f1839678..000000000 --- a/tools/language-server/src/plugins/css/CSSDocument.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 3083edc56..000000000 --- a/tools/language-server/src/plugins/css/CSSPlugin.ts +++ /dev/null @@ -1,119 +0,0 @@ -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(['.', ':', '-', '/']); - public pluginName = 'CSS'; - - 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 deleted file mode 100644 index e00398037..000000000 --- a/tools/language-server/src/plugins/css/StyleAttributeDocument.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index 45acb5ad6..000000000 --- a/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 78b11296e..000000000 --- a/tools/language-server/src/plugins/css/service.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 49ca46b86..000000000 --- a/tools/language-server/src/plugins/html/HTMLPlugin.ts +++ /dev/null @@ -1,142 +0,0 @@ -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; - public pluginName = 'HTML'; - - 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 offset = document.offsetAt(position); - const node = html.findNodeAt(offset); - - if (this.isComponentTag(node)) { - 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)); - } - - private isComponentTag(node: Node): boolean { - if (!node.tag) { - return false; - } - const firstChar = node.tag[0]; - return /[A-Z]/.test(firstChar); - } -} diff --git a/tools/language-server/src/plugins/index.ts b/tools/language-server/src/plugins/index.ts deleted file mode 100644 index bb73cbe5e..000000000 --- a/tools/language-server/src/plugins/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 84e4cbda3..000000000 --- a/tools/language-server/src/plugins/interfaces.ts +++ /dev/null @@ -1,171 +0,0 @@ -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; -} - -interface NamedPlugin { - pluginName: string; -} - -export type Plugin = Partial<NamedPlugin & 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 deleted file mode 100644 index 89f8c400e..000000000 --- a/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts +++ /dev/null @@ -1,263 +0,0 @@ -import * as ts from 'typescript'; -import { readFileSync } from 'fs'; -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'; - -const ASTRO_DEFINITION = readFileSync(require.resolve('../../../astro.d.ts')); - -/** - * 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() { - let raw = this.doc.getText(); - return this.transformContent(raw); - } - - /** @internal */ - private transformContent(content: string) { - return ( - content.replace(/---/g, '///') + - // Add TypeScript definitions - ASTRO_DEFINITION - ); - } - - 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, '///') + - // Add TypeScript definitions - ASTRO_DEFINITION - ); - } - - 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; - } - - getOriginalPosition(pos: Position): Position { - return pos; - } - - 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 deleted file mode 100644 index 9ff71abf7..000000000 --- a/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts +++ /dev/null @@ -1,85 +0,0 @@ -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, getLanguageServiceForPath, 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 getTypeScriptLangForPath(filePath: string): Promise<ts.LanguageService> { - return getLanguageServiceForPath(filePath, this.workspaceUris, this.docContext); - } - - 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 deleted file mode 100644 index 5a406b945..000000000 --- a/tools/language-server/src/plugins/typescript/SnapshotManager.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index db5e701b4..000000000 --- a/tools/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { ConfigManager } from '../../core/config'; -import type { CompletionsProvider, AppCompletionItem, AppCompletionList } from '../interfaces'; -import type { CancellationToken, Hover, SignatureHelp, SignatureHelpContext } from 'vscode-languageserver'; -import { join as pathJoin, dirname as pathDirname } from 'path'; -import { Document, DocumentManager, isInsideFrontmatter } from '../../core/documents'; -import { SourceFile, ImportDeclaration, Node, SyntaxKind } from 'typescript'; -import { CompletionContext, DefinitionLink, FileChangeType, Position, LocationLink } from 'vscode-languageserver'; -import * as ts from 'typescript'; -import { LanguageServiceManager } from './LanguageServiceManager'; -import { SnapshotManager } from './SnapshotManager'; -import { convertToLocationRange, isVirtualAstroFilePath, isVirtualFilePath, getScriptKindFromFileName } from './utils'; -import { isNotNullOrUndefined, pathToUrl } from '../../utils'; -import { CompletionsProviderImpl, CompletionEntryWithIdentifer } from './features/CompletionsProvider'; -import { HoverProviderImpl } from './features/HoverProvider'; -import { isNoTextSpanInGeneratedCode, SnapshotFragmentMap } from './features/utils'; -import { SignatureHelpProviderImpl } from './features/SignatureHelpProvider'; - -type BetterTS = typeof ts & { - getTouchingPropertyName(sourceFile: SourceFile, pos: number): Node; -}; - -export class TypeScriptPlugin implements CompletionsProvider { - private readonly docManager: DocumentManager; - private readonly configManager: ConfigManager; - private readonly languageServiceManager: LanguageServiceManager; - public pluginName = 'TypeScript'; - - private readonly completionProvider: CompletionsProviderImpl; - private readonly hoverProvider: HoverProviderImpl; - private readonly signatureHelpProvider: SignatureHelpProviderImpl; - - 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); - this.hoverProvider = new HoverProviderImpl(this.languageServiceManager); - this.signatureHelpProvider = new SignatureHelpProviderImpl(this.languageServiceManager); - } - - async doHover(document: Document, position: Position): Promise<Hover | null> { - return this.hoverProvider.doHover(document, position); - } - - 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 fragmentPosition = mainFragment.getGeneratedPosition(position); - const fragmentOffset = mainFragment.offsetAt(fragmentPosition); - - let defs = lang.getDefinitionAndBoundSpan(tsFilePath, fragmentOffset); - - if (!defs || !defs.definitions) { - return []; - } - - // Resolve all imports if we can - if (this.goToDefinitionFoundOnlyAlias(tsFilePath, defs.definitions!)) { - let importDef = this.getGoToDefinitionRefsForImportSpecifier(tsFilePath, fragmentOffset, lang); - if (importDef) { - defs = importDef; - } - } - - 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; - const textSpan = isVirtualAstroFilePath(tsFilePath) ? { start: 0, length: 0 } : def.textSpan; - return LocationLink.create( - pathToUrl(fileName), - convertToLocationRange(fragment, textSpan), - convertToLocationRange(fragment, 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); - } - } - - async getSignatureHelp(document: Document, position: Position, context: SignatureHelpContext | undefined, cancellationToken?: CancellationToken): Promise<SignatureHelp | null> { - return this.signatureHelpProvider.getSignatureHelp(document, position, context, cancellationToken); - } - - /** - * - * @internal - */ - public async getSnapshotManager(fileName: string) { - return this.languageServiceManager.getSnapshotManager(fileName); - } - - private isInsideFrontmatter(document: Document, position: Position) { - return isInsideFrontmatter(document.getText(), document.offsetAt(position)); - } - - private goToDefinitionFoundOnlyAlias(tsFileName: string, defs: readonly ts.DefinitionInfo[]) { - return !!(defs.length === 1 && defs[0].kind === 'alias' && defs[0].fileName === tsFileName); - } - - private getGoToDefinitionRefsForImportSpecifier(tsFilePath: string, offset: number, lang: ts.LanguageService): ts.DefinitionInfoAndBoundSpan | undefined { - const program = lang.getProgram(); - const sourceFile = program?.getSourceFile(tsFilePath); - if (sourceFile) { - let node = (ts as BetterTS).getTouchingPropertyName(sourceFile, offset); - if (node && node.kind === SyntaxKind.Identifier) { - if (node.parent.kind === SyntaxKind.ImportClause) { - let decl = node.parent.parent as ImportDeclaration; - let spec = ts.isStringLiteral(decl.moduleSpecifier) && decl.moduleSpecifier.text; - if (spec) { - let fileName = pathJoin(pathDirname(tsFilePath), spec); - let start = node.pos + 1; - let def: ts.DefinitionInfoAndBoundSpan = { - definitions: [ - { - kind: 'alias', - fileName, - name: '', - containerKind: '', - containerName: '', - textSpan: { - start: 0, - length: 0, - }, - } as ts.DefinitionInfo, - ], - textSpan: { - start, - length: node.end - start, - }, - }; - return def; - } - } - } - } - } -} diff --git a/tools/language-server/src/plugins/typescript/astro-sys.ts b/tools/language-server/src/plugins/typescript/astro-sys.ts deleted file mode 100644 index c8d23254d..000000000 --- a/tools/language-server/src/plugins/typescript/astro-sys.ts +++ /dev/null @@ -1,39 +0,0 @@ -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) { - 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 deleted file mode 100644 index da4a5cd54..000000000 --- a/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { CompletionContext, CompletionItem, Position, TextDocumentIdentifier, MarkupContent } from 'vscode-languageserver'; -import type { LanguageServiceManager } from '../LanguageServiceManager'; -import { isInsideFrontmatter } from '../../../core/documents/utils'; -import { Document } from '../../../core/documents'; -import * as ts from 'typescript'; -import { CompletionList, MarkupKind } from 'vscode-languageserver'; -import { AppCompletionItem, AppCompletionList, CompletionsProvider } from '../../interfaces'; -import { scriptElementKindToCompletionItemKind, getCommitCharactersForScriptElement, toVirtualAstroFilePath } from '../utils'; - -const completionOptions: ts.GetCompletionsAtPositionOptions = Object.freeze({ - importModuleSpecifierPreference: 'relative', - importModuleSpecifierEnding: 'js', - quotePreference: 'single', -}); - -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, completionOptions)?.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 = toVirtualAstroFilePath(tsDoc.filePath); - - if (!comp || !filePath) { - return completionItem; - } - - const fragment = await tsDoc.getFragment(); - const detail = lang.getCompletionEntryDetails( - filePath, // fileName - fragment.offsetAt(comp.position), // position - comp.name, // entryName - {}, // formatOptions - comp.source, // source - {}, // preferences - comp.data // data - ); - - if (detail) { - const { detail: itemDetail, documentation: itemDocumentation } = this.getCompletionDocument(detail); - - completionItem.detail = itemDetail; - completionItem.documentation = itemDocumentation; - } - - 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/HoverProvider.ts b/tools/language-server/src/plugins/typescript/features/HoverProvider.ts deleted file mode 100644 index f772bc390..000000000 --- a/tools/language-server/src/plugins/typescript/features/HoverProvider.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { LanguageServiceManager } from '../LanguageServiceManager'; -import ts from 'typescript'; -import { Hover, Position } from 'vscode-languageserver'; -import { Document, mapObjWithRangeToOriginal } from '../../../core/documents'; -import { HoverProvider } from '../../interfaces'; -import { getMarkdownDocumentation } from '../previewer'; -import { convertRange, toVirtualAstroFilePath } from '../utils'; - -export class HoverProviderImpl implements HoverProvider { - constructor(private readonly lang: LanguageServiceManager) {} - - async doHover(document: Document, position: Position): Promise<Hover | null> { - const { lang, tsDoc } = await this.getLSAndTSDoc(document); - const fragment = await tsDoc.getFragment(); - - const offset = fragment.offsetAt(fragment.getGeneratedPosition(position)); - const filePath = toVirtualAstroFilePath(tsDoc.filePath); - let info = lang.getQuickInfoAtPosition(filePath, offset); - if (!info) { - return null; - } - - const textSpan = info.textSpan; - - const declaration = ts.displayPartsToString(info.displayParts); - const documentation = getMarkdownDocumentation(info.documentation, info.tags); - - // https://microsoft.github.io/language-server-protocol/specification#textDocument_hover - const contents = ['```typescript', declaration, '```'].concat(documentation ? ['---', documentation] : []).join('\n'); - - return mapObjWithRangeToOriginal(fragment, { - range: convertRange(fragment, textSpan), - contents, - }); - } - - private async getLSAndTSDoc(document: Document) { - return this.lang.getTypeScriptDoc(document); - } -} diff --git a/tools/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts b/tools/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts deleted file mode 100644 index 93bad724f..000000000 --- a/tools/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { LanguageServiceManager } from '../LanguageServiceManager'; -import type { SignatureHelpProvider } from '../../interfaces'; -import ts from 'typescript'; -import { - Position, - SignatureHelpContext, - SignatureHelp, - SignatureHelpTriggerKind, - SignatureInformation, - ParameterInformation, - MarkupKind, - CancellationToken, -} from 'vscode-languageserver'; -import { Document } from '../../../core/documents'; -import { getMarkdownDocumentation } from '../previewer'; -import { toVirtualAstroFilePath } from '../utils'; - -export class SignatureHelpProviderImpl implements SignatureHelpProvider { - constructor(private readonly lang: LanguageServiceManager) {} - - private static readonly triggerCharacters = ['(', ',', '<']; - private static readonly retriggerCharacters = [')']; - - async getSignatureHelp(document: Document, position: Position, context: SignatureHelpContext | undefined, cancellationToken?: CancellationToken): Promise<SignatureHelp | null> { - const { lang, tsDoc } = await this.lang.getTypeScriptDoc(document); - const fragment = await tsDoc.getFragment(); - - if (cancellationToken?.isCancellationRequested) { - return null; - } - - const offset = fragment.offsetAt(fragment.getGeneratedPosition(position)); - const triggerReason = this.toTsTriggerReason(context); - const info = lang.getSignatureHelpItems(toVirtualAstroFilePath(tsDoc.filePath), offset, triggerReason ? { triggerReason } : undefined); - if (!info || info.items.some((signature) => this.isInSvelte2tsxGeneratedFunction(signature))) { - return null; - } - - const signatures = info.items.map(this.toSignatureHelpInformation); - - return { - signatures, - activeSignature: info.selectedItemIndex, - activeParameter: info.argumentIndex, - }; - } - - private isReTrigger(isRetrigger: boolean, triggerCharacter: string): triggerCharacter is ts.SignatureHelpRetriggerCharacter { - return isRetrigger && (this.isTriggerCharacter(triggerCharacter) || SignatureHelpProviderImpl.retriggerCharacters.includes(triggerCharacter)); - } - - private isTriggerCharacter(triggerCharacter: string): triggerCharacter is ts.SignatureHelpTriggerCharacter { - return SignatureHelpProviderImpl.triggerCharacters.includes(triggerCharacter); - } - - /** - * adopted from https://github.com/microsoft/vscode/blob/265a2f6424dfbd3a9788652c7d376a7991d049a3/extensions/typescript-language-features/src/languageFeatures/signatureHelp.ts#L103 - */ - private toTsTriggerReason(context: SignatureHelpContext | undefined): ts.SignatureHelpTriggerReason { - switch (context?.triggerKind) { - case SignatureHelpTriggerKind.TriggerCharacter: - if (context.triggerCharacter) { - if (this.isReTrigger(context.isRetrigger, context.triggerCharacter)) { - return { kind: 'retrigger', triggerCharacter: context.triggerCharacter }; - } - if (this.isTriggerCharacter(context.triggerCharacter)) { - return { - kind: 'characterTyped', - triggerCharacter: context.triggerCharacter, - }; - } - } - return { kind: 'invoked' }; - case SignatureHelpTriggerKind.ContentChange: - return context.isRetrigger ? { kind: 'retrigger' } : { kind: 'invoked' }; - - case SignatureHelpTriggerKind.Invoked: - default: - return { kind: 'invoked' }; - } - } - - /** - * adopted from https://github.com/microsoft/vscode/blob/265a2f6424dfbd3a9788652c7d376a7991d049a3/extensions/typescript-language-features/src/languageFeatures/signatureHelp.ts#L73 - */ - private toSignatureHelpInformation(item: ts.SignatureHelpItem): SignatureInformation { - const [prefixLabel, separatorLabel, suffixLabel] = [item.prefixDisplayParts, item.separatorDisplayParts, item.suffixDisplayParts].map(ts.displayPartsToString); - - let textIndex = prefixLabel.length; - let signatureLabel = ''; - const parameters: ParameterInformation[] = []; - const lastIndex = item.parameters.length - 1; - - item.parameters.forEach((parameter, index) => { - const label = ts.displayPartsToString(parameter.displayParts); - - const startIndex = textIndex; - const endIndex = textIndex + label.length; - const doc = ts.displayPartsToString(parameter.documentation); - - signatureLabel += label; - parameters.push(ParameterInformation.create([startIndex, endIndex], doc)); - - if (index < lastIndex) { - textIndex = endIndex + separatorLabel.length; - signatureLabel += separatorLabel; - } - }); - const signatureDocumentation = getMarkdownDocumentation( - item.documentation, - item.tags.filter((tag) => tag.name !== 'param') - ); - - return { - label: prefixLabel + signatureLabel + suffixLabel, - documentation: signatureDocumentation - ? { - value: signatureDocumentation, - kind: MarkupKind.Markdown, - } - : undefined, - parameters, - }; - } - - private isInSvelte2tsxGeneratedFunction(signatureHelpItem: ts.SignatureHelpItem) { - return signatureHelpItem.prefixDisplayParts.some((part) => part.text.includes('__sveltets')); - } -} diff --git a/tools/language-server/src/plugins/typescript/features/utils.ts b/tools/language-server/src/plugins/typescript/features/utils.ts deleted file mode 100644 index 8c87dc5f4..000000000 --- a/tools/language-server/src/plugins/typescript/features/utils.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 22e2b1cdd..000000000 --- a/tools/language-server/src/plugins/typescript/languageService.ts +++ /dev/null @@ -1,184 +0,0 @@ -/* 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 deleted file mode 100644 index 2bcb206e7..000000000 --- a/tools/language-server/src/plugins/typescript/module-loader.ts +++ /dev/null @@ -1,110 +0,0 @@ -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/previewer.ts b/tools/language-server/src/plugins/typescript/previewer.ts deleted file mode 100644 index 710da4c17..000000000 --- a/tools/language-server/src/plugins/typescript/previewer.ts +++ /dev/null @@ -1,125 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * adopted from https://github.com/microsoft/vscode/blob/10722887b8629f90cc38ee7d90d54e8246dc895f/extensions/typescript-language-features/src/utils/previewer.ts - */ - -import ts from 'typescript'; -import { isNotNullOrUndefined } from '../../utils'; - -function replaceLinks(text: string): string { - return ( - text - // Http(s) links - .replace(/\{@(link|linkplain|linkcode) (https?:\/\/[^ |}]+?)(?:[| ]([^{}\n]+?))?\}/gi, (_, tag: string, link: string, text?: string) => { - switch (tag) { - case 'linkcode': - return `[\`${text ? text.trim() : link}\`](${link})`; - - default: - return `[${text ? text.trim() : link}](${link})`; - } - }) - ); -} - -function processInlineTags(text: string): string { - return replaceLinks(text); -} - -function getTagBodyText(tag: ts.JSDocTagInfo): string | undefined { - if (!tag.text) { - return undefined; - } - - // Convert to markdown code block if it is not already one - function makeCodeblock(text: string): string { - if (text.match(/^\s*[~`]{3}/g)) { - return text; - } - return '```\n' + text + '\n```'; - } - - function makeExampleTag(text: string) { - // check for caption tags, fix for https://github.com/microsoft/vscode/issues/79704 - const captionTagMatches = text.match(/<caption>(.*?)<\/caption>\s*(\r\n|\n)/); - if (captionTagMatches && captionTagMatches.index === 0) { - return captionTagMatches[1] + '\n\n' + makeCodeblock(text.substr(captionTagMatches[0].length)); - } else { - return makeCodeblock(text); - } - } - - function makeEmailTag(text: string) { - // fix obsucated email address, https://github.com/microsoft/vscode/issues/80898 - const emailMatch = text.match(/(.+)\s<([-.\w]+@[-.\w]+)>/); - - if (emailMatch === null) { - return text; - } else { - return `${emailMatch[1]} ${emailMatch[2]}`; - } - } - - switch (tag.name) { - case 'example': - return makeExampleTag(ts.displayPartsToString(tag.text)); - case 'author': - return makeEmailTag(ts.displayPartsToString(tag.text)); - case 'default': - return makeCodeblock(ts.displayPartsToString(tag.text)); - } - - return processInlineTags(ts.displayPartsToString(tag.text)); -} - -export function getTagDocumentation(tag: ts.JSDocTagInfo): string | undefined { - function getWithType() { - const body = (ts.displayPartsToString(tag.text) || '').split(/^(\S+)\s*-?\s*/); - if (body?.length === 3) { - const param = body[1]; - const doc = body[2]; - const label = `*@${tag.name}* \`${param}\``; - if (!doc) { - return label; - } - return label + (doc.match(/\r\n|\n/g) ? ' \n' + processInlineTags(doc) : ` — ${processInlineTags(doc)}`); - } - } - - switch (tag.name) { - case 'augments': - case 'extends': - case 'param': - case 'template': - return getWithType(); - } - - // Generic tag - const label = `*@${tag.name}*`; - const text = getTagBodyText(tag); - if (!text) { - return label; - } - return label + (text.match(/\r\n|\n/g) ? ' \n' + text : ` — ${text}`); -} - -export function plain(parts: ts.SymbolDisplayPart[] | string): string { - return processInlineTags(typeof parts === 'string' ? parts : ts.displayPartsToString(parts)); -} - -export function getMarkdownDocumentation(documentation: ts.SymbolDisplayPart[] | undefined, tags: ts.JSDocTagInfo[] | undefined) { - let result: Array<string | undefined> = []; - if (documentation) { - result.push(plain(documentation)); - } - - if (tags) { - result = result.concat(tags.map(getTagDocumentation)); - } - - return result.filter(isNotNullOrUndefined).join('\n\n'); -} diff --git a/tools/language-server/src/plugins/typescript/utils.ts b/tools/language-server/src/plugins/typescript/utils.ts deleted file mode 100644 index a1a748946..000000000 --- a/tools/language-server/src/plugins/typescript/utils.ts +++ /dev/null @@ -1,239 +0,0 @@ -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) { - if (isVirtualFrameworkFilePath('astro', filePath)) { - return filePath; - } - 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, filePath.length - 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 deleted file mode 100644 index e048b1a0c..000000000 --- a/tools/language-server/src/types/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Starts `astro-languageservice` - */ -export function startServer(): void {} diff --git a/tools/language-server/src/utils.ts b/tools/language-server/src/utils.ts deleted file mode 100644 index ba3d9366e..000000000 --- a/tools/language-server/src/utils.ts +++ /dev/null @@ -1,91 +0,0 @@ -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); - }; -} |