diff options
Diffstat (limited to 'tools/language-server/src')
33 files changed, 3737 insertions, 0 deletions
diff --git a/tools/language-server/src/core/config/ConfigManager.ts b/tools/language-server/src/core/config/ConfigManager.ts new file mode 100644 index 000000000..1e795ab96 --- /dev/null +++ b/tools/language-server/src/core/config/ConfigManager.ts @@ -0,0 +1,13 @@ +import { VSCodeEmmetConfig } from 'vscode-emmet-helper'; + +export class ConfigManager { + private emmetConfig: VSCodeEmmetConfig = {}; + + updateEmmetConfig(config: VSCodeEmmetConfig): void { + this.emmetConfig = config || {}; + } + + getEmmetConfig(): VSCodeEmmetConfig { + return this.emmetConfig; + } +} diff --git a/tools/language-server/src/core/config/index.ts b/tools/language-server/src/core/config/index.ts new file mode 100644 index 000000000..cd869b795 --- /dev/null +++ b/tools/language-server/src/core/config/index.ts @@ -0,0 +1 @@ +export * from './ConfigManager'; diff --git a/tools/language-server/src/core/documents/Document.ts b/tools/language-server/src/core/documents/Document.ts new file mode 100644 index 000000000..04a460a08 --- /dev/null +++ b/tools/language-server/src/core/documents/Document.ts @@ -0,0 +1,160 @@ +import type { TagInformation } from './utils'; +import { Position, Range } from 'vscode-languageserver'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { HTMLDocument } from 'vscode-html-languageservice'; + +import { clamp, urlToPath } from '../../utils'; +import { parseHtml } from './parseHtml'; +import { parseAstro, AstroDocument } from './parseAstro'; +import { extractStyleTag } from './utils'; + +export class Document implements TextDocument { + private content: string; + + languageId = 'astro'; + version = 0; + html!: HTMLDocument; + astro!: AstroDocument; + styleInfo: TagInformation | null = null; + + constructor(public uri: string, text: string) { + this.content = text; + this.updateDocInfo(); + } + + private updateDocInfo() { + this.html = parseHtml(this.content); + this.astro = parseAstro(this.content); + this.styleInfo = extractStyleTag(this.content, this.html); + if (this.styleInfo) { + this.styleInfo.attributes.lang = 'css'; + } + } + + setText(text: string) { + this.content = text; + this.version++; + this.updateDocInfo(); + } + + /** + * Update the text between two positions. + * @param text The new text slice + * @param start Start offset of the new text + * @param end End offset of the new text + */ + update(text: string, start: number, end: number): void { + const content = this.getText(); + this.setText(content.slice(0, start) + text + content.slice(end)); + } + + getText(): string { + return this.content; + } + + /** + * Get the line and character based on the offset + * @param offset The index of the position + */ + positionAt(offset: number): Position { + offset = clamp(offset, 0, this.getTextLength()); + + const lineOffsets = this.getLineOffsets(); + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return Position.create(0, offset); + } + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + return Position.create(line, offset - lineOffsets[line]); + } + + /** + * Get the index of the line and character position + * @param position Line and character position + */ + offsetAt(position: Position): number { + const lineOffsets = this.getLineOffsets(); + + if (position.line >= lineOffsets.length) { + return this.getTextLength(); + } else if (position.line < 0) { + return 0; + } + + const lineOffset = lineOffsets[position.line]; + const nextLineOffset = position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : this.getTextLength(); + + return clamp(nextLineOffset, lineOffset, lineOffset + position.character); + } + + getLineUntilOffset(offset: number): string { + const { line, character } = this.positionAt(offset); + return this.lines[line].slice(0, character); + } + + private getLineOffsets() { + const lineOffsets = []; + const text = this.getText(); + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + return lineOffsets; + } + + /** + * Get the length of the document's content + */ + getTextLength(): number { + return this.getText().length; + } + + /** + * Returns the file path if the url scheme is file + */ + getFilePath(): string | null { + return urlToPath(this.uri); + } + + /** + * Get URL file path. + */ + getURL() { + return this.uri; + } + + get lines(): string[] { + return this.getText().split(/\r?\n/); + } + + get lineCount(): number { + return this.lines.length; + } +} diff --git a/tools/language-server/src/core/documents/DocumentBase.ts b/tools/language-server/src/core/documents/DocumentBase.ts new file mode 100644 index 000000000..299feeb62 --- /dev/null +++ b/tools/language-server/src/core/documents/DocumentBase.ts @@ -0,0 +1,141 @@ +import { clamp } from '../../utils'; +import { Position, TextDocument } from 'vscode-languageserver'; + +/** + * Represents a textual document. + */ +export abstract class ReadableDocument implements TextDocument { + /** + * Get the text content of the document + */ + abstract getText(): string; + + /** + * Returns the url of the document + */ + abstract getURL(): string; + + /** + * Returns the file path if the url scheme is file + */ + abstract getFilePath(): string | null; + + /** + * Current version of the document. + */ + public version = 0; + + /** + * Get the length of the document's content + */ + getTextLength(): number { + return this.getText().length; + } + + /** + * Get the line and character based on the offset + * @param offset The index of the position + */ + positionAt(offset: number): Position { + offset = clamp(offset, 0, this.getTextLength()); + + const lineOffsets = this.getLineOffsets(); + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return Position.create(0, offset); + } + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + return Position.create(line, offset - lineOffsets[line]); + } + + /** + * Get the index of the line and character position + * @param position Line and character position + */ + offsetAt(position: Position): number { + const lineOffsets = this.getLineOffsets(); + + if (position.line >= lineOffsets.length) { + return this.getTextLength(); + } else if (position.line < 0) { + return 0; + } + + const lineOffset = lineOffsets[position.line]; + const nextLineOffset = position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : this.getTextLength(); + + return clamp(nextLineOffset, lineOffset, lineOffset + position.character); + } + + private getLineOffsets() { + const lineOffsets = []; + const text = this.getText(); + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + return lineOffsets; + } + + /** + * Implements TextDocument + */ + get uri(): string { + return this.getURL(); + } + + get lineCount(): number { + return this.getText().split(/\r?\n/).length; + } + + abstract languageId: string; +} + +/** + * Represents a textual document that can be manipulated. + */ +export abstract class WritableDocument extends ReadableDocument { + /** + * Set the text content of the document + * @param text The new text content + */ + abstract setText(text: string): void; + + /** + * Update the text between two positions. + * @param text The new text slice + * @param start Start offset of the new text + * @param end End offset of the new text + */ + update(text: string, start: number, end: number): void { + const content = this.getText(); + this.setText(content.slice(0, start) + text + content.slice(end)); + } +} diff --git a/tools/language-server/src/core/documents/DocumentManager.ts b/tools/language-server/src/core/documents/DocumentManager.ts new file mode 100644 index 000000000..7c9c168c1 --- /dev/null +++ b/tools/language-server/src/core/documents/DocumentManager.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from 'events'; +import { TextDocumentContentChangeEvent, TextDocumentItem } from 'vscode-languageserver'; +import { Document } from './Document'; +import { normalizeUri } from '../../utils'; + +export type DocumentEvent = 'documentOpen' | 'documentChange' | 'documentClose'; + +export class DocumentManager { + private emitter = new EventEmitter(); + private openedInClient = new Set<string>(); + private documents: Map<string, Document> = new Map(); + private locked = new Set<string>(); + private deleteCandidates = new Set<string>(); + + constructor(private createDocument: (textDocument: { uri: string; text: string }) => Document) {} + + get(uri: string) { + return this.documents.get(normalizeUri(uri)); + } + + openDocument(textDocument: TextDocumentItem) { + let document: Document; + if (this.documents.has(textDocument.uri)) { + document = this.get(textDocument.uri) as Document; + document.setText(textDocument.text); + } else { + document = this.createDocument(textDocument); + this.documents.set(normalizeUri(textDocument.uri), document); + this.notify('documentOpen', document); + } + + this.notify('documentChange', document); + + return document; + } + + closeDocument(uri: string) { + uri = normalizeUri(uri); + + const document = this.documents.get(uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + this.notify('documentClose', document); + + // Some plugin may prevent a document from actually being closed. + if (!this.locked.has(uri)) { + this.documents.delete(uri); + } else { + this.deleteCandidates.add(uri); + } + + this.openedInClient.delete(uri); + } + + updateDocument(uri: string, changes: TextDocumentContentChangeEvent[]) { + const document = this.documents.get(normalizeUri(uri)); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + for (const change of changes) { + let start = 0; + let end = 0; + if ('range' in change) { + start = document.offsetAt(change.range.start); + end = document.offsetAt(change.range.end); + } else { + end = document.getTextLength(); + } + + document.update(change.text, start, end); + } + + this.notify('documentChange', document); + } + + markAsOpenedInClient(uri: string) { + this.openedInClient.add(normalizeUri(uri)); + } + + getAllOpenedByClient() { + return Array.from(this.documents.entries()).filter((doc) => this.openedInClient.has(doc[0])); + } + + on(name: DocumentEvent, listener: (document: Document) => void) { + this.emitter.on(name, listener); + } + + private notify(name: DocumentEvent, document: Document) { + this.emitter.emit(name, document); + } +} diff --git a/tools/language-server/src/core/documents/DocumentMapper.ts b/tools/language-server/src/core/documents/DocumentMapper.ts new file mode 100644 index 000000000..8a6a6ef29 --- /dev/null +++ b/tools/language-server/src/core/documents/DocumentMapper.ts @@ -0,0 +1,317 @@ +import { + Position, + Range, + CompletionItem, + Hover, + Diagnostic, + ColorPresentation, + SymbolInformation, + LocationLink, + TextDocumentEdit, + CodeAction, + SelectionRange, + TextEdit, + InsertReplaceEdit, +} from 'vscode-languageserver'; +import { TagInformation, offsetAt, positionAt } from './utils'; +import { SourceMapConsumer } from 'source-map'; + +export interface DocumentMapper { + /** + * Map the generated position to the original position + * @param generatedPosition Position in fragment + */ + getOriginalPosition(generatedPosition: Position): Position; + + /** + * Map the original position to the generated position + * @param originalPosition Position in parent + */ + getGeneratedPosition(originalPosition: Position): Position; + + /** + * Returns true if the given original position is inside of the generated map + * @param pos Position in original + */ + isInGenerated(pos: Position): boolean; + + /** + * Get document URL + */ + getURL(): string; + + /** + * Implement this if you need teardown logic before this mapper gets cleaned up. + */ + destroy?(): void; +} + +/** + * Does not map, returns positions as is. + */ +export class IdentityMapper implements DocumentMapper { + constructor(private url: string, private parent?: DocumentMapper) {} + + getOriginalPosition(generatedPosition: Position): Position { + if (this.parent) { + generatedPosition = this.getOriginalPosition(generatedPosition); + } + + return generatedPosition; + } + + getGeneratedPosition(originalPosition: Position): Position { + if (this.parent) { + originalPosition = this.getGeneratedPosition(originalPosition); + } + + return originalPosition; + } + + isInGenerated(position: Position): boolean { + if (this.parent && !this.parent.isInGenerated(position)) { + return false; + } + + return true; + } + + getURL(): string { + return this.url; + } + + destroy() { + this.parent?.destroy?.(); + } +} + +/** + * Maps positions in a fragment relative to a parent. + */ +export class FragmentMapper implements DocumentMapper { + constructor(private originalText: string, private tagInfo: TagInformation, private url: string) {} + + getOriginalPosition(generatedPosition: Position): Position { + const parentOffset = this.offsetInParent(offsetAt(generatedPosition, this.tagInfo.content)); + return positionAt(parentOffset, this.originalText); + } + + private offsetInParent(offset: number): number { + return this.tagInfo.start + offset; + } + + getGeneratedPosition(originalPosition: Position): Position { + const fragmentOffset = offsetAt(originalPosition, this.originalText) - this.tagInfo.start; + return positionAt(fragmentOffset, this.tagInfo.content); + } + + isInGenerated(pos: Position): boolean { + const offset = offsetAt(pos, this.originalText); + return offset >= this.tagInfo.start && offset <= this.tagInfo.end; + } + + getURL(): string { + return this.url; + } +} + +export class SourceMapDocumentMapper implements DocumentMapper { + constructor(protected consumer: SourceMapConsumer, protected sourceUri: string, private parent?: DocumentMapper) {} + + getOriginalPosition(generatedPosition: Position): Position { + if (this.parent) { + generatedPosition = this.parent.getOriginalPosition(generatedPosition); + } + + if (generatedPosition.line < 0) { + return { line: -1, character: -1 }; + } + + const mapped = this.consumer.originalPositionFor({ + line: generatedPosition.line + 1, + column: generatedPosition.character, + }); + + if (!mapped) { + return { line: -1, character: -1 }; + } + + if (mapped.line === 0) { + console.log('Got 0 mapped line from', generatedPosition, 'col was', mapped.column); + } + + return { + line: (mapped.line || 0) - 1, + character: mapped.column || 0, + }; + } + + getGeneratedPosition(originalPosition: Position): Position { + if (this.parent) { + originalPosition = this.parent.getGeneratedPosition(originalPosition); + } + + const mapped = this.consumer.generatedPositionFor({ + line: originalPosition.line + 1, + column: originalPosition.character, + source: this.sourceUri, + }); + + if (!mapped) { + return { line: -1, character: -1 }; + } + + const result = { + line: (mapped.line || 0) - 1, + character: mapped.column || 0, + }; + + if (result.line < 0) { + return result; + } + + return result; + } + + isInGenerated(position: Position): boolean { + if (this.parent && !this.isInGenerated(position)) { + return false; + } + + const generated = this.getGeneratedPosition(position); + return generated.line >= 0; + } + + getURL(): string { + return this.sourceUri; + } + + /** + * Needs to be called when source mapper is no longer needed in order to prevent memory leaks. + */ + destroy() { + this.parent?.destroy?.(); + this.consumer.destroy(); + } +} + +export function mapRangeToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, range: Range): Range { + // DON'T use Range.create here! Positions might not be mapped + // and therefore return negative numbers, which makes Range.create throw. + // These invalid position need to be handled + // on a case-by-case basis in the calling functions. + const originalRange = { + start: fragment.getOriginalPosition(range.start), + end: fragment.getOriginalPosition(range.end), + }; + + // Range may be mapped one character short - reverse that for "in the same line" cases + if ( + originalRange.start.line === originalRange.end.line && + range.start.line === range.end.line && + originalRange.end.character - originalRange.start.character === range.end.character - range.start.character - 1 + ) { + originalRange.end.character += 1; + } + + return originalRange; +} + +export function mapRangeToGenerated(fragment: DocumentMapper, range: Range): Range { + return Range.create(fragment.getGeneratedPosition(range.start), fragment.getGeneratedPosition(range.end)); +} + +export function mapCompletionItemToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, item: CompletionItem): CompletionItem { + if (!item.textEdit) { + return item; + } + + return { + ...item, + textEdit: mapEditToOriginal(fragment, item.textEdit), + }; +} + +export function mapHoverToParent(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, hover: Hover): Hover { + if (!hover.range) { + return hover; + } + + return { ...hover, range: mapRangeToOriginal(fragment, hover.range) }; +} + +export function mapObjWithRangeToOriginal<T extends { range: Range }>(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, objWithRange: T): T { + return { ...objWithRange, range: mapRangeToOriginal(fragment, objWithRange.range) }; +} + +export function mapInsertReplaceEditToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, edit: InsertReplaceEdit): InsertReplaceEdit { + return { + ...edit, + insert: mapRangeToOriginal(fragment, edit.insert), + replace: mapRangeToOriginal(fragment, edit.replace), + }; +} + +export function mapEditToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, edit: TextEdit | InsertReplaceEdit): TextEdit | InsertReplaceEdit { + return TextEdit.is(edit) ? mapObjWithRangeToOriginal(fragment, edit) : mapInsertReplaceEditToOriginal(fragment, edit); +} + +export function mapDiagnosticToGenerated(fragment: DocumentMapper, diagnostic: Diagnostic): Diagnostic { + return { ...diagnostic, range: mapRangeToGenerated(fragment, diagnostic.range) }; +} + +export function mapColorPresentationToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, presentation: ColorPresentation): ColorPresentation { + const item = { + ...presentation, + }; + + if (item.textEdit) { + item.textEdit = mapObjWithRangeToOriginal(fragment, item.textEdit); + } + + if (item.additionalTextEdits) { + item.additionalTextEdits = item.additionalTextEdits.map((edit) => mapObjWithRangeToOriginal(fragment, edit)); + } + + return item; +} + +export function mapSymbolInformationToOriginal(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, info: SymbolInformation): SymbolInformation { + return { ...info, location: mapObjWithRangeToOriginal(fragment, info.location) }; +} + +export function mapLocationLinkToOriginal(fragment: DocumentMapper, def: LocationLink): LocationLink { + return LocationLink.create( + def.targetUri, + fragment.getURL() === def.targetUri ? mapRangeToOriginal(fragment, def.targetRange) : def.targetRange, + fragment.getURL() === def.targetUri ? mapRangeToOriginal(fragment, def.targetSelectionRange) : def.targetSelectionRange, + def.originSelectionRange ? mapRangeToOriginal(fragment, def.originSelectionRange) : undefined + ); +} + +export function mapTextDocumentEditToOriginal(fragment: DocumentMapper, edit: TextDocumentEdit) { + if (edit.textDocument.uri !== fragment.getURL()) { + return edit; + } + + return TextDocumentEdit.create( + edit.textDocument, + edit.edits.map((textEdit) => mapObjWithRangeToOriginal(fragment, textEdit)) + ); +} + +export function mapCodeActionToOriginal(fragment: DocumentMapper, codeAction: CodeAction) { + return CodeAction.create( + codeAction.title, + { + documentChanges: codeAction.edit!.documentChanges!.map((edit) => mapTextDocumentEditToOriginal(fragment, edit as TextDocumentEdit)), + }, + codeAction.kind + ); +} + +export function mapSelectionRangeToParent(fragment: Pick<DocumentMapper, 'getOriginalPosition'>, selectionRange: SelectionRange): SelectionRange { + const { range, parent } = selectionRange; + + return SelectionRange.create(mapRangeToOriginal(fragment, range), parent && mapSelectionRangeToParent(fragment, parent)); +} diff --git a/tools/language-server/src/core/documents/index.ts b/tools/language-server/src/core/documents/index.ts new file mode 100644 index 000000000..5dc0eb61f --- /dev/null +++ b/tools/language-server/src/core/documents/index.ts @@ -0,0 +1,5 @@ +export * from './Document'; +export * from './DocumentBase'; +export * from './DocumentManager'; +export * from './DocumentMapper'; +export * from './utils'; diff --git a/tools/language-server/src/core/documents/parseAstro.ts b/tools/language-server/src/core/documents/parseAstro.ts new file mode 100644 index 000000000..71c7764d8 --- /dev/null +++ b/tools/language-server/src/core/documents/parseAstro.ts @@ -0,0 +1,77 @@ +import { getFirstNonWhitespaceIndex } from './utils'; + +interface Frontmatter { + state: null | 'open' | 'closed'; + startOffset: null | number; + endOffset: null | number; +} + +interface Content { + firstNonWhitespaceOffset: null | number; +} + +export interface AstroDocument { + frontmatter: Frontmatter; + content: Content; +} + +/** Parses a document to collect metadata about Astro features */ +export function parseAstro(content: string): AstroDocument { + const frontmatter = getFrontmatter(content); + return { + frontmatter, + content: getContent(content, frontmatter), + }; +} + +/** Get frontmatter metadata */ +function getFrontmatter(content: string): Frontmatter { + /** Quickly check how many `---` blocks are in the document */ + function getFrontmatterState(): Frontmatter['state'] { + const parts = content.trim().split('---').length; + switch (parts) { + case 1: + return null; + case 2: + return 'open'; + default: + return 'closed'; + } + } + const state = getFrontmatterState(); + + /** Construct a range containing the document's frontmatter */ + function getFrontmatterOffsets(): [number | null, number | null] { + const startOffset = content.indexOf('---'); + if (startOffset === -1) return [null, null]; + const endOffset = content.slice(startOffset + 3).indexOf('---') + 3; + if (endOffset === -1) return [startOffset, null]; + return [startOffset, endOffset]; + } + const [startOffset, endOffset] = getFrontmatterOffsets(); + + return { + state, + startOffset, + endOffset, + }; +} + +/** Get content metadata */ +function getContent(content: string, frontmatter: Frontmatter): Content { + switch (frontmatter.state) { + case null: { + const offset = getFirstNonWhitespaceIndex(content); + return { firstNonWhitespaceOffset: offset === -1 ? null : offset }; + } + case 'open': { + return { firstNonWhitespaceOffset: null }; + } + case 'closed': { + const { endOffset } = frontmatter; + const end = (endOffset ?? 0) + 3; + const offset = getFirstNonWhitespaceIndex(content.slice(end)); + return { firstNonWhitespaceOffset: end + offset }; + } + } +} diff --git a/tools/language-server/src/core/documents/parseHtml.ts b/tools/language-server/src/core/documents/parseHtml.ts new file mode 100644 index 000000000..f5de5f292 --- /dev/null +++ b/tools/language-server/src/core/documents/parseHtml.ts @@ -0,0 +1,141 @@ +import { getLanguageService, HTMLDocument, TokenType, ScannerState, Scanner, Node, Position } from 'vscode-html-languageservice'; +import { Document } from './Document'; +import { isInsideExpression } from './utils'; + +const parser = getLanguageService(); + +/** + * Parses text as HTML + */ +export function parseHtml(text: string): HTMLDocument { + const preprocessed = preprocess(text); + + // We can safely only set getText because only this is used for parsing + const parsedDoc = parser.parseHTMLDocument(<any>{ getText: () => preprocessed }); + + return parsedDoc; +} + +const createScanner = parser.createScanner as (input: string, initialOffset?: number, initialState?: ScannerState) => Scanner; + +/** + * scan the text and remove any `>` or `<` that cause the tag to end short, + */ +function preprocess(text: string) { + let scanner = createScanner(text); + let token = scanner.scan(); + let currentStartTagStart: number | null = null; + + while (token !== TokenType.EOS) { + const offset = scanner.getTokenOffset(); + + if (token === TokenType.StartTagOpen) { + currentStartTagStart = offset; + } + + if (token === TokenType.StartTagClose) { + if (shouldBlankStartOrEndTagLike(offset)) { + blankStartOrEndTagLike(offset); + } else { + currentStartTagStart = null; + } + } + + if (token === TokenType.StartTagSelfClose) { + currentStartTagStart = null; + } + + // <Foo checked={a < 1}> + // https://github.com/microsoft/vscode-html-languageservice/blob/71806ef57be07e1068ee40900ef8b0899c80e68a/src/parser/htmlScanner.ts#L327 + if (token === TokenType.Unknown && scanner.getScannerState() === ScannerState.WithinTag && scanner.getTokenText() === '<' && shouldBlankStartOrEndTagLike(offset)) { + blankStartOrEndTagLike(offset); + } + + token = scanner.scan(); + } + + return text; + + function shouldBlankStartOrEndTagLike(offset: number) { + // not null rather than falsy, otherwise it won't work on first tag(0) + return currentStartTagStart !== null && isInsideExpression(text, currentStartTagStart, offset); + } + + function blankStartOrEndTagLike(offset: number) { + text = text.substring(0, offset) + ' ' + text.substring(offset + 1); + scanner = createScanner(text, offset, ScannerState.WithinTag); + } +} + +export interface AttributeContext { + name: string; + inValue: boolean; + valueRange?: [number, number]; +} + +export function getAttributeContextAtPosition(document: Document, position: Position): AttributeContext | null { + const offset = document.offsetAt(position); + const { html } = document; + const tag = html.findNodeAt(offset); + + if (!inStartTag(offset, tag) || !tag.attributes) { + return null; + } + + const text = document.getText(); + const beforeStartTagEnd = text.substring(0, tag.start) + preprocess(text.substring(tag.start, tag.startTagEnd)); + + const scanner = createScanner(beforeStartTagEnd, tag.start); + + let token = scanner.scan(); + let currentAttributeName: string | undefined; + const inTokenRange = () => scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd(); + while (token != TokenType.EOS) { + // adopted from https://github.com/microsoft/vscode-html-languageservice/blob/2f7ae4df298ac2c299a40e9024d118f4a9dc0c68/src/services/htmlCompletion.ts#L402 + if (token === TokenType.AttributeName) { + currentAttributeName = scanner.getTokenText(); + + if (inTokenRange()) { + return { + name: currentAttributeName, + inValue: false, + }; + } + } else if (token === TokenType.DelimiterAssign) { + if (scanner.getTokenEnd() === offset && currentAttributeName) { + const nextToken = scanner.scan(); + + return { + name: currentAttributeName, + inValue: true, + valueRange: [offset, nextToken === TokenType.AttributeValue ? scanner.getTokenEnd() : offset], + }; + } + } else if (token === TokenType.AttributeValue) { + if (inTokenRange() && currentAttributeName) { + let start = scanner.getTokenOffset(); + let end = scanner.getTokenEnd(); + const char = text[start]; + + if (char === '"' || char === "'") { + start++; + end--; + } + + return { + name: currentAttributeName, + inValue: true, + valueRange: [start, end], + }; + } + currentAttributeName = undefined; + } + token = scanner.scan(); + } + + return null; +} + +function inStartTag(offset: number, node: Node) { + return offset > node.start && node.startTagEnd != undefined && offset < node.startTagEnd; +} diff --git a/tools/language-server/src/core/documents/utils.ts b/tools/language-server/src/core/documents/utils.ts new file mode 100644 index 000000000..eb9d2060d --- /dev/null +++ b/tools/language-server/src/core/documents/utils.ts @@ -0,0 +1,250 @@ +import { HTMLDocument, Node, Position } from 'vscode-html-languageservice'; +import { Range } from 'vscode-languageserver'; +import { clamp, isInRange } from '../../utils'; +import { parseHtml } from './parseHtml'; + +export interface TagInformation { + content: string; + attributes: Record<string, string>; + start: number; + end: number; + startPos: Position; + endPos: Position; + container: { start: number; end: number }; +} + +function parseAttributes(rawAttrs: Record<string, string | null> | undefined): Record<string, string> { + const attrs: Record<string, string> = {}; + if (!rawAttrs) { + return attrs; + } + + Object.keys(rawAttrs).forEach((attrName) => { + const attrValue = rawAttrs[attrName]; + attrs[attrName] = attrValue === null ? attrName : removeOuterQuotes(attrValue); + }); + return attrs; + + function removeOuterQuotes(attrValue: string) { + if ((attrValue.startsWith('"') && attrValue.endsWith('"')) || (attrValue.startsWith("'") && attrValue.endsWith("'"))) { + return attrValue.slice(1, attrValue.length - 1); + } + return attrValue; + } +} + +/** + * Gets word range at position. + * Delimiter is by default a whitespace, but can be adjusted. + */ +export function getWordRangeAt(str: string, pos: number, delimiterRegex = { left: /\S+$/, right: /\s/ }): { start: number; end: number } { + let start = str.slice(0, pos).search(delimiterRegex.left); + if (start < 0) { + start = pos; + } + + let end = str.slice(pos).search(delimiterRegex.right); + if (end < 0) { + end = str.length; + } else { + end = end + pos; + } + + return { start, end }; +} + +/** + * Gets word at position. + * Delimiter is by default a whitespace, but can be adjusted. + */ +export function getWordAt(str: string, pos: number, delimiterRegex = { left: /\S+$/, right: /\s/ }): string { + const { start, end } = getWordRangeAt(str, pos, delimiterRegex); + return str.slice(start, end); +} + +/** + * Gets index of first-non-whitespace character. + */ +export function getFirstNonWhitespaceIndex(str: string): number { + return str.length - str.trimStart().length; +} + +/** checks if a position is currently inside of an expression */ +export function isInsideExpression(html: string, tagStart: number, position: number) { + const charactersInNode = html.substring(tagStart, position); + return charactersInNode.lastIndexOf('{') > charactersInNode.lastIndexOf('}'); +} + +/** + * Returns if a given offset is inside of the document frontmatter + */ +export function isInsideFrontmatter(text: string, offset: number): boolean { + let start = text.slice(0, offset).trim().split('---').length; + let end = text.slice(offset).trim().split('---').length; + + return start > 1 && start < 3 && end >= 1; +} + +export function isInTag(position: Position, tagInfo: TagInformation | null): tagInfo is TagInformation { + return !!tagInfo && isInRange(position, Range.create(tagInfo.startPos, tagInfo.endPos)); +} + +/** + * Get the line and character based on the offset + * @param offset The index of the position + * @param text The text for which the position should be retrived + */ +export function positionAt(offset: number, text: string): Position { + offset = clamp(offset, 0, text.length); + + const lineOffsets = getLineOffsets(text); + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return Position.create(0, offset); + } + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + return Position.create(line, offset - lineOffsets[line]); +} + +/** + * Get the offset of the line and character position + * @param position Line and character position + * @param text The text for which the offset should be retrived + */ +export function offsetAt(position: Position, text: string): number { + const lineOffsets = getLineOffsets(text); + + if (position.line >= lineOffsets.length) { + return text.length; + } else if (position.line < 0) { + return 0; + } + + const lineOffset = lineOffsets[position.line]; + const nextLineOffset = position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : text.length; + + return clamp(nextLineOffset, lineOffset, lineOffset + position.character); +} + +function getLineOffsets(text: string) { + const lineOffsets = []; + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + return lineOffsets; +} + +export function* walk(node: Node): Generator<Node, void, unknown> { + for (let child of node.children) { + yield* walk(child); + } + yield node; +} + +/* +export function* walk(node: Node, startIndex = 0) { + let skip, tmp; + let depth = 0; + let index = startIndex; + + // Always start with the initial element. + do { + if ( !skip && (tmp = node.firstChild) ) { + depth++; + callback('child', node, tmp, index); + index++; + } else if ( tmp = node.nextSibling ) { + skip = false; + callback('sibling', node, tmp, index); + index++; + } else { + tmp = node.parentNode; + depth--; + skip = true; + } + node = tmp; + } while ( depth > 0 ); +}; +*/ + +/** + * Extracts a tag (style or script) from the given text + * and returns its start, end and the attributes on that tag. + * + * @param source text content to extract tag from + * @param tag the tag to extract + */ +function extractTags(text: string, tag: 'script' | 'style' | 'template', html?: HTMLDocument): TagInformation[] { + const rootNodes = html?.roots || parseHtml(text).roots; + const matchedNodes = rootNodes.filter((node) => node.tag === tag); + + if (tag === 'style' && !matchedNodes.length && rootNodes.length && rootNodes[0].tag === 'html') { + for (let child of walk(rootNodes[0])) { + if (child.tag === 'style') { + matchedNodes.push(child); + } + } + } + + return matchedNodes.map(transformToTagInfo); + + function transformToTagInfo(matchedNode: Node) { + const start = matchedNode.startTagEnd ?? matchedNode.start; + const end = matchedNode.endTagStart ?? matchedNode.end; + const startPos = positionAt(start, text); + const endPos = positionAt(end, text); + const container = { + start: matchedNode.start, + end: matchedNode.end, + }; + const content = text.substring(start, end); + + return { + content, + attributes: parseAttributes(matchedNode.attributes), + start, + end, + startPos, + endPos, + container, + }; + } +} + +export function extractStyleTag(source: string, html?: HTMLDocument): TagInformation | null { + const styles = extractTags(source, 'style', html); + if (!styles.length) { + return null; + } + + // There can only be one style tag + return styles[0]; +} diff --git a/tools/language-server/src/index.ts b/tools/language-server/src/index.ts new file mode 100644 index 000000000..5e4c736a2 --- /dev/null +++ b/tools/language-server/src/index.ts @@ -0,0 +1,115 @@ +import { RequestType, TextDocumentPositionParams, createConnection, ProposedFeatures, TextDocumentSyncKind, TextDocumentIdentifier } from 'vscode-languageserver'; +import { Document, DocumentManager } from './core/documents'; +import { ConfigManager } from './core/config'; +import { PluginHost, CSSPlugin, HTMLPlugin, TypeScriptPlugin, AppCompletionItem, AstroPlugin } from './plugins'; +import { urlToPath } from './utils'; + +const TagCloseRequest: RequestType<TextDocumentPositionParams, string | null, any> = new RequestType('html/tag'); + +/** + * Starts `astro-languageservice` + */ +export function startServer() { + let connection = createConnection(ProposedFeatures.all); + + const docManager = new DocumentManager(({ uri, text }: { uri: string; text: string }) => new Document(uri, text)); + const configManager = new ConfigManager(); + const pluginHost = new PluginHost(docManager); + + connection.onInitialize((evt) => { + const workspaceUris = evt.workspaceFolders?.map((folder) => folder.uri.toString()) ?? [evt.rootUri ?? '']; + + pluginHost.initialize({ + filterIncompleteCompletions: !evt.initializationOptions?.dontFilterIncompleteCompletions, + definitionLinkSupport: !!evt.capabilities.textDocument?.definition?.linkSupport, + }); + pluginHost.register(new AstroPlugin(docManager, configManager, workspaceUris)); + pluginHost.register(new HTMLPlugin(docManager, configManager)); + pluginHost.register(new CSSPlugin(docManager, configManager)); + pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspaceUris)); + configManager.updateEmmetConfig(evt.initializationOptions?.configuration?.emmet || evt.initializationOptions?.emmetConfig || {}); + + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + foldingRangeProvider: true, + definitionProvider: true, + completionProvider: { + resolveProvider: true, + triggerCharacters: [ + '.', + '"', + "'", + '`', + '/', + '@', + '<', + + // Emmet + '>', + '*', + '#', + '$', + '+', + '^', + '(', + '[', + '@', + '-', + // No whitespace because + // it makes for weird/too many completions + // of other completion providers + + // Astro + ':', + ], + }, + }, + }; + }); + + // Documents + connection.onDidOpenTextDocument((evt) => { + docManager.openDocument(evt.textDocument); + docManager.markAsOpenedInClient(evt.textDocument.uri); + }); + + connection.onDidCloseTextDocument((evt) => docManager.closeDocument(evt.textDocument.uri)); + + connection.onDidChangeTextDocument((evt) => { + docManager.updateDocument(evt.textDocument.uri, evt.contentChanges); + }); + + connection.onDidChangeWatchedFiles((evt) => { + const params = evt.changes + .map((change) => ({ + fileName: urlToPath(change.uri), + changeType: change.type, + })) + .filter((change) => !!change.fileName); + + pluginHost.onWatchFileChanges(params); + }); + + // Config + connection.onDidChangeConfiguration(({ settings }) => { + configManager.updateEmmetConfig(settings.emmet); + }); + + // Features + connection.onCompletion((evt) => pluginHost.getCompletions(evt.textDocument, evt.position, evt.context)); + connection.onCompletionResolve((completionItem) => { + const data = (completionItem as AppCompletionItem).data as TextDocumentIdentifier; + + if (!data) { + return completionItem; + } + + return pluginHost.resolveCompletion(data, completionItem); + }); + connection.onDefinition((evt) => pluginHost.getDefinitions(evt.textDocument, evt.position)); + connection.onFoldingRanges((evt) => pluginHost.getFoldingRanges(evt.textDocument)); + connection.onRequest(TagCloseRequest, (evt: any) => pluginHost.doTagComplete(evt.textDocument, evt.position)); + + connection.listen(); +} diff --git a/tools/language-server/src/plugins/PluginHost.ts b/tools/language-server/src/plugins/PluginHost.ts new file mode 100644 index 000000000..3741845c4 --- /dev/null +++ b/tools/language-server/src/plugins/PluginHost.ts @@ -0,0 +1,139 @@ +import { CompletionContext, CompletionItem, CompletionList, DefinitionLink, Location, Position, TextDocumentIdentifier } from 'vscode-languageserver'; +import type { DocumentManager } from '../core/documents'; +import type * as d from './interfaces'; +import { flatten } from '../utils'; +import { FoldingRange } from 'vscode-languageserver-types'; + +enum ExecuteMode { + None, + FirstNonNull, + Collect, +} + +interface PluginHostConfig { + filterIncompleteCompletions: boolean; + definitionLinkSupport: boolean; +} + +export class PluginHost { + private plugins: d.Plugin[] = []; + private pluginHostConfig: PluginHostConfig = { + filterIncompleteCompletions: true, + definitionLinkSupport: false, + }; + + constructor(private documentsManager: DocumentManager) {} + + initialize(pluginHostConfig: PluginHostConfig) { + this.pluginHostConfig = pluginHostConfig; + } + + register(plugin: d.Plugin) { + this.plugins.push(plugin); + } + + async getCompletions(textDocument: TextDocumentIdentifier, position: Position, completionContext?: CompletionContext): Promise<CompletionList> { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + const completions = (await this.execute<CompletionList>('getCompletions', [document, position, completionContext], ExecuteMode.Collect)).filter( + (completion) => completion != null + ); + + let flattenedCompletions = flatten(completions.map((completion) => completion.items)); + const isIncomplete = completions.reduce((incomplete, completion) => incomplete || completion.isIncomplete, false as boolean); + + return CompletionList.create(flattenedCompletions, isIncomplete); + } + + async resolveCompletion(textDocument: TextDocumentIdentifier, completionItem: d.AppCompletionItem): Promise<CompletionItem> { + const document = this.getDocument(textDocument.uri); + + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + const result = await this.execute<CompletionItem>('resolveCompletion', [document, completionItem], ExecuteMode.FirstNonNull); + + return result ?? completionItem; + } + + async doTagComplete(textDocument: TextDocumentIdentifier, position: Position): Promise<string | null> { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + return this.execute<string | null>('doTagComplete', [document, position], ExecuteMode.FirstNonNull); + } + + async getFoldingRanges(textDocument: TextDocumentIdentifier): Promise<FoldingRange[] | null> { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + const foldingRanges = flatten(await this.execute<FoldingRange[]>('getFoldingRanges', [document], ExecuteMode.Collect)).filter((completion) => completion != null); + + return foldingRanges; + } + + async getDefinitions(textDocument: TextDocumentIdentifier, position: Position): Promise<DefinitionLink[] | Location[]> { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + const definitions = flatten(await this.execute<DefinitionLink[]>('getDefinitions', [document, position], ExecuteMode.Collect)); + + if (this.pluginHostConfig.definitionLinkSupport) { + return definitions; + } else { + return definitions.map((def) => <Location>{ range: def.targetSelectionRange, uri: def.targetUri }); + } + } + + onWatchFileChanges(onWatchFileChangesParams: any[]): void { + for (const support of this.plugins) { + support.onWatchFileChanges?.(onWatchFileChangesParams); + } + } + + private getDocument(uri: string) { + return this.documentsManager.get(uri); + } + + private execute<T>(name: keyof d.LSProvider, args: any[], mode: ExecuteMode.FirstNonNull): Promise<T | null>; + private execute<T>(name: keyof d.LSProvider, args: any[], mode: ExecuteMode.Collect): Promise<T[]>; + private execute(name: keyof d.LSProvider, args: any[], mode: ExecuteMode.None): Promise<void>; + private async execute<T>(name: keyof d.LSProvider, args: any[], mode: ExecuteMode): Promise<(T | null) | T[] | void> { + const plugins = this.plugins.filter((plugin) => typeof plugin[name] === 'function'); + + switch (mode) { + case ExecuteMode.FirstNonNull: + for (const plugin of plugins) { + const res = await this.tryExecutePlugin(plugin, name, args, null); + if (res != null) { + return res; + } + } + return null; + case ExecuteMode.Collect: + return Promise.all(plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, []))); + case ExecuteMode.None: + await Promise.all(plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, null))); + return; + } + } + + private async tryExecutePlugin(plugin: any, fnName: string, args: any[], failValue: any) { + try { + return await plugin[fnName](...args); + } catch (e) { + console.error(e); + return failValue; + } + } +} diff --git a/tools/language-server/src/plugins/astro/AstroPlugin.ts b/tools/language-server/src/plugins/astro/AstroPlugin.ts new file mode 100644 index 000000000..535375eeb --- /dev/null +++ b/tools/language-server/src/plugins/astro/AstroPlugin.ts @@ -0,0 +1,199 @@ +import { DefinitionLink } from 'vscode-languageserver'; +import type { Document, DocumentManager } from '../../core/documents'; +import type { ConfigManager } from '../../core/config'; +import type { CompletionsProvider, AppCompletionList, FoldingRangeProvider } from '../interfaces'; +import { + CompletionContext, + Position, + CompletionList, + CompletionItem, + CompletionItemKind, + InsertTextFormat, + LocationLink, + FoldingRange, + Range, + TextEdit, +} from 'vscode-languageserver'; +import { Node } from 'vscode-html-languageservice'; +import { isPossibleClientComponent, pathToUrl, urlToPath } from '../../utils'; +import { isInsideFrontmatter } from '../../core/documents/utils'; +import * as ts from 'typescript'; +import { LanguageServiceManager as TypeScriptLanguageServiceManager } from '../typescript/LanguageServiceManager'; +import { ensureRealFilePath } from '../typescript/utils'; +import { FoldingRangeKind } from 'vscode-languageserver-types'; + +export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider { + private readonly docManager: DocumentManager; + private readonly configManager: ConfigManager; + private readonly tsLanguageServiceManager: TypeScriptLanguageServiceManager; + + constructor(docManager: DocumentManager, configManager: ConfigManager, workspaceUris: string[]) { + this.docManager = docManager; + this.configManager = configManager; + this.tsLanguageServiceManager = new TypeScriptLanguageServiceManager(docManager, configManager, workspaceUris); + } + + async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise<AppCompletionList | null> { + const doc = this.docManager.get(document.uri); + if (!doc) return null; + + let items: CompletionItem[] = []; + + if (completionContext?.triggerCharacter === '-') { + const frontmatter = this.getComponentScriptCompletion(doc, position, completionContext); + if (frontmatter) items.push(frontmatter); + } + + if (completionContext?.triggerCharacter === ':') { + const clientHint = this.getClientHintCompletion(doc, position, completionContext); + if (clientHint) items.push(...clientHint); + } + + return CompletionList.create(items, true); + } + + async getFoldingRanges(document: Document): Promise<FoldingRange[]> { + const foldingRanges: FoldingRange[] = []; + const { frontmatter } = document.astro; + + // Currently editing frontmatter, don't fold + if (frontmatter.state !== 'closed') return foldingRanges; + + const start = document.positionAt(frontmatter.startOffset as number); + const end = document.positionAt((frontmatter.endOffset as number) - 3); + return [ + { + startLine: start.line, + startCharacter: start.character, + endLine: end.line, + endCharacter: end.character, + kind: FoldingRangeKind.Imports, + }, + ]; + } + + async getDefinitions(document: Document, position: Position): Promise<DefinitionLink[]> { + if (this.isInsideFrontmatter(document, position)) { + return []; + } + + const offset = document.offsetAt(position); + const html = document.html; + + const node = html.findNodeAt(offset); + if (!this.isComponentTag(node)) { + return []; + } + + const [componentName] = node.tag!.split(':'); + + const filePath = urlToPath(document.uri); + const tsFilePath = filePath + '.ts'; + + const { lang, tsDoc } = await this.tsLanguageServiceManager.getTypeScriptDoc(document); + + const sourceFile = lang.getProgram()?.getSourceFile(tsFilePath); + if (!sourceFile) { + return []; + } + + const specifier = this.getImportSpecifierForIdentifier(sourceFile, componentName); + if (!specifier) { + return []; + } + + const defs = lang.getDefinitionAtPosition(tsFilePath, specifier.getStart()); + if (!defs) { + return []; + } + + const tsFragment = await tsDoc.getFragment(); + const startRange: Range = Range.create(Position.create(0, 0), Position.create(0, 0)); + const links = defs.map((def) => { + const defFilePath = ensureRealFilePath(def.fileName); + return LocationLink.create(pathToUrl(defFilePath), startRange, startRange); + }); + + return links; + } + + private getClientHintCompletion(document: Document, position: Position, completionContext?: CompletionContext): CompletionItem[] | null { + const node = document.html.findNodeAt(document.offsetAt(position)); + if (!isPossibleClientComponent(node)) return null; + + return [ + { + label: ':load', + insertText: 'load', + commitCharacters: ['l'], + }, + { + label: ':idle', + insertText: 'idle', + commitCharacters: ['i'], + }, + { + label: ':visible', + insertText: 'visible', + commitCharacters: ['v'], + }, + ]; + } + + private getComponentScriptCompletion(document: Document, position: Position, completionContext?: CompletionContext): CompletionItem | null { + const base = { + kind: CompletionItemKind.Snippet, + label: '---', + sortText: '\0', + preselect: true, + detail: 'Component script', + insertTextFormat: InsertTextFormat.Snippet, + commitCharacters: ['-'], + }; + const prefix = document.getLineUntilOffset(document.offsetAt(position)); + + if (document.astro.frontmatter.state === null) { + return { + ...base, + insertText: '---\n$0\n---', + textEdit: prefix.match(/^\s*\-+/) ? TextEdit.replace({ start: { ...position, character: 0 }, end: position }, '---\n$0\n---') : undefined, + }; + } + if (document.astro.frontmatter.state === 'open') { + return { + ...base, + insertText: '---', + textEdit: prefix.match(/^\s*\-+/) ? TextEdit.replace({ start: { ...position, character: 0 }, end: position }, '---') : undefined, + }; + } + return null; + } + + private isInsideFrontmatter(document: Document, position: Position) { + return isInsideFrontmatter(document.getText(), document.offsetAt(position)); + } + + private isComponentTag(node: Node): boolean { + if (!node.tag) { + return false; + } + const firstChar = node.tag[0]; + return /[A-Z]/.test(firstChar); + } + + private getImportSpecifierForIdentifier(sourceFile: ts.SourceFile, identifier: string): ts.Expression | undefined { + let importSpecifier: ts.Expression | undefined = undefined; + ts.forEachChild(sourceFile, (tsNode) => { + if (ts.isImportDeclaration(tsNode)) { + if (tsNode.importClause) { + const { name } = tsNode.importClause; + if (name && name.getText() === identifier) { + importSpecifier = tsNode.moduleSpecifier; + return true; + } + } + } + }); + return importSpecifier; + } +} diff --git a/tools/language-server/src/plugins/css/CSSDocument.ts b/tools/language-server/src/plugins/css/CSSDocument.ts new file mode 100644 index 000000000..9f1839678 --- /dev/null +++ b/tools/language-server/src/plugins/css/CSSDocument.ts @@ -0,0 +1,95 @@ +import { Stylesheet, TextDocument } from 'vscode-css-languageservice'; +import { Position } from 'vscode-languageserver'; +import { getLanguageService } from './service'; +import { Document, DocumentMapper, ReadableDocument, TagInformation } from '../../core/documents/index'; + +export interface CSSDocumentBase extends DocumentMapper, TextDocument { + languageId: string; + stylesheet: Stylesheet; +} + +export class CSSDocument extends ReadableDocument implements DocumentMapper { + private styleInfo: Pick<TagInformation, 'attributes' | 'start' | 'end'>; + readonly version = this.parent.version; + + public stylesheet: Stylesheet; + public languageId: string; + + constructor(private parent: Document) { + super(); + + if (this.parent.styleInfo) { + this.styleInfo = this.parent.styleInfo; + } else { + this.styleInfo = { + attributes: {}, + start: -1, + end: -1, + }; + } + + this.languageId = this.language; + this.stylesheet = getLanguageService(this.language).parseStylesheet(this); + } + + /** + * Get the fragment position relative to the parent + * @param pos Position in fragment + */ + getOriginalPosition(pos: Position): Position { + const parentOffset = this.styleInfo.start + this.offsetAt(pos); + return this.parent.positionAt(parentOffset); + } + + /** + * Get the position relative to the start of the fragment + * @param pos Position in parent + */ + getGeneratedPosition(pos: Position): Position { + const fragmentOffset = this.parent.offsetAt(pos) - this.styleInfo.start; + return this.positionAt(fragmentOffset); + } + + /** + * Returns true if the given parent position is inside of this fragment + * @param pos Position in parent + */ + isInGenerated(pos: Position): boolean { + const offset = this.parent.offsetAt(pos); + return offset >= this.styleInfo.start && offset <= this.styleInfo.end; + } + + /** + * Get the fragment text from the parent + */ + getText(): string { + return this.parent.getText().slice(this.styleInfo.start, this.styleInfo.end); + } + + /** + * Returns the length of the fragment as calculated from the start and end positon + */ + getTextLength(): number { + return this.styleInfo.end - this.styleInfo.start; + } + + /** + * Return the parent file path + */ + getFilePath(): string | null { + return this.parent.getFilePath(); + } + + getURL() { + return this.parent.getURL(); + } + + getAttributes() { + return this.styleInfo.attributes; + } + + private get language() { + const attrs = this.getAttributes(); + return attrs.lang || attrs.type || 'css'; + } +} diff --git a/tools/language-server/src/plugins/css/CSSPlugin.ts b/tools/language-server/src/plugins/css/CSSPlugin.ts new file mode 100644 index 000000000..26c90ac66 --- /dev/null +++ b/tools/language-server/src/plugins/css/CSSPlugin.ts @@ -0,0 +1,118 @@ +import type { CompletionsProvider } from '../interfaces'; +import type { Document, DocumentManager } from '../../core/documents'; +import type { ConfigManager } from '../../core/config'; +import { getEmmetCompletionParticipants, doComplete as doEmmetComplete } from 'vscode-emmet-helper'; +import { CompletionContext, CompletionList, CompletionTriggerKind, Position } from 'vscode-languageserver'; +import { isInsideFrontmatter } from '../../core/documents/utils'; +import { CSSDocument, CSSDocumentBase } from './CSSDocument'; +import { getLanguage, getLanguageService } from './service'; +import { StyleAttributeDocument } from './StyleAttributeDocument'; +import { mapCompletionItemToOriginal } from '../../core/documents'; +import { AttributeContext, getAttributeContextAtPosition } from '../../core/documents/parseHtml'; +import { getIdClassCompletion } from './features/getIdClassCompletion'; + +export class CSSPlugin implements CompletionsProvider { + private docManager: DocumentManager; + private configManager: ConfigManager; + private documents = new WeakMap<Document, CSSDocument>(); + private triggerCharacters = new Set(['.', ':', '-', '/']); + + constructor(docManager: DocumentManager, configManager: ConfigManager) { + this.docManager = docManager; + this.configManager = configManager; + + this.docManager.on('documentChange', (document) => { + this.documents.set(document, new CSSDocument(document)); + }); + } + + getCompletions(document: Document, position: Position, completionContext?: CompletionContext): CompletionList | null { + const triggerCharacter = completionContext?.triggerCharacter; + const triggerKind = completionContext?.triggerKind; + const isCustomTriggerCharacter = triggerKind === CompletionTriggerKind.TriggerCharacter; + + if (isCustomTriggerCharacter && triggerCharacter && !this.triggerCharacters.has(triggerCharacter)) { + return null; + } + + if (this.isInsideFrontmatter(document, position)) { + return null; + } + + const cssDocument = this.getCSSDoc(document); + + if (cssDocument.isInGenerated(position)) { + return this.getCompletionsInternal(document, position, cssDocument); + } + + const attributeContext = getAttributeContextAtPosition(document, position); + if (!attributeContext) { + return null; + } + + if (this.inStyleAttributeWithoutInterpolation(attributeContext, document.getText())) { + const [start, end] = attributeContext.valueRange; + return this.getCompletionsInternal(document, position, new StyleAttributeDocument(document, start, end)); + } else { + return getIdClassCompletion(cssDocument, attributeContext); + } + } + + private getCompletionsInternal(document: Document, position: Position, cssDocument: CSSDocumentBase) { + if (isSASS(cssDocument)) { + // the css language service does not support sass, still we can use + // the emmet helper directly to at least get emmet completions + return doEmmetComplete(document, position, 'sass', this.configManager.getEmmetConfig()); + } + + const type = extractLanguage(cssDocument); + + const lang = getLanguageService(type); + const emmetResults: CompletionList = { + isIncomplete: true, + items: [], + }; + if (false /* this.configManager.getConfig().css.completions.emmet */) { + lang.setCompletionParticipants([ + getEmmetCompletionParticipants(cssDocument, cssDocument.getGeneratedPosition(position), getLanguage(type), this.configManager.getEmmetConfig(), emmetResults), + ]); + } + const results = lang.doComplete(cssDocument, cssDocument.getGeneratedPosition(position), cssDocument.stylesheet); + return CompletionList.create( + [...(results ? results.items : []), ...emmetResults.items].map((completionItem) => mapCompletionItemToOriginal(cssDocument, completionItem)), + // Emmet completions change on every keystroke, so they are never complete + emmetResults.items.length > 0 + ); + } + + private inStyleAttributeWithoutInterpolation(attrContext: AttributeContext, text: string): attrContext is Required<AttributeContext> { + return attrContext.name === 'style' && !!attrContext.valueRange && !text.substring(attrContext.valueRange[0], attrContext.valueRange[1]).includes('{'); + } + + private getCSSDoc(document: Document) { + let cssDoc = this.documents.get(document); + if (!cssDoc || cssDoc.version < document.version) { + cssDoc = new CSSDocument(document); + this.documents.set(document, cssDoc); + } + return cssDoc; + } + + private isInsideFrontmatter(document: Document, position: Position) { + return isInsideFrontmatter(document.getText(), document.offsetAt(position)); + } +} + +function isSASS(document: CSSDocumentBase) { + switch (extractLanguage(document)) { + case 'sass': + return true; + default: + return false; + } +} + +function extractLanguage(document: CSSDocumentBase): string { + const lang = document.languageId; + return lang.replace(/^text\//, ''); +} diff --git a/tools/language-server/src/plugins/css/StyleAttributeDocument.ts b/tools/language-server/src/plugins/css/StyleAttributeDocument.ts new file mode 100644 index 000000000..e00398037 --- /dev/null +++ b/tools/language-server/src/plugins/css/StyleAttributeDocument.ts @@ -0,0 +1,72 @@ +import { Stylesheet } from 'vscode-css-languageservice'; +import { Position } from 'vscode-languageserver'; +import { getLanguageService } from './service'; +import { Document, DocumentMapper, ReadableDocument } from '../../core/documents'; + +const PREFIX = '__ {'; +const SUFFIX = '}'; + +export class StyleAttributeDocument extends ReadableDocument implements DocumentMapper { + readonly version = this.parent.version; + + public stylesheet: Stylesheet; + public languageId = 'css'; + + constructor(private readonly parent: Document, private readonly attrStart: number, private readonly attrEnd: number) { + super(); + + this.stylesheet = getLanguageService(this.languageId).parseStylesheet(this); + } + + /** + * Get the fragment position relative to the parent + * @param pos Position in fragment + */ + getOriginalPosition(pos: Position): Position { + const parentOffset = this.attrStart + this.offsetAt(pos) - PREFIX.length; + return this.parent.positionAt(parentOffset); + } + + /** + * Get the position relative to the start of the fragment + * @param pos Position in parent + */ + getGeneratedPosition(pos: Position): Position { + const fragmentOffset = this.parent.offsetAt(pos) - this.attrStart + PREFIX.length; + return this.positionAt(fragmentOffset); + } + + /** + * Returns true if the given parent position is inside of this fragment + * @param pos Position in parent + */ + isInGenerated(pos: Position): boolean { + const offset = this.parent.offsetAt(pos); + return offset >= this.attrStart && offset <= this.attrEnd; + } + + /** + * Get the fragment text from the parent + */ + getText(): string { + return PREFIX + this.parent.getText().slice(this.attrStart, this.attrEnd) + SUFFIX; + } + + /** + * Returns the length of the fragment as calculated from the start and end position + */ + getTextLength(): number { + return PREFIX.length + this.attrEnd - this.attrStart + SUFFIX.length; + } + + /** + * Return the parent file path + */ + getFilePath(): string | null { + return this.parent.getFilePath(); + } + + getURL() { + return this.parent.getURL(); + } +} diff --git a/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts b/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts new file mode 100644 index 000000000..45acb5ad6 --- /dev/null +++ b/tools/language-server/src/plugins/css/features/getIdClassCompletion.ts @@ -0,0 +1,67 @@ +import { CompletionItem, CompletionItemKind, CompletionList } from 'vscode-languageserver'; +import { AttributeContext } from '../../../core/documents/parseHtml'; +import { CSSDocument } from '../CSSDocument'; + +export function getIdClassCompletion(cssDoc: CSSDocument, attributeContext: AttributeContext): CompletionList | null { + const collectingType = getCollectingType(attributeContext); + + if (!collectingType) { + return null; + } + const items = collectSelectors(cssDoc.stylesheet as CSSNode, collectingType); + + console.log('getIdClassCompletion items', items.length); + return CompletionList.create(items); +} + +function getCollectingType(attributeContext: AttributeContext): number | undefined { + if (attributeContext.inValue) { + if (attributeContext.name === 'class') { + return NodeType.ClassSelector; + } + if (attributeContext.name === 'id') { + return NodeType.IdentifierSelector; + } + } else if (attributeContext.name.startsWith('class:')) { + return NodeType.ClassSelector; + } +} + +/** + * incomplete see + * https://github.com/microsoft/vscode-css-languageservice/blob/master/src/parser/cssNodes.ts#L14 + * The enum is not exported. we have to update this whenever it changes + */ +export enum NodeType { + ClassSelector = 14, + IdentifierSelector = 15, +} + +export type CSSNode = { + type: number; + children: CSSNode[] | undefined; + getText(): string; +}; + +export function collectSelectors(stylesheet: CSSNode, type: number) { + const result: CSSNode[] = []; + walk(stylesheet, (node) => { + if (node.type === type) { + result.push(node); + } + }); + + return result.map( + (node): CompletionItem => ({ + label: node.getText().substring(1), + kind: CompletionItemKind.Keyword, + }) + ); +} + +function walk(node: CSSNode, callback: (node: CSSNode) => void) { + callback(node); + if (node.children) { + node.children.forEach((node) => walk(node, callback)); + } +} diff --git a/tools/language-server/src/plugins/css/service.ts b/tools/language-server/src/plugins/css/service.ts new file mode 100644 index 000000000..78b11296e --- /dev/null +++ b/tools/language-server/src/plugins/css/service.ts @@ -0,0 +1,48 @@ +import { getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService, LanguageService, ICSSDataProvider } from 'vscode-css-languageservice'; + +const customDataProvider: ICSSDataProvider = { + providePseudoClasses() { + return []; + }, + provideProperties() { + return []; + }, + provideAtDirectives() { + return []; + }, + providePseudoElements() { + return []; + }, +}; + +const [css, scss, less] = [getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService].map((getService) => + getService({ + customDataProviders: [customDataProvider], + }) +); + +const langs = { + css, + scss, + less, +}; + +export function getLanguage(kind?: string) { + switch (kind) { + case 'scss': + case 'text/scss': + return 'scss' as const; + case 'less': + case 'text/less': + return 'less' as const; + case 'css': + case 'text/css': + default: + return 'css' as const; + } +} + +export function getLanguageService(kind?: string): LanguageService { + const lang = getLanguage(kind); + return langs[lang]; +} diff --git a/tools/language-server/src/plugins/html/HTMLPlugin.ts b/tools/language-server/src/plugins/html/HTMLPlugin.ts new file mode 100644 index 000000000..7e0ab4861 --- /dev/null +++ b/tools/language-server/src/plugins/html/HTMLPlugin.ts @@ -0,0 +1,126 @@ +import { CompletionsProvider, FoldingRangeProvider } from '../interfaces'; +import { getEmmetCompletionParticipants, VSCodeEmmetConfig } from 'vscode-emmet-helper'; +import { getLanguageService, HTMLDocument, CompletionItem as HtmlCompletionItem, Node, FoldingRange } from 'vscode-html-languageservice'; +import { CompletionList, Position, CompletionItem, CompletionItemKind, TextEdit } from 'vscode-languageserver'; +import type { Document, DocumentManager } from '../../core/documents'; +import { isInsideExpression, isInsideFrontmatter } from '../../core/documents/utils'; +import type { ConfigManager } from '../../core/config'; + +export class HTMLPlugin implements CompletionsProvider, FoldingRangeProvider { + private lang = getLanguageService(); + private documents = new WeakMap<Document, HTMLDocument>(); + private styleScriptTemplate = new Set(['template', 'style', 'script']); + private configManager: ConfigManager; + + constructor(docManager: DocumentManager, configManager: ConfigManager) { + docManager.on('documentChange', (document) => { + this.documents.set(document, document.html); + }); + this.configManager = configManager; + } + + getCompletions(document: Document, position: Position): CompletionList | null { + const html = this.documents.get(document); + + if (!html) { + return null; + } + + if (this.isInsideFrontmatter(document, position) || this.isInsideExpression(html, document, position)) { + return null; + } + + const emmetResults: CompletionList = { + isIncomplete: true, + items: [], + }; + this.lang.setCompletionParticipants([getEmmetCompletionParticipants(document, position, 'html', this.configManager.getEmmetConfig(), emmetResults)]); + + const results = this.lang.doComplete(document, position, html); + const items = this.toCompletionItems(results.items); + + return CompletionList.create( + [...this.toCompletionItems(items), ...this.getLangCompletions(items), ...emmetResults.items], + // Emmet completions change on every keystroke, so they are never complete + emmetResults.items.length > 0 + ); + } + + getFoldingRanges(document: Document): FoldingRange[] | null { + const html = this.documents.get(document); + if (!html) { + return null; + } + + return this.lang.getFoldingRanges(document); + } + + doTagComplete(document: Document, position: Position): string | null { + const html = this.documents.get(document); + if (!html) { + return null; + } + + if (this.isInsideFrontmatter(document, position) || this.isInsideExpression(html, document, position)) { + return null; + } + + return this.lang.doTagComplete(document, position, html); + } + + /** + * The HTML language service uses newer types which clash + * without the stable ones. Transform to the stable types. + */ + private toCompletionItems(items: HtmlCompletionItem[]): CompletionItem[] { + return items.map((item) => { + if (!item.textEdit || TextEdit.is(item.textEdit)) { + return item as CompletionItem; + } + return { + ...item, + textEdit: TextEdit.replace(item.textEdit.replace, item.textEdit.newText), + }; + }); + } + + private getLangCompletions(completions: CompletionItem[]): CompletionItem[] { + const styleScriptTemplateCompletions = completions.filter((completion) => completion.kind === CompletionItemKind.Property && this.styleScriptTemplate.has(completion.label)); + const langCompletions: CompletionItem[] = []; + addLangCompletion('style', ['scss', 'sass']); + return langCompletions; + + /** Add language completions */ + function addLangCompletion(tag: string, languages: string[]) { + const existingCompletion = styleScriptTemplateCompletions.find((completion) => completion.label === tag); + if (!existingCompletion) { + return; + } + + languages.forEach((lang) => + langCompletions.push({ + ...existingCompletion, + label: `${tag} (lang="${lang}")`, + insertText: existingCompletion.insertText && `${existingCompletion.insertText} lang="${lang}"`, + textEdit: + existingCompletion.textEdit && TextEdit.is(existingCompletion.textEdit) + ? { + range: existingCompletion.textEdit.range, + newText: `${existingCompletion.textEdit.newText} lang="${lang}"`, + } + : undefined, + }) + ); + } + } + + private isInsideExpression(html: HTMLDocument, document: Document, position: Position) { + const offset = document.offsetAt(position); + const node = html.findNodeAt(offset); + return isInsideExpression(document.getText(), node.start, offset); + } + + private isInsideFrontmatter(document: Document, position: Position) { + return isInsideFrontmatter(document.getText(), document.offsetAt(position)); + } +} diff --git a/tools/language-server/src/plugins/index.ts b/tools/language-server/src/plugins/index.ts new file mode 100644 index 000000000..bb73cbe5e --- /dev/null +++ b/tools/language-server/src/plugins/index.ts @@ -0,0 +1,6 @@ +export * from './PluginHost'; +export * from './astro/AstroPlugin'; +export * from './html/HTMLPlugin'; +export * from './typescript/TypeScriptPlugin'; +export * from './interfaces'; +export * from './css/CSSPlugin'; diff --git a/tools/language-server/src/plugins/interfaces.ts b/tools/language-server/src/plugins/interfaces.ts new file mode 100644 index 000000000..b68100de1 --- /dev/null +++ b/tools/language-server/src/plugins/interfaces.ts @@ -0,0 +1,167 @@ +import { CompletionContext, FileChangeType, LinkedEditingRanges, SemanticTokens, SignatureHelpContext, TextDocumentContentChangeEvent } from 'vscode-languageserver'; +import { + CodeAction, + CodeActionContext, + Color, + ColorInformation, + ColorPresentation, + CompletionItem, + CompletionList, + DefinitionLink, + Diagnostic, + FormattingOptions, + Hover, + Location, + Position, + Range, + ReferenceContext, + SymbolInformation, + TextDocumentIdentifier, + TextEdit, + WorkspaceEdit, + SelectionRange, + SignatureHelp, + FoldingRange, +} from 'vscode-languageserver-types'; +import { Document } from '../core/documents'; + +export type Resolvable<T> = T | Promise<T>; + +export interface AppCompletionItem<T extends TextDocumentIdentifier = any> extends CompletionItem { + data?: T; +} + +export interface AppCompletionList<T extends TextDocumentIdentifier = any> extends CompletionList { + items: Array<AppCompletionItem<T>>; +} + +export interface DiagnosticsProvider { + getDiagnostics(document: Document): Resolvable<Diagnostic[]>; +} + +export interface HoverProvider { + doHover(document: Document, position: Position): Resolvable<Hover | null>; +} + +export interface FoldingRangeProvider { + getFoldingRanges(document: Document): Resolvable<FoldingRange[] | null>; +} + +export interface CompletionsProvider<T extends TextDocumentIdentifier = any> { + getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Resolvable<AppCompletionList<T> | null>; + + resolveCompletion?(document: Document, completionItem: AppCompletionItem<T>): Resolvable<AppCompletionItem<T>>; +} + +export interface FormattingProvider { + formatDocument(document: Document, options: FormattingOptions): Resolvable<TextEdit[]>; +} + +export interface TagCompleteProvider { + doTagComplete(document: Document, position: Position): Resolvable<string | null>; +} + +export interface DocumentColorsProvider { + getDocumentColors(document: Document): Resolvable<ColorInformation[]>; +} + +export interface ColorPresentationsProvider { + getColorPresentations(document: Document, range: Range, color: Color): Resolvable<ColorPresentation[]>; +} + +export interface DocumentSymbolsProvider { + getDocumentSymbols(document: Document): Resolvable<SymbolInformation[]>; +} + +export interface DefinitionsProvider { + getDefinitions(document: Document, position: Position): Resolvable<DefinitionLink[]>; +} + +export interface BackwardsCompatibleDefinitionsProvider { + getDefinitions(document: Document, position: Position): Resolvable<DefinitionLink[] | Location[]>; +} + +export interface CodeActionsProvider { + getCodeActions(document: Document, range: Range, context: CodeActionContext): Resolvable<CodeAction[]>; + executeCommand?(document: Document, command: string, args?: any[]): Resolvable<WorkspaceEdit | string | null>; +} + +export interface FileRename { + oldUri: string; + newUri: string; +} + +export interface UpdateImportsProvider { + updateImports(fileRename: FileRename): Resolvable<WorkspaceEdit | null>; +} + +export interface RenameProvider { + rename(document: Document, position: Position, newName: string): Resolvable<WorkspaceEdit | null>; + prepareRename(document: Document, position: Position): Resolvable<Range | null>; +} + +export interface FindReferencesProvider { + findReferences(document: Document, position: Position, context: ReferenceContext): Promise<Location[] | null>; +} + +export interface SignatureHelpProvider { + getSignatureHelp(document: Document, position: Position, context: SignatureHelpContext | undefined): Resolvable<SignatureHelp | null>; +} + +export interface SelectionRangeProvider { + getSelectionRange(document: Document, position: Position): Resolvable<SelectionRange | null>; +} + +export interface SemanticTokensProvider { + getSemanticTokens(textDocument: Document, range?: Range): Resolvable<SemanticTokens | null>; +} + +export interface LinkedEditingRangesProvider { + getLinkedEditingRanges(document: Document, position: Position): Resolvable<LinkedEditingRanges | null>; +} + +export interface OnWatchFileChangesPara { + fileName: string; + changeType: FileChangeType; +} + +export interface OnWatchFileChanges { + onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void; +} + +export interface UpdateTsOrJsFile { + updateTsOrJsFile(fileName: string, changes: TextDocumentContentChangeEvent[]): void; +} + +type ProviderBase = DiagnosticsProvider & + HoverProvider & + CompletionsProvider & + FormattingProvider & + FoldingRangeProvider & + TagCompleteProvider & + DocumentColorsProvider & + ColorPresentationsProvider & + DocumentSymbolsProvider & + UpdateImportsProvider & + CodeActionsProvider & + FindReferencesProvider & + RenameProvider & + SignatureHelpProvider & + SemanticTokensProvider & + LinkedEditingRangesProvider; + +export type LSProvider = ProviderBase & BackwardsCompatibleDefinitionsProvider; + +export interface LSPProviderConfig { + /** + * Whether or not completion lists that are marked as imcomplete + * should be filtered server side. + */ + filterIncompleteCompletions: boolean; + /** + * Whether or not getDefinitions supports the LocationLink interface. + */ + definitionLinkSupport: boolean; +} + +export type Plugin = Partial<ProviderBase & DefinitionsProvider & OnWatchFileChanges & SelectionRangeProvider & UpdateTsOrJsFile>; diff --git a/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts b/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts new file mode 100644 index 000000000..9e2e778c6 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts @@ -0,0 +1,242 @@ +import * as ts from 'typescript'; +import { TextDocumentContentChangeEvent, Position } from 'vscode-languageserver'; +import { Document, DocumentMapper, IdentityMapper } from '../../core/documents'; +import { isInTag, positionAt, offsetAt } from '../../core/documents/utils'; +import { pathToUrl } from '../../utils'; +import { getScriptKindFromFileName, isAstroFilePath, toVirtualAstroFilePath } from './utils'; + +/** + * The mapper to get from original snapshot positions to generated and vice versa. + */ +export interface SnapshotFragment extends DocumentMapper { + positionAt(offset: number): Position; + offsetAt(position: Position): number; +} + +export interface DocumentSnapshot extends ts.IScriptSnapshot { + version: number; + filePath: string; + scriptKind: ts.ScriptKind; + positionAt(offset: number): Position; + /** + * Instantiates a source mapper. + * `destroyFragment` needs to be called when + * it's no longer needed / the class should be cleaned up + * in order to prevent memory leaks. + */ + getFragment(): Promise<DocumentFragmentSnapshot>; + /** + * Needs to be called when source mapper + * is no longer needed / the class should be cleaned up + * in order to prevent memory leaks. + */ + destroyFragment(): void; + /** + * Convenience function for getText(0, getLength()) + */ + getFullText(): string; +} + +export const createDocumentSnapshot = (filePath: string, currentText: string | null, createDocument?: (_filePath: string, text: string) => Document): DocumentSnapshot => { + const text = currentText || (ts.sys.readFile(filePath) ?? ''); + + if (isAstroFilePath(filePath)) { + if (!createDocument) throw new Error('Astro documents require the "createDocument" utility to be provided'); + const snapshot = new AstroDocumentSnapshot(createDocument(filePath, text)); + return snapshot; + } + + return new TypeScriptDocumentSnapshot(0, filePath, text); +}; + +class AstroDocumentSnapshot implements DocumentSnapshot { + version = this.doc.version; + scriptKind = ts.ScriptKind.Unknown; + + constructor(private doc: Document) {} + + async getFragment(): Promise<DocumentFragmentSnapshot> { + const uri = pathToUrl(this.filePath); + const mapper = await this.getMapper(uri); + return new DocumentFragmentSnapshot(mapper, this.doc); + } + + async destroyFragment() { + return; + } + + get text() { + return this.doc.getText(); + } + + get filePath() { + return this.doc.getFilePath() || ''; + } + + getText(start: number, end: number) { + return this.text.substring(start, end); + } + + getLength() { + return this.text.length; + } + + getFullText() { + return this.text; + } + + getChangeRange() { + return undefined; + } + + positionAt(offset: number) { + return positionAt(offset, this.text); + } + + getLineContainingOffset(offset: number) { + const chunks = this.getText(0, offset).split('\n'); + return chunks[chunks.length - 1]; + } + + offsetAt(position: Position) { + return offsetAt(position, this.text); + } + + private getMapper(uri: string) { + return new IdentityMapper(uri); + } +} + +export class DocumentFragmentSnapshot implements Omit<DocumentSnapshot, 'getFragment' | 'destroyFragment'>, SnapshotFragment { + version: number; + filePath: string; + url: string; + text: string; + + scriptKind = ts.ScriptKind.TSX; + scriptInfo = null; + + constructor(private mapper: any, private parent: Document) { + const filePath = parent.getFilePath(); + if (!filePath) throw new Error('Cannot create a document fragment from a non-local document'); + const text = parent.getText(); + this.version = parent.version; + this.filePath = toVirtualAstroFilePath(filePath); + this.url = toVirtualAstroFilePath(filePath); + this.text = this.transformContent(text); + } + + /** @internal */ + private transformContent(content: string) { + return content.replace(/---/g, '///'); + } + + getText(start: number, end: number) { + return this.text.substring(start, end); + } + + getLength() { + return this.text.length; + } + + getFullText() { + return this.text; + } + + getChangeRange() { + return undefined; + } + + positionAt(offset: number) { + return positionAt(offset, this.text); + } + + getLineContainingOffset(offset: number) { + const chunks = this.getText(0, offset).split('\n'); + return chunks[chunks.length - 1]; + } + + offsetAt(position: Position): number { + return offsetAt(position, this.text); + } + + getOriginalPosition(pos: Position): Position { + return this.mapper.getOriginalPosition(pos); + } + + getGeneratedPosition(pos: Position): Position { + return this.mapper.getGeneratedPosition(pos); + } + + isInGenerated(pos: Position): boolean { + return !isInTag(pos, this.parent.styleInfo); + } + + getURL(): string { + return this.url; + } +} + +export class TypeScriptDocumentSnapshot implements DocumentSnapshot { + scriptKind = getScriptKindFromFileName(this.filePath); + scriptInfo = null; + url: string; + + constructor(public version: number, public readonly filePath: string, private text: string) { + this.url = pathToUrl(filePath); + } + + getText(start: number, end: number) { + return this.text.substring(start, end); + } + + getLength() { + return this.text.length; + } + + getFullText() { + return this.text; + } + + getChangeRange() { + return undefined; + } + + positionAt(offset: number) { + return positionAt(offset, this.text); + } + + offsetAt(position: Position): number { + return offsetAt(position, this.text); + } + + async getFragment(): Promise<DocumentFragmentSnapshot> { + return this as unknown as any; + } + + destroyFragment() { + // nothing to clean up + } + + getLineContainingOffset(offset: number) { + const chunks = this.getText(0, offset).split('\n'); + return chunks[chunks.length - 1]; + } + + update(changes: TextDocumentContentChangeEvent[]): void { + for (const change of changes) { + let start = 0; + let end = 0; + if ('range' in change) { + start = this.offsetAt(change.range.start); + end = this.offsetAt(change.range.end); + } else { + end = this.getLength(); + } + + this.text = this.text.slice(0, start) + change.text + this.text.slice(end); + } + + this.version++; + } +} diff --git a/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts b/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts new file mode 100644 index 000000000..3ebcfdd77 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts @@ -0,0 +1,81 @@ +import * as ts from 'typescript'; +import type { Document, DocumentManager } from '../../core/documents'; +import type { ConfigManager } from '../../core/config'; +import { urlToPath, pathToUrl, debounceSameArg } from '../../utils'; +import { getLanguageService, getLanguageServiceForDocument, LanguageServiceContainer, LanguageServiceDocumentContext } from './languageService'; +import { SnapshotManager } from './SnapshotManager'; +import { DocumentSnapshot } from './DocumentSnapshot'; + +export class LanguageServiceManager { + private readonly docManager: DocumentManager; + private readonly configManager: ConfigManager; + private readonly workspaceUris: string[]; + private docContext: LanguageServiceDocumentContext; + + constructor(docManager: DocumentManager, configManager: ConfigManager, workspaceUris: string[]) { + this.docManager = docManager; + this.configManager = configManager; + this.workspaceUris = workspaceUris; + this.docContext = { + getWorkspaceRoot: (fileName: string) => this.getWorkspaceRoot(fileName), + createDocument: this.createDocument, + }; + + const handleDocumentChange = (document: Document) => { + // This refreshes the document in the ts language service + this.getTypeScriptDoc(document); + }; + + docManager.on( + 'documentChange', + debounceSameArg(handleDocumentChange, (newDoc, prevDoc) => newDoc.uri === prevDoc?.uri, 1000) + ); + docManager.on('documentOpen', handleDocumentChange); + } + + private getWorkspaceRoot(fileName: string) { + if (this.workspaceUris.length === 1) return urlToPath(this.workspaceUris[0]) as string; + return this.workspaceUris.reduce((found, curr) => { + const url = urlToPath(curr) as string; + if (fileName.startsWith(url) && curr.length < url.length) return url; + return found; + }, ''); + } + + private createDocument = (fileName: string, content: string) => { + const uri = pathToUrl(fileName); + const document = this.docManager.openDocument({ + languageId: 'astro', + version: 0, + text: content, + uri, + }); + return document; + }; + + async getSnapshot(document: Document): Promise<DocumentSnapshot>; + async getSnapshot(pathOrDoc: string | Document): Promise<DocumentSnapshot>; + async getSnapshot(pathOrDoc: string | Document) { + const filePath = typeof pathOrDoc === 'string' ? pathOrDoc : pathOrDoc.getFilePath() || ''; + const tsService = await this.getTypeScriptLanguageService(filePath); + return tsService.updateDocument(pathOrDoc); + } + + async getTypeScriptDoc(document: Document): Promise<{ + tsDoc: DocumentSnapshot; + lang: ts.LanguageService; + }> { + const lang = await getLanguageServiceForDocument(document, this.workspaceUris, this.docContext); + const tsDoc = await this.getSnapshot(document); + + return { tsDoc, lang }; + } + + async getSnapshotManager(filePath: string): Promise<SnapshotManager> { + return (await this.getTypeScriptLanguageService(filePath)).snapshotManager; + } + + private getTypeScriptLanguageService(filePath: string): Promise<LanguageServiceContainer> { + return getLanguageService(filePath, this.workspaceUris, this.docContext); + } +} diff --git a/tools/language-server/src/plugins/typescript/SnapshotManager.ts b/tools/language-server/src/plugins/typescript/SnapshotManager.ts new file mode 100644 index 000000000..5a406b945 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/SnapshotManager.ts @@ -0,0 +1,95 @@ +import * as ts from 'typescript'; +import { TextDocumentContentChangeEvent } from 'vscode-languageserver'; +import { toVirtualAstroFilePath } from './utils'; +import { DocumentSnapshot, TypeScriptDocumentSnapshot, createDocumentSnapshot } from './DocumentSnapshot'; + +export interface TsFilesSpec { + include?: readonly string[]; + exclude?: readonly string[]; +} + +export class SnapshotManager { + private documents: Map<string, DocumentSnapshot> = new Map(); + private lastLogged = new Date(new Date().getTime() - 60_001); + + private readonly watchExtensions = [ts.Extension.Dts, ts.Extension.Js, ts.Extension.Jsx, ts.Extension.Ts, ts.Extension.Tsx, ts.Extension.Json]; + + constructor(private projectFiles: string[], private fileSpec: TsFilesSpec, private workspaceRoot: string) {} + + updateProjectFiles() { + const { include, exclude } = this.fileSpec; + + if (include?.length === 0) return; + + const projectFiles = ts.sys.readDirectory(this.workspaceRoot, this.watchExtensions, exclude, include); + + this.projectFiles = Array.from(new Set([...this.projectFiles, ...projectFiles])); + } + + updateProjectFile(fileName: string, changes?: TextDocumentContentChangeEvent[]): void { + const previousSnapshot = this.get(fileName); + + if (changes) { + if (!(previousSnapshot instanceof TypeScriptDocumentSnapshot)) { + return; + } + previousSnapshot.update(changes); + } else { + const newSnapshot = createDocumentSnapshot(fileName, null); + + if (previousSnapshot) { + newSnapshot.version = previousSnapshot.version + 1; + } else { + // ensure it's greater than initial version + // so that ts server picks up the change + newSnapshot.version += 1; + } + this.set(fileName, newSnapshot); + } + } + + has(fileName: string) { + return this.projectFiles.includes(fileName) || this.getFileNames().includes(fileName); + } + + get(fileName: string) { + return this.documents.get(fileName); + } + + set(fileName: string, snapshot: DocumentSnapshot) { + // const prev = this.get(fileName); + this.logStatistics(); + return this.documents.set(fileName, snapshot); + } + + delete(fileName: string) { + this.projectFiles = this.projectFiles.filter((s) => s !== fileName); + return this.documents.delete(fileName); + } + + getFileNames() { + return Array.from(this.documents.keys()).map((fileName) => toVirtualAstroFilePath(fileName)); + } + + getProjectFileNames() { + return [...this.projectFiles]; + } + + private logStatistics() { + const date = new Date(); + // Don't use setInterval because that will keep tests running forever + if (date.getTime() - this.lastLogged.getTime() > 60_000) { + this.lastLogged = date; + + const projectFiles = this.getProjectFileNames(); + const allFiles = Array.from(new Set([...projectFiles, ...this.getFileNames()])); + console.log( + 'SnapshotManager File Statistics:\n' + + `Project files: ${projectFiles.length}\n` + + `Astro files: ${allFiles.filter((name) => name.endsWith('.astro')).length}\n` + + `From node_modules: ${allFiles.filter((name) => name.includes('node_modules')).length}\n` + + `Total: ${allFiles.length}` + ); + } + } +} diff --git a/tools/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/tools/language-server/src/plugins/typescript/TypeScriptPlugin.ts new file mode 100644 index 000000000..10b94cb83 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -0,0 +1,113 @@ +import { Document, DocumentManager, isInsideFrontmatter } from '../../core/documents'; +import type { ConfigManager } from '../../core/config'; +import type { CompletionsProvider, AppCompletionItem, AppCompletionList } from '../interfaces'; +import { CompletionContext, DefinitionLink, FileChangeType, Position, LocationLink } from 'vscode-languageserver'; +import * as ts from 'typescript'; +import { CompletionsProviderImpl, CompletionEntryWithIdentifer } from './features/CompletionsProvider'; +import { LanguageServiceManager } from './LanguageServiceManager'; +import { SnapshotManager } from './SnapshotManager'; +import { convertToLocationRange, isVirtualFilePath, getScriptKindFromFileName } from './utils'; +import { isNoTextSpanInGeneratedCode, SnapshotFragmentMap } from './features/utils'; +import { isNotNullOrUndefined, pathToUrl } from '../../utils'; + +export class TypeScriptPlugin implements CompletionsProvider { + private readonly docManager: DocumentManager; + private readonly configManager: ConfigManager; + private readonly languageServiceManager: LanguageServiceManager; + + private readonly completionProvider: CompletionsProviderImpl; + + constructor(docManager: DocumentManager, configManager: ConfigManager, workspaceUris: string[]) { + this.docManager = docManager; + this.configManager = configManager; + this.languageServiceManager = new LanguageServiceManager(docManager, configManager, workspaceUris); + + this.completionProvider = new CompletionsProviderImpl(this.languageServiceManager); + } + + async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise<AppCompletionList<CompletionEntryWithIdentifer> | null> { + const completions = await this.completionProvider.getCompletions(document, position, completionContext); + + return completions; + } + + async resolveCompletion(document: Document, completionItem: AppCompletionItem<CompletionEntryWithIdentifer>): Promise<AppCompletionItem<CompletionEntryWithIdentifer>> { + return this.completionProvider.resolveCompletion(document, completionItem); + } + + async getDefinitions(document: Document, position: Position): Promise<DefinitionLink[]> { + if (!this.isInsideFrontmatter(document, position)) { + return []; + } + + const { lang, tsDoc } = await this.languageServiceManager.getTypeScriptDoc(document); + const mainFragment = await tsDoc.getFragment(); + + const filePath = tsDoc.filePath; + const tsFilePath = filePath.endsWith('.ts') ? filePath : filePath + '.ts'; + + const defs = lang.getDefinitionAndBoundSpan(tsFilePath, mainFragment.offsetAt(mainFragment.getGeneratedPosition(position))); + + if (!defs || !defs.definitions) { + return []; + } + + const docs = new SnapshotFragmentMap(this.languageServiceManager); + docs.set(tsDoc.filePath, { fragment: mainFragment, snapshot: tsDoc }); + + const result = await Promise.all( + defs.definitions.map(async (def) => { + const { fragment, snapshot } = await docs.retrieve(def.fileName); + + if (isNoTextSpanInGeneratedCode(snapshot.getFullText(), def.textSpan)) { + const fileName = isVirtualFilePath(def.fileName) ? def.fileName.substr(0, def.fileName.length - 3) : def.fileName; + return LocationLink.create( + pathToUrl(fileName), + convertToLocationRange(fragment, def.textSpan), + convertToLocationRange(fragment, def.textSpan), + convertToLocationRange(mainFragment, defs.textSpan) + ); + } + }) + ); + return result.filter(isNotNullOrUndefined); + } + + async onWatchFileChanges(onWatchFileChangesParams: any[]): Promise<void> { + const doneUpdateProjectFiles = new Set<SnapshotManager>(); + + for (const { fileName, changeType } of onWatchFileChangesParams) { + const scriptKind = getScriptKindFromFileName(fileName); + + if (scriptKind === ts.ScriptKind.Unknown) { + // We don't deal with svelte files here + continue; + } + + const snapshotManager = await this.getSnapshotManager(fileName); + if (changeType === FileChangeType.Created) { + if (!doneUpdateProjectFiles.has(snapshotManager)) { + snapshotManager.updateProjectFiles(); + doneUpdateProjectFiles.add(snapshotManager); + } + } else if (changeType === FileChangeType.Deleted) { + snapshotManager.delete(fileName); + return; + } + + snapshotManager.updateProjectFile(fileName); + } + } + + /** + * + * @internal + */ + public async getSnapshotManager(fileName: string) { + return this.languageServiceManager.getSnapshotManager(fileName); + } + + private isInsideFrontmatter(document: Document, position: Position) { + return isInsideFrontmatter(document.getText(), document.offsetAt(position)); + } +} diff --git a/tools/language-server/src/plugins/typescript/astro-sys.ts b/tools/language-server/src/plugins/typescript/astro-sys.ts new file mode 100644 index 000000000..57cd3b497 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/astro-sys.ts @@ -0,0 +1,42 @@ +import * as ts from 'typescript'; +import { DocumentSnapshot } from './SnapshotManager'; +import { ensureRealAstroFilePath, isAstroFilePath, isVirtualAstroFilePath, toRealAstroFilePath } from './utils'; + +/** + * This should only be accessed by TS Astro module resolution. + */ +export function createAstroSys(getSnapshot: (fileName: string) => DocumentSnapshot) { + const AstroSys: ts.System = { + ...ts.sys, + fileExists(path: string) { + if (isAstroFilePath(path) || isVirtualAstroFilePath(path)) { + console.log('fileExists', path, ts.sys.fileExists(ensureRealAstroFilePath(path))); + } + return ts.sys.fileExists(ensureRealAstroFilePath(path)); + }, + readFile(path: string) { + if (isAstroFilePath(path) || isVirtualAstroFilePath(path)) { + console.log('readFile', path); + } + const snapshot = getSnapshot(path); + return snapshot.getFullText(); + }, + readDirectory(path, extensions, exclude, include, depth) { + const extensionsWithAstro = (extensions ?? []).concat(...['.astro', '.svelte', '.vue']); + const result = ts.sys.readDirectory(path, extensionsWithAstro, exclude, include, depth); + return result; + }, + }; + + if (ts.sys.realpath) { + const realpath = ts.sys.realpath; + AstroSys.realpath = function (path) { + if (isVirtualAstroFilePath(path)) { + return realpath(toRealAstroFilePath(path)) + '.ts'; + } + return realpath(path); + }; + } + + return AstroSys; +} diff --git a/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts b/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts new file mode 100644 index 000000000..d13269c5c --- /dev/null +++ b/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts @@ -0,0 +1,134 @@ +import { isInsideFrontmatter } from '../../../core/documents/utils'; +import { Document } from '../../../core/documents'; +import * as ts from 'typescript'; +import { CompletionContext, CompletionList, CompletionItem, Position, TextDocumentIdentifier, TextEdit, MarkupKind, MarkupContent } from 'vscode-languageserver'; +import { AppCompletionItem, AppCompletionList, CompletionsProvider } from '../../interfaces'; +import type { LanguageServiceManager } from '../LanguageServiceManager'; +import { scriptElementKindToCompletionItemKind, getCommitCharactersForScriptElement } from '../utils'; + +export interface CompletionEntryWithIdentifer extends ts.CompletionEntry, TextDocumentIdentifier { + position: Position; +} + +export class CompletionsProviderImpl implements CompletionsProvider<CompletionEntryWithIdentifer> { + constructor(private lang: LanguageServiceManager) {} + + async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise<AppCompletionList<CompletionEntryWithIdentifer> | null> { + // TODO: handle inside expression + if (!isInsideFrontmatter(document.getText(), document.offsetAt(position))) { + return null; + } + + const filePath = document.getFilePath(); + if (!filePath) throw new Error(); + + const { tsDoc, lang } = await this.lang.getTypeScriptDoc(document); + const fragment = await tsDoc.getFragment(); + + const offset = document.offsetAt(position); + const entries = + lang.getCompletionsAtPosition(fragment.filePath, offset, { + importModuleSpecifierPreference: 'relative', + importModuleSpecifierEnding: 'js', + quotePreference: 'single', + })?.entries || []; + + const completionItems = entries + .map((entry: ts.CompletionEntry) => this.toCompletionItem(fragment, entry, document.uri, position, new Set())) + .filter((i) => i) as CompletionItem[]; + + return CompletionList.create(completionItems, true); + } + + async resolveCompletion(document: Document, completionItem: AppCompletionItem<CompletionEntryWithIdentifer>): Promise<AppCompletionItem<CompletionEntryWithIdentifer>> { + const { data: comp } = completionItem; + const { tsDoc, lang } = await this.lang.getTypeScriptDoc(document); + + let filePath = tsDoc.filePath; + + if (!comp || !filePath) { + return completionItem; + } + + if (filePath.endsWith('.astro')) { + filePath = filePath + '.ts'; + } + + const fragment = await tsDoc.getFragment(); + const detail = lang.getCompletionEntryDetails(filePath, fragment.offsetAt(comp.position), comp.name, {}, comp.source, {}, undefined); + + if (detail) { + const { detail: itemDetail, documentation: itemDocumentation } = this.getCompletionDocument(detail); + + completionItem.detail = itemDetail; + completionItem.documentation = itemDocumentation; + } + + // const actions = detail?.codeActions; + // const isImport = !!detail?.source; + + // TODO: handle actions + // if (actions) { + // const edit: TextEdit[] = []; + + // for (const action of actions) { + // for (const change of action.changes) { + // edit.push( + // ...this.codeActionChangesToTextEdit( + // document, + // fragment, + // change, + // isImport, + // isInsideFrontmatter(fragment.getFullText(), fragment.offsetAt(comp.position)) + // ) + // ); + // } + // } + + // completionItem.additionalTextEdits = edit; + // } + + return completionItem; + } + + private toCompletionItem( + fragment: any, + comp: ts.CompletionEntry, + uri: string, + position: Position, + existingImports: Set<string> + ): AppCompletionItem<CompletionEntryWithIdentifer> | null { + return { + label: comp.name, + insertText: comp.insertText, + kind: scriptElementKindToCompletionItemKind(comp.kind), + commitCharacters: getCommitCharactersForScriptElement(comp.kind), + // Make sure svelte component takes precedence + sortText: comp.sortText, + preselect: comp.isRecommended, + // pass essential data for resolving completion + data: { + ...comp, + uri, + position, + }, + }; + } + + private getCompletionDocument(compDetail: ts.CompletionEntryDetails) { + const { source, documentation: tsDocumentation, displayParts, tags } = compDetail; + let detail: string = ts.displayPartsToString(displayParts); + + if (source) { + const importPath = ts.displayPartsToString(source); + detail = `Auto import from ${importPath}\n${detail}`; + } + + const documentation: MarkupContent | undefined = tsDocumentation ? { value: tsDocumentation.join('\n'), kind: MarkupKind.Markdown } : undefined; + + return { + documentation, + detail, + }; + } +} diff --git a/tools/language-server/src/plugins/typescript/features/utils.ts b/tools/language-server/src/plugins/typescript/features/utils.ts new file mode 100644 index 000000000..8c87dc5f4 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/features/utils.ts @@ -0,0 +1,54 @@ +import type { SnapshotFragment, DocumentSnapshot } from '../DocumentSnapshot'; +import type { LanguageServiceManager } from '../LanguageServiceManager'; + +/** + * Checks if this a section that should be completely ignored + * because it's purely generated. + */ +export function isInGeneratedCode(text: string, start: number, end: number) { + const lineStart = text.lastIndexOf('\n', start); + const lineEnd = text.indexOf('\n', end); + const lastStart = text.substring(lineStart, start).lastIndexOf('/*Ωignore_startΩ*/'); + const lastEnd = text.substring(lineStart, start).lastIndexOf('/*Ωignore_endΩ*/'); + return lastStart > lastEnd && text.substring(end, lineEnd).includes('/*Ωignore_endΩ*/'); +} + +/** + * Checks that this isn't a text span that should be completely ignored + * because it's purely generated. + */ +export function isNoTextSpanInGeneratedCode(text: string, span: ts.TextSpan) { + return !isInGeneratedCode(text, span.start, span.start + span.length); +} + +export class SnapshotFragmentMap { + private map = new Map<string, { fragment: SnapshotFragment; snapshot: DocumentSnapshot }>(); + constructor(private languageServiceManager: LanguageServiceManager) {} + + set(fileName: string, content: { fragment: SnapshotFragment; snapshot: DocumentSnapshot }) { + this.map.set(fileName, content); + } + + get(fileName: string) { + return this.map.get(fileName); + } + + getFragment(fileName: string) { + return this.map.get(fileName)?.fragment; + } + + async retrieve(fileName: string) { + let snapshotFragment = this.get(fileName); + if (!snapshotFragment) { + const snapshot = await this.languageServiceManager.getSnapshot(fileName); + const fragment = await snapshot.getFragment(); + snapshotFragment = { fragment, snapshot }; + this.set(fileName, snapshotFragment); + } + return snapshotFragment; + } + + async retrieveFragment(fileName: string) { + return (await this.retrieve(fileName)).fragment; + } +} diff --git a/tools/language-server/src/plugins/typescript/languageService.ts b/tools/language-server/src/plugins/typescript/languageService.ts new file mode 100644 index 000000000..22e2b1cdd --- /dev/null +++ b/tools/language-server/src/plugins/typescript/languageService.ts @@ -0,0 +1,184 @@ +/* eslint-disable require-jsdoc */ + +import * as ts from 'typescript'; +import { basename } from 'path'; +import { ensureRealAstroFilePath, findTsConfigPath } from './utils'; +import { Document } from '../../core/documents'; +import { SnapshotManager } from './SnapshotManager'; +import { createDocumentSnapshot, DocumentSnapshot } from './DocumentSnapshot'; +import { createAstroModuleLoader } from './module-loader'; + +const services = new Map<string, Promise<LanguageServiceContainer>>(); + +export interface LanguageServiceContainer { + readonly tsconfigPath: string; + readonly snapshotManager: SnapshotManager; + getService(): ts.LanguageService; + updateDocument(documentOrFilePath: Document | string): ts.IScriptSnapshot; + deleteDocument(filePath: string): void; +} + +export interface LanguageServiceDocumentContext { + getWorkspaceRoot(fileName: string): string; + createDocument: (fileName: string, content: string) => Document; +} + +export async function getLanguageService(path: string, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise<LanguageServiceContainer> { + const tsconfigPath = findTsConfigPath(path, workspaceUris); + const workspaceRoot = docContext.getWorkspaceRoot(path); + + let service: LanguageServiceContainer; + if (services.has(tsconfigPath)) { + service = (await services.get(tsconfigPath)) as LanguageServiceContainer; + } else { + const newServicePromise = createLanguageService(tsconfigPath, workspaceRoot, docContext); + services.set(tsconfigPath, newServicePromise); + service = await newServicePromise; + } + + return service; +} + +export async function getLanguageServiceForDocument(document: Document, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise<ts.LanguageService> { + return getLanguageServiceForPath(document.getFilePath() || '', workspaceUris, docContext); +} + +export async function getLanguageServiceForPath(path: string, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise<ts.LanguageService> { + return (await getLanguageService(path, workspaceUris, docContext)).getService(); +} + +async function createLanguageService(tsconfigPath: string, workspaceRoot: string, docContext: LanguageServiceDocumentContext): Promise<LanguageServiceContainer> { + const parseConfigHost: ts.ParseConfigHost = { + ...ts.sys, + readDirectory: (path, extensions, exclude, include, depth) => { + return ts.sys.readDirectory(path, [...extensions, '.vue', '.svelte', '.astro', '.js', '.jsx'], exclude, include, depth); + }, + }; + + let configJson = (tsconfigPath && ts.readConfigFile(tsconfigPath, ts.sys.readFile).config) || getDefaultJsConfig(); + if (!configJson.extends) { + configJson = Object.assign( + { + exclude: getDefaultExclude(), + }, + configJson + ); + } + + const project = ts.parseJsonConfigFileContent(configJson, parseConfigHost, workspaceRoot, {}, basename(tsconfigPath), undefined, [ + { extension: '.vue', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }, + { extension: '.svelte', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }, + { extension: '.astro', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }, + ]); + + let projectVersion = 0; + + const snapshotManager = new SnapshotManager( + project.fileNames, + { + exclude: ['node_modules', 'dist'], + include: ['src'], + }, + workspaceRoot || process.cwd() + ); + + const astroModuleLoader = createAstroModuleLoader(getScriptSnapshot, {}); + + const host: ts.LanguageServiceHost = { + getNewLine: () => ts.sys.newLine, + useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, + readFile: astroModuleLoader.readFile, + writeFile: astroModuleLoader.writeFile, + fileExists: astroModuleLoader.fileExists, + directoryExists: astroModuleLoader.directoryExists, + getDirectories: astroModuleLoader.getDirectories, + readDirectory: astroModuleLoader.readDirectory, + realpath: astroModuleLoader.realpath, + + getCompilationSettings: () => project.options, + getCurrentDirectory: () => workspaceRoot, + getDefaultLibFileName: () => ts.getDefaultLibFilePath(project.options), + + getProjectVersion: () => `${projectVersion}`, + getScriptFileNames: () => Array.from(new Set([...snapshotManager.getFileNames(), ...snapshotManager.getProjectFileNames()])), + getScriptSnapshot, + getScriptVersion: (fileName: string) => getScriptSnapshot(fileName).version.toString(), + }; + + const languageService: ts.LanguageService = ts.createLanguageService(host); + const languageServiceProxy = new Proxy(languageService, { + get(target, prop) { + return Reflect.get(target, prop); + }, + }); + + return { + tsconfigPath, + snapshotManager, + getService: () => languageServiceProxy, + updateDocument, + deleteDocument, + }; + + function onProjectUpdated() { + projectVersion++; + } + + function deleteDocument(filePath: string) { + snapshotManager.delete(filePath); + } + + function updateDocument(documentOrFilePath: Document | string) { + const filePath = ensureRealAstroFilePath(typeof documentOrFilePath === 'string' ? documentOrFilePath : documentOrFilePath.getFilePath() || ''); + const document = typeof documentOrFilePath === 'string' ? undefined : documentOrFilePath; + + if (!filePath) { + throw new Error(`Unable to find document`); + } + + const previousSnapshot = snapshotManager.get(filePath); + if (document && previousSnapshot?.version.toString() === `${document.version}`) { + return previousSnapshot; + } + + const currentText = document ? document.getText() : null; + const snapshot = createDocumentSnapshot(filePath, currentText, docContext.createDocument); + snapshotManager.set(filePath, snapshot); + onProjectUpdated(); + return snapshot; + } + + function getScriptSnapshot(fileName: string): DocumentSnapshot { + fileName = ensureRealAstroFilePath(fileName); + + let doc = snapshotManager.get(fileName); + if (doc) { + return doc; + } + + doc = createDocumentSnapshot(fileName, null, docContext.createDocument); + snapshotManager.set(fileName, doc); + return doc; + } +} + +/** + * This should only be used when there's no jsconfig/tsconfig at all + */ +function getDefaultJsConfig(): { + compilerOptions: ts.CompilerOptions; + include: string[]; +} { + return { + compilerOptions: { + maxNodeModuleJsDepth: 2, + allowSyntheticDefaultImports: true, + allowJs: true, + }, + include: ['src'], + }; +} + +function getDefaultExclude() { + return ['dist', 'node_modules']; +} diff --git a/tools/language-server/src/plugins/typescript/module-loader.ts b/tools/language-server/src/plugins/typescript/module-loader.ts new file mode 100644 index 000000000..2bcb206e7 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/module-loader.ts @@ -0,0 +1,110 @@ +import ts from 'typescript'; +import type { DocumentSnapshot } from './SnapshotManager'; +import { isVirtualAstroFilePath, ensureRealAstroFilePath, getExtensionFromScriptKind } from './utils'; +import { createAstroSys } from './astro-sys'; + +/** + * Caches resolved modules. + */ +class ModuleResolutionCache { + private cache = new Map<string, ts.ResolvedModule>(); + + /** + * Tries to get a cached module. + */ + get(moduleName: string, containingFile: string): ts.ResolvedModule | undefined { + return this.cache.get(this.getKey(moduleName, containingFile)); + } + + /** + * Caches resolved module, if it is not undefined. + */ + set(moduleName: string, containingFile: string, resolvedModule: ts.ResolvedModule | undefined) { + if (!resolvedModule) { + return; + } + this.cache.set(this.getKey(moduleName, containingFile), resolvedModule); + } + + /** + * Deletes module from cache. Call this if a file was deleted. + * @param resolvedModuleName full path of the module + */ + delete(resolvedModuleName: string): void { + this.cache.forEach((val, key) => { + if (val.resolvedFileName === resolvedModuleName) { + this.cache.delete(key); + } + }); + } + + private getKey(moduleName: string, containingFile: string) { + return containingFile + ':::' + ensureRealAstroFilePath(moduleName); + } +} + +/** + * Creates a module loader specifically for `.astro` files. + * + * The typescript language service tries to look up other files that are referenced in the currently open astro file. + * For `.ts`/`.js` files this works, for `.astro` files it does not by default. + * Reason: The typescript language service does not know about the `.astro` file ending, + * so it assumes it's a normal typescript file and searches for files like `../Component.astro.ts`, which is wrong. + * In order to fix this, we need to wrap typescript's module resolution and reroute all `.astro.ts` file lookups to .astro. + * + * @param getSnapshot A function which returns a (in case of astro file fully preprocessed) typescript/javascript snapshot + * @param compilerOptions The typescript compiler options + */ +export function createAstroModuleLoader(getSnapshot: (fileName: string) => DocumentSnapshot, compilerOptions: ts.CompilerOptions) { + const astroSys = createAstroSys(getSnapshot); + const moduleCache = new ModuleResolutionCache(); + + return { + fileExists: astroSys.fileExists, + readFile: astroSys.readFile, + writeFile: astroSys.writeFile, + readDirectory: astroSys.readDirectory, + directoryExists: astroSys.directoryExists, + getDirectories: astroSys.getDirectories, + realpath: astroSys.realpath, + deleteFromModuleCache: (path: string) => moduleCache.delete(path), + resolveModuleNames, + }; + + function resolveModuleNames(moduleNames: string[], containingFile: string): Array<ts.ResolvedModule | undefined> { + return moduleNames.map((moduleName) => { + const cachedModule = moduleCache.get(moduleName, containingFile); + if (cachedModule) { + return cachedModule; + } + + const resolvedModule = resolveModuleName(moduleName, containingFile); + moduleCache.set(moduleName, containingFile, resolvedModule); + return resolvedModule; + }); + } + + function resolveModuleName(name: string, containingFile: string): ts.ResolvedModule | undefined { + // Delegate to the TS resolver first. + // If that does not bring up anything, try the Astro Module loader + // which is able to deal with .astro files. + const tsResolvedModule = ts.resolveModuleName(name, containingFile, compilerOptions, ts.sys).resolvedModule; + if (tsResolvedModule && !isVirtualAstroFilePath(tsResolvedModule.resolvedFileName)) { + return tsResolvedModule; + } + + const astroResolvedModule = ts.resolveModuleName(name, containingFile, compilerOptions, astroSys).resolvedModule; + if (!astroResolvedModule || !isVirtualAstroFilePath(astroResolvedModule.resolvedFileName)) { + return astroResolvedModule; + } + + const resolvedFileName = ensureRealAstroFilePath(astroResolvedModule.resolvedFileName); + const snapshot = getSnapshot(resolvedFileName); + + const resolvedastroModule: ts.ResolvedModuleFull = { + extension: getExtensionFromScriptKind(snapshot && snapshot.scriptKind), + resolvedFileName, + }; + return resolvedastroModule; + } +} diff --git a/tools/language-server/src/plugins/typescript/utils.ts b/tools/language-server/src/plugins/typescript/utils.ts new file mode 100644 index 000000000..9acbe2ed8 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/utils.ts @@ -0,0 +1,236 @@ +import * as ts from 'typescript'; +import { CompletionItemKind, DiagnosticSeverity, Position, Range } from 'vscode-languageserver'; +import { dirname } from 'path'; +import { pathToUrl } from '../../utils'; +import { mapRangeToOriginal } from '../../core/documents'; +import { SnapshotFragment } from './DocumentSnapshot'; + +export function scriptElementKindToCompletionItemKind(kind: ts.ScriptElementKind): CompletionItemKind { + switch (kind) { + case ts.ScriptElementKind.primitiveType: + case ts.ScriptElementKind.keyword: + return CompletionItemKind.Keyword; + case ts.ScriptElementKind.constElement: + return CompletionItemKind.Constant; + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + case ts.ScriptElementKind.localVariableElement: + case ts.ScriptElementKind.alias: + return CompletionItemKind.Variable; + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + return CompletionItemKind.Field; + case ts.ScriptElementKind.functionElement: + return CompletionItemKind.Function; + case ts.ScriptElementKind.memberFunctionElement: + case ts.ScriptElementKind.constructSignatureElement: + case ts.ScriptElementKind.callSignatureElement: + case ts.ScriptElementKind.indexSignatureElement: + return CompletionItemKind.Method; + case ts.ScriptElementKind.enumElement: + return CompletionItemKind.Enum; + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.externalModuleName: + return CompletionItemKind.Module; + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.typeElement: + return CompletionItemKind.Class; + case ts.ScriptElementKind.interfaceElement: + return CompletionItemKind.Interface; + case ts.ScriptElementKind.warning: + case ts.ScriptElementKind.scriptElement: + return CompletionItemKind.File; + case ts.ScriptElementKind.directory: + return CompletionItemKind.Folder; + case ts.ScriptElementKind.string: + return CompletionItemKind.Constant; + } + return CompletionItemKind.Property; +} + +export function getCommitCharactersForScriptElement(kind: ts.ScriptElementKind): string[] | undefined { + const commitCharacters: string[] = []; + switch (kind) { + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + case ts.ScriptElementKind.constructSignatureElement: + case ts.ScriptElementKind.callSignatureElement: + case ts.ScriptElementKind.indexSignatureElement: + case ts.ScriptElementKind.enumElement: + case ts.ScriptElementKind.interfaceElement: + commitCharacters.push('.'); + break; + + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.alias: + case ts.ScriptElementKind.constElement: + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + case ts.ScriptElementKind.localVariableElement: + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.functionElement: + case ts.ScriptElementKind.memberFunctionElement: + commitCharacters.push('.', ','); + commitCharacters.push('('); + break; + } + + return commitCharacters.length === 0 ? undefined : commitCharacters; +} + +export function mapSeverity(category: ts.DiagnosticCategory): DiagnosticSeverity { + switch (category) { + case ts.DiagnosticCategory.Error: + return DiagnosticSeverity.Error; + case ts.DiagnosticCategory.Warning: + return DiagnosticSeverity.Warning; + case ts.DiagnosticCategory.Suggestion: + return DiagnosticSeverity.Hint; + case ts.DiagnosticCategory.Message: + return DiagnosticSeverity.Information; + } + + return DiagnosticSeverity.Error; +} + +export function getScriptKindFromFileName(fileName: string): ts.ScriptKind { + const ext = fileName.substr(fileName.lastIndexOf('.')); + switch (ext.toLowerCase()) { + case ts.Extension.Js: + return ts.ScriptKind.JS; + case ts.Extension.Jsx: + return ts.ScriptKind.JSX; + case ts.Extension.Ts: + return ts.ScriptKind.TS; + case ts.Extension.Tsx: + return ts.ScriptKind.TSX; + case ts.Extension.Json: + return ts.ScriptKind.JSON; + default: + return ts.ScriptKind.Unknown; + } +} + +export function getExtensionFromScriptKind(kind: ts.ScriptKind | undefined): ts.Extension { + switch (kind) { + case ts.ScriptKind.JSX: + return ts.Extension.Jsx; + case ts.ScriptKind.TS: + return ts.Extension.Ts; + case ts.ScriptKind.TSX: + return ts.Extension.Tsx; + case ts.ScriptKind.JSON: + return ts.Extension.Json; + case ts.ScriptKind.JS: + default: + return ts.Extension.Js; + } +} + +export function convertRange(document: { positionAt: (offset: number) => Position }, range: { start?: number; length?: number }): Range { + return Range.create(document.positionAt(range.start || 0), document.positionAt((range.start || 0) + (range.length || 0))); +} + +export function convertToLocationRange(defDoc: SnapshotFragment, textSpan: ts.TextSpan): Range { + const range = mapRangeToOriginal(defDoc, convertRange(defDoc, textSpan)); + // Some definition like the svelte component class definition don't exist in the original, so we map to 0,1 + if (range.start.line < 0) { + range.start.line = 0; + range.start.character = 1; + } + if (range.end.line < 0) { + range.end = range.start; + } + + return range; +} + +type FrameworkExt = 'astro' | 'vue' | 'jsx' | 'tsx' | 'svelte'; + +export function isVirtualFrameworkFilePath(ext: FrameworkExt, filePath: string) { + return filePath.endsWith('.' + ext + '.ts'); +} + +export function isAstroFilePath(filePath: string) { + return filePath.endsWith('.astro'); +} + +export function isVirtualAstroFilePath(filePath: string) { + return isVirtualFrameworkFilePath('astro', filePath); +} + +export function isVirtualVueFilePath(filePath: string) { + return isVirtualFrameworkFilePath('vue', filePath); +} + +export function isVirtualJsxFilePath(filePath: string) { + return isVirtualFrameworkFilePath('jsx', filePath) || isVirtualFrameworkFilePath('tsx', filePath); +} + +export function isVirtualSvelteFilePath(filePath: string) { + return isVirtualFrameworkFilePath('svelte', filePath); +} + +export function isVirtualFilePath(filePath: string) { + return isVirtualAstroFilePath(filePath) || isVirtualVueFilePath(filePath) || isVirtualSvelteFilePath(filePath) || isVirtualJsxFilePath(filePath); +} + +export function toVirtualAstroFilePath(filePath: string) { + return `${filePath}.ts`; +} + +export function toRealAstroFilePath(filePath: string) { + return filePath.slice(0, -'.ts'.length); +} + +export function ensureRealAstroFilePath(filePath: string) { + return isVirtualAstroFilePath(filePath) ? toRealAstroFilePath(filePath) : filePath; +} + +export function ensureRealFilePath(filePath: string) { + return isVirtualFilePath(filePath) ? filePath.slice(0, 3) : filePath; +} + +export function findTsConfigPath(fileName: string, rootUris: string[]) { + const searchDir = dirname(fileName); + const path = ts.findConfigFile(searchDir, ts.sys.fileExists, 'tsconfig.json') || ts.findConfigFile(searchDir, ts.sys.fileExists, 'jsconfig.json') || ''; + // Don't return config files that exceed the current workspace context. + return !!path && rootUris.some((rootUri) => isSubPath(rootUri, path)) ? path : ''; +} + +/** */ +export function isSubPath(uri: string, possibleSubPath: string): boolean { + return pathToUrl(possibleSubPath).startsWith(uri); +} + +/** Substitutes */ +export function substituteWithWhitespace(result: string, start: number, end: number, oldContent: string, before: string, after: string) { + let accumulatedWS = 0; + result += before; + for (let i = start + before.length; i < end; i++) { + let ch = oldContent[i]; + if (ch === '\n' || ch === '\r') { + // only write new lines, skip the whitespace + accumulatedWS = 0; + result += ch; + } else { + accumulatedWS++; + } + } + result = append(result, ' ', accumulatedWS - after.length); + result += after; + return result; +} + +function append(result: string, str: string, n: number): string { + while (n > 0) { + if (n & 1) { + result += str; + } + n >>= 1; + str += str; + } + return result; +} diff --git a/tools/language-server/src/types/index.d.ts b/tools/language-server/src/types/index.d.ts new file mode 100644 index 000000000..e048b1a0c --- /dev/null +++ b/tools/language-server/src/types/index.d.ts @@ -0,0 +1,4 @@ +/** + * Starts `astro-languageservice` + */ +export function startServer(): void {} diff --git a/tools/language-server/src/utils.ts b/tools/language-server/src/utils.ts new file mode 100644 index 000000000..ba3d9366e --- /dev/null +++ b/tools/language-server/src/utils.ts @@ -0,0 +1,91 @@ +import { URI } from 'vscode-uri'; +import { Position, Range } from 'vscode-languageserver'; +import { Node } from 'vscode-html-languageservice'; + +/** Normalizes a document URI */ +export function normalizeUri(uri: string): string { + return URI.parse(uri).toString(); +} + +/** Turns a URL into a normalized FS Path */ +export function urlToPath(stringUrl: string): string | null { + const url = URI.parse(stringUrl); + if (url.scheme !== 'file') { + return null; + } + return url.fsPath.replace(/\\/g, '/'); +} + +/** Converts a path to a URL */ +export function pathToUrl(path: string) { + return URI.file(path).toString(); +} + +/** + * + * The language service is case insensitive, and would provide + * hover info for Svelte components like `Option` which have + * the same name like a html tag. + */ +export function isPossibleComponent(node: Node): boolean { + return !!node.tag?.[0].match(/[A-Z]/); +} + +/** + * + * The language service is case insensitive, and would provide + * hover info for Svelte components like `Option` which have + * the same name like a html tag. + */ +export function isPossibleClientComponent(node: Node): boolean { + return isPossibleComponent(node) && (node.tag?.indexOf(':') ?? -1) > -1; +} + +/** Flattens an array */ +export function flatten<T>(arr: T[][]): T[] { + return arr.reduce((all, item) => [...all, ...item], []); +} + +/** Clamps a number between min and max */ +export function clamp(num: number, min: number, max: number): number { + return Math.max(min, Math.min(max, num)); +} + +export function isNotNullOrUndefined<T>(val: T | undefined | null): val is T { + return val !== undefined && val !== null; +} + +/** Checks if a position is inside range */ +export function isInRange(positionToTest: Position, range: Range): boolean { + return isBeforeOrEqualToPosition(range.end, positionToTest) && isBeforeOrEqualToPosition(positionToTest, range.start); +} + +/** */ +export function isBeforeOrEqualToPosition(position: Position, positionToTest: Position): boolean { + return positionToTest.line < position.line || (positionToTest.line === position.line && positionToTest.character <= position.character); +} + +/** + * Debounces a function but cancels previous invocation only if + * a second function determines it should. + * + * @param fn The function with it's argument + * @param determineIfSame The function which determines if the previous invocation should be canceld or not + * @param miliseconds Number of miliseconds to debounce + */ +export function debounceSameArg<T>(fn: (arg: T) => void, shouldCancelPrevious: (newArg: T, prevArg?: T) => boolean, miliseconds: number): (arg: T) => void { + let timeout: any; + let prevArg: T | undefined; + + return (arg: T) => { + if (shouldCancelPrevious(arg, prevArg)) { + clearTimeout(timeout); + } + + prevArg = arg; + timeout = setTimeout(() => { + fn(arg); + prevArg = undefined; + }, miliseconds); + }; +} |