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