diff --git a/tools/astro-languageserver/src/core/documents/utils.ts b/tools/astro-languageserver/src/core/documents/utils.ts index 227a76176..eb9d2060d 100644 --- a/tools/astro-languageserver/src/core/documents/utils.ts +++ b/tools/astro-languageserver/src/core/documents/utils.ts @@ -1,5 +1,6 @@ import { HTMLDocument, Node, Position } from 'vscode-html-languageservice'; -import { clamp } from '../../utils'; +import { Range } from 'vscode-languageserver'; +import { clamp, isInRange } from '../../utils'; import { parseHtml } from './parseHtml'; export interface TagInformation { @@ -84,6 +85,10 @@ export function isInsideFrontmatter(text: string, offset: number): boolean { 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 diff --git a/tools/astro-languageserver/src/index.ts b/tools/astro-languageserver/src/index.ts index 7c4417d69..e3532f252 100644 --- a/tools/astro-languageserver/src/index.ts +++ b/tools/astro-languageserver/src/index.ts @@ -19,6 +19,10 @@ export function startServer() { 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)); pluginHost.register(new HTMLPlugin(docManager, configManager)); pluginHost.register(new CSSPlugin(docManager, configManager)); @@ -29,6 +33,7 @@ export function startServer() { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, foldingRangeProvider: true, + definitionProvider: true, completionProvider: { resolveProvider: true, triggerCharacters: [ @@ -102,6 +107,7 @@ export function startServer() { 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)); diff --git a/tools/astro-languageserver/src/plugins/PluginHost.ts b/tools/astro-languageserver/src/plugins/PluginHost.ts index 8d59c9c93..3741845c4 100644 --- a/tools/astro-languageserver/src/plugins/PluginHost.ts +++ b/tools/astro-languageserver/src/plugins/PluginHost.ts @@ -1,4 +1,4 @@ -import { CompletionContext, CompletionItem, CompletionList, Position, TextDocumentIdentifier } from 'vscode-languageserver'; +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'; @@ -10,11 +10,24 @@ enum ExecuteMode { 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); } @@ -67,6 +80,21 @@ export class PluginHost { return foldingRanges; } + async getDefinitions(textDocument: TextDocumentIdentifier, position: Position): Promise { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + const definitions = flatten(await this.execute('getDefinitions', [document, position], ExecuteMode.Collect)); + + if (this.pluginHostConfig.definitionLinkSupport) { + return definitions; + } else { + return definitions.map((def) => { range: def.targetSelectionRange, uri: def.targetUri }); + } + } + onWatchFileChanges(onWatchFileChangesParams: any[]): void { for (const support of this.plugins) { support.onWatchFileChanges?.(onWatchFileChangesParams); diff --git a/tools/astro-languageserver/src/plugins/typescript/DocumentSnapshot.ts b/tools/astro-languageserver/src/plugins/typescript/DocumentSnapshot.ts new file mode 100644 index 000000000..04ea170d4 --- /dev/null +++ b/tools/astro-languageserver/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; + /** + * 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 { + 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, 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 { + return (this as unknown) as any; + } + + destroyFragment() { + // nothing to clean up + } + + getLineContainingOffset(offset: number) { + const chunks = this.getText(0, offset).split('\n'); + return chunks[chunks.length - 1]; + } + + update(changes: TextDocumentContentChangeEvent[]): void { + for (const change of changes) { + let start = 0; + let end = 0; + if ('range' in change) { + start = this.offsetAt(change.range.start); + end = this.offsetAt(change.range.end); + } else { + end = this.getLength(); + } + + this.text = this.text.slice(0, start) + change.text + this.text.slice(end); + } + + this.version++; + } +} diff --git a/tools/astro-languageserver/src/plugins/typescript/LanguageServiceManager.ts b/tools/astro-languageserver/src/plugins/typescript/LanguageServiceManager.ts index 529ab2b4c..d2a5cecff 100644 --- a/tools/astro-languageserver/src/plugins/typescript/LanguageServiceManager.ts +++ b/tools/astro-languageserver/src/plugins/typescript/LanguageServiceManager.ts @@ -3,7 +3,8 @@ 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'; +import { SnapshotManager } from './SnapshotManager'; +import { DocumentSnapshot } from './DocumentSnapshot'; export class LanguageServiceManager { private readonly docManager: DocumentManager; diff --git a/tools/astro-languageserver/src/plugins/typescript/SnapshotManager.ts b/tools/astro-languageserver/src/plugins/typescript/SnapshotManager.ts index 4f9e865a1..5a406b945 100644 --- a/tools/astro-languageserver/src/plugins/typescript/SnapshotManager.ts +++ b/tools/astro-languageserver/src/plugins/typescript/SnapshotManager.ts @@ -1,9 +1,7 @@ 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'; +import { TextDocumentContentChangeEvent } from 'vscode-languageserver'; +import { toVirtualAstroFilePath } from './utils'; +import { DocumentSnapshot, TypeScriptDocumentSnapshot, createDocumentSnapshot } from './DocumentSnapshot'; export interface TsFilesSpec { include?: readonly string[]; @@ -95,209 +93,3 @@ export class SnapshotManager { } } } - -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; - /** - * 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 { - 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 { - 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 { - return (this as unknown) as any; - } - - destroyFragment() { - // nothing to clean up - } - - getLineContainingOffset(offset: number) { - const chunks = this.getText(0, offset).split('\n'); - return chunks[chunks.length - 1]; - } - - update(changes: TextDocumentContentChangeEvent[]): void { - for (const change of changes) { - let start = 0; - let end = 0; - if ('range' in change) { - start = this.offsetAt(change.range.start); - end = this.offsetAt(change.range.end); - } else { - end = this.getLength(); - } - - this.text = this.text.slice(0, start) + change.text + this.text.slice(end); - } - - this.version++; - } -} diff --git a/tools/astro-languageserver/src/plugins/typescript/TypeScriptPlugin.ts b/tools/astro-languageserver/src/plugins/typescript/TypeScriptPlugin.ts index aab758bdb..30781a508 100644 --- a/tools/astro-languageserver/src/plugins/typescript/TypeScriptPlugin.ts +++ b/tools/astro-languageserver/src/plugins/typescript/TypeScriptPlugin.ts @@ -1,12 +1,14 @@ 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 { 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 { getScriptKindFromFileName } from './utils'; +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; @@ -33,6 +35,40 @@ export class TypeScriptPlugin implements CompletionsProvider { return this.completionProvider.resolveCompletion(document, completionItem); } + async getDefinitions(document: Document, position: Position): Promise { + 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 { const doneUpdateProjectFiles = new Set(); diff --git a/tools/astro-languageserver/src/plugins/typescript/features/utils.ts b/tools/astro-languageserver/src/plugins/typescript/features/utils.ts new file mode 100644 index 000000000..8c87dc5f4 --- /dev/null +++ b/tools/astro-languageserver/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(); + 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/astro-languageserver/src/plugins/typescript/languageService.ts b/tools/astro-languageserver/src/plugins/typescript/languageService.ts index 0db9e66cc..b7ff6df20 100644 --- a/tools/astro-languageserver/src/plugins/typescript/languageService.ts +++ b/tools/astro-languageserver/src/plugins/typescript/languageService.ts @@ -4,7 +4,8 @@ import * as ts from 'typescript'; import { basename } from 'path'; import { ensureRealAstroFilePath, findTsConfigPath } from './utils'; import { Document } from '../../core/documents'; -import { createDocumentSnapshot, SnapshotManager, DocumentSnapshot } from './SnapshotManager'; +import { SnapshotManager } from './SnapshotManager'; +import { createDocumentSnapshot, DocumentSnapshot } from './DocumentSnapshot'; import { createAstroModuleLoader } from './module-loader'; const services = new Map>(); diff --git a/tools/astro-languageserver/src/plugins/typescript/utils.ts b/tools/astro-languageserver/src/plugins/typescript/utils.ts index da4e37c84..b212d9cd3 100644 --- a/tools/astro-languageserver/src/plugins/typescript/utils.ts +++ b/tools/astro-languageserver/src/plugins/typescript/utils.ts @@ -1,7 +1,9 @@ import * as ts from 'typescript'; -import { CompletionItemKind, DiagnosticSeverity } from 'vscode-languageserver'; +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) { @@ -127,12 +129,52 @@ export function getExtensionFromScriptKind(kind: ts.ScriptKind | undefined): ts. } } +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 filePath.endsWith('.astro.ts'); + 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) { diff --git a/tools/astro-languageserver/src/utils.ts b/tools/astro-languageserver/src/utils.ts index f9f1acf34..ba3d9366e 100644 --- a/tools/astro-languageserver/src/utils.ts +++ b/tools/astro-languageserver/src/utils.ts @@ -51,6 +51,10 @@ export function clamp(num: number, min: number, max: number): number { return Math.max(min, Math.min(max, num)); } +export function isNotNullOrUndefined(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);