Get definitions (#219)

* Start on css completion

* Support for CSS completions

* Adds support for Go to Definition in TypeScript in Astro

* Run formatting
This commit is contained in:
Matthew Phillips 2021-05-20 13:02:46 -04:00 committed by GitHub
parent 3da2b58b9d
commit 6ce068b838
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 430 additions and 219 deletions

View file

@ -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

View file

@ -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));

View file

@ -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<DefinitionLink[] | Location[]> {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
const definitions = flatten(await this.execute<DefinitionLink[]>('getDefinitions', [document, position], ExecuteMode.Collect));
if (this.pluginHostConfig.definitionLinkSupport) {
return definitions;
} else {
return definitions.map((def) => <Location>{ range: def.targetSelectionRange, uri: def.targetUri });
}
}
onWatchFileChanges(onWatchFileChangesParams: any[]): void {
for (const support of this.plugins) {
support.onWatchFileChanges?.(onWatchFileChangesParams);

View file

@ -0,0 +1,242 @@
import * as ts from 'typescript';
import { TextDocumentContentChangeEvent, Position } from 'vscode-languageserver';
import { Document, DocumentMapper, IdentityMapper } from '../../core/documents';
import { isInTag, positionAt, offsetAt } from '../../core/documents/utils';
import { pathToUrl } from '../../utils';
import { getScriptKindFromFileName, isAstroFilePath, toVirtualAstroFilePath } from './utils';
/**
* The mapper to get from original snapshot positions to generated and vice versa.
*/
export interface SnapshotFragment extends DocumentMapper {
positionAt(offset: number): Position;
offsetAt(position: Position): number;
}
export interface DocumentSnapshot extends ts.IScriptSnapshot {
version: number;
filePath: string;
scriptKind: ts.ScriptKind;
positionAt(offset: number): Position;
/**
* Instantiates a source mapper.
* `destroyFragment` needs to be called when
* it's no longer needed / the class should be cleaned up
* in order to prevent memory leaks.
*/
getFragment(): Promise<DocumentFragmentSnapshot>;
/**
* Needs to be called when source mapper
* is no longer needed / the class should be cleaned up
* in order to prevent memory leaks.
*/
destroyFragment(): void;
/**
* Convenience function for getText(0, getLength())
*/
getFullText(): string;
}
export const createDocumentSnapshot = (filePath: string, currentText: string | null, createDocument?: (_filePath: string, text: string) => Document): DocumentSnapshot => {
const text = currentText || (ts.sys.readFile(filePath) ?? '');
if (isAstroFilePath(filePath)) {
if (!createDocument) throw new Error('Astro documents require the "createDocument" utility to be provided');
const snapshot = new AstroDocumentSnapshot(createDocument(filePath, text));
return snapshot;
}
return new TypeScriptDocumentSnapshot(0, filePath, text);
};
class AstroDocumentSnapshot implements DocumentSnapshot {
version = this.doc.version;
scriptKind = ts.ScriptKind.Unknown;
constructor(private doc: Document) {}
async getFragment(): Promise<DocumentFragmentSnapshot> {
const uri = pathToUrl(this.filePath);
const mapper = await this.getMapper(uri);
return new DocumentFragmentSnapshot(mapper, this.doc);
}
async destroyFragment() {
return;
}
get text() {
return this.doc.getText();
}
get filePath() {
return this.doc.getFilePath() || '';
}
getText(start: number, end: number) {
return this.text.substring(start, end);
}
getLength() {
return this.text.length;
}
getFullText() {
return this.text;
}
getChangeRange() {
return undefined;
}
positionAt(offset: number) {
return positionAt(offset, this.text);
}
getLineContainingOffset(offset: number) {
const chunks = this.getText(0, offset).split('\n');
return chunks[chunks.length - 1];
}
offsetAt(position: Position) {
return offsetAt(position, this.text);
}
private getMapper(uri: string) {
return new IdentityMapper(uri);
}
}
export class DocumentFragmentSnapshot implements Omit<DocumentSnapshot, 'getFragment' | 'destroyFragment'>, SnapshotFragment {
version: number;
filePath: string;
url: string;
text: string;
scriptKind = ts.ScriptKind.TSX;
scriptInfo = null;
constructor(private mapper: any, private parent: Document) {
const filePath = parent.getFilePath();
if (!filePath) throw new Error('Cannot create a document fragment from a non-local document');
const text = parent.getText();
this.version = parent.version;
this.filePath = toVirtualAstroFilePath(filePath);
this.url = toVirtualAstroFilePath(filePath);
this.text = this.transformContent(text);
}
/** @internal */
private transformContent(content: string) {
return content.replace(/---/g, '///');
}
getText(start: number, end: number) {
return this.text.substring(start, end);
}
getLength() {
return this.text.length;
}
getFullText() {
return this.text;
}
getChangeRange() {
return undefined;
}
positionAt(offset: number) {
return positionAt(offset, this.text);
}
getLineContainingOffset(offset: number) {
const chunks = this.getText(0, offset).split('\n');
return chunks[chunks.length - 1];
}
offsetAt(position: Position): number {
return offsetAt(position, this.text);
}
getOriginalPosition(pos: Position): Position {
return this.mapper.getOriginalPosition(pos);
}
getGeneratedPosition(pos: Position): Position {
return this.mapper.getGeneratedPosition(pos);
}
isInGenerated(pos: Position): boolean {
return !isInTag(pos, this.parent.styleInfo);
}
getURL(): string {
return this.url;
}
}
export class TypeScriptDocumentSnapshot implements DocumentSnapshot {
scriptKind = getScriptKindFromFileName(this.filePath);
scriptInfo = null;
url: string;
constructor(public version: number, public readonly filePath: string, private text: string) {
this.url = pathToUrl(filePath);
}
getText(start: number, end: number) {
return this.text.substring(start, end);
}
getLength() {
return this.text.length;
}
getFullText() {
return this.text;
}
getChangeRange() {
return undefined;
}
positionAt(offset: number) {
return positionAt(offset, this.text);
}
offsetAt(position: Position): number {
return offsetAt(position, this.text);
}
async getFragment(): Promise<DocumentFragmentSnapshot> {
return (this as unknown) as any;
}
destroyFragment() {
// nothing to clean up
}
getLineContainingOffset(offset: number) {
const chunks = this.getText(0, offset).split('\n');
return chunks[chunks.length - 1];
}
update(changes: TextDocumentContentChangeEvent[]): void {
for (const change of changes) {
let start = 0;
let end = 0;
if ('range' in change) {
start = this.offsetAt(change.range.start);
end = this.offsetAt(change.range.end);
} else {
end = this.getLength();
}
this.text = this.text.slice(0, start) + change.text + this.text.slice(end);
}
this.version++;
}
}

View file

@ -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;

View file

@ -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<DocumentFragmentSnapshot>;
/**
* Needs to be called when source mapper
* is no longer needed / the class should be cleaned up
* in order to prevent memory leaks.
*/
destroyFragment(): void;
/**
* Convenience function for getText(0, getLength())
*/
getFullText(): string;
}
export const createDocumentSnapshot = (filePath: string, currentText: string | null, createDocument?: (_filePath: string, text: string) => Document): DocumentSnapshot => {
const text = currentText || (ts.sys.readFile(filePath) ?? '');
if (isAstroFilePath(filePath)) {
if (!createDocument) throw new Error('Astro documents require the "createDocument" utility to be provided');
const snapshot = new AstroDocumentSnapshot(createDocument(filePath, text));
return snapshot;
}
return new TypeScriptDocumentSnapshot(0, filePath, text);
};
class AstroDocumentSnapshot implements DocumentSnapshot {
version = this.doc.version;
scriptKind = ts.ScriptKind.Unknown;
constructor(private doc: Document) {}
async getFragment(): Promise<DocumentFragmentSnapshot> {
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++;
}
}

View file

@ -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<DefinitionLink[]> {
const { lang, tsDoc } = await this.languageServiceManager.getTypeScriptDoc(document);
const mainFragment = await tsDoc.getFragment();
const filePath = tsDoc.filePath;
const tsFilePath = filePath.endsWith('.ts') ? filePath : filePath + '.ts';
const defs = lang.getDefinitionAndBoundSpan(tsFilePath, mainFragment.offsetAt(mainFragment.getGeneratedPosition(position)));
if (!defs || !defs.definitions) {
return [];
}
const docs = new SnapshotFragmentMap(this.languageServiceManager);
docs.set(tsDoc.filePath, { fragment: mainFragment, snapshot: tsDoc });
const result = await Promise.all(
defs.definitions.map(async (def) => {
const { fragment, snapshot } = await docs.retrieve(def.fileName);
if (isNoTextSpanInGeneratedCode(snapshot.getFullText(), def.textSpan)) {
const fileName = isVirtualFilePath(def.fileName) ? def.fileName.substr(0, def.fileName.length - 3) : def.fileName;
return LocationLink.create(
pathToUrl(fileName),
convertToLocationRange(fragment, def.textSpan),
convertToLocationRange(fragment, def.textSpan),
convertToLocationRange(mainFragment, defs.textSpan)
);
}
})
);
return result.filter(isNotNullOrUndefined);
}
async onWatchFileChanges(onWatchFileChangesParams: any[]): Promise<void> {
const doneUpdateProjectFiles = new Set<SnapshotManager>();

View file

@ -0,0 +1,54 @@
import type { SnapshotFragment, DocumentSnapshot } from '../DocumentSnapshot';
import type { LanguageServiceManager } from '../LanguageServiceManager';
/**
* Checks if this a section that should be completely ignored
* because it's purely generated.
*/
export function isInGeneratedCode(text: string, start: number, end: number) {
const lineStart = text.lastIndexOf('\n', start);
const lineEnd = text.indexOf('\n', end);
const lastStart = text.substring(lineStart, start).lastIndexOf('/*Ωignore_startΩ*/');
const lastEnd = text.substring(lineStart, start).lastIndexOf('/*Ωignore_endΩ*/');
return lastStart > lastEnd && text.substring(end, lineEnd).includes('/*Ωignore_endΩ*/');
}
/**
* Checks that this isn't a text span that should be completely ignored
* because it's purely generated.
*/
export function isNoTextSpanInGeneratedCode(text: string, span: ts.TextSpan) {
return !isInGeneratedCode(text, span.start, span.start + span.length);
}
export class SnapshotFragmentMap {
private map = new Map<string, { fragment: SnapshotFragment; snapshot: DocumentSnapshot }>();
constructor(private languageServiceManager: LanguageServiceManager) {}
set(fileName: string, content: { fragment: SnapshotFragment; snapshot: DocumentSnapshot }) {
this.map.set(fileName, content);
}
get(fileName: string) {
return this.map.get(fileName);
}
getFragment(fileName: string) {
return this.map.get(fileName)?.fragment;
}
async retrieve(fileName: string) {
let snapshotFragment = this.get(fileName);
if (!snapshotFragment) {
const snapshot = await this.languageServiceManager.getSnapshot(fileName);
const fragment = await snapshot.getFragment();
snapshotFragment = { fragment, snapshot };
this.set(fileName, snapshotFragment);
}
return snapshotFragment;
}
async retrieveFragment(fileName: string) {
return (await this.retrieve(fileName)).fragment;
}
}

View file

@ -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<string, Promise<LanguageServiceContainer>>();

View file

@ -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) {

View file

@ -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<T>(val: T | undefined | null): val is T {
return val !== undefined && val !== null;
}
/** Checks if a position is inside range */
export function isInRange(positionToTest: Position, range: Range): boolean {
return isBeforeOrEqualToPosition(range.end, positionToTest) && isBeforeOrEqualToPosition(positionToTest, range.start);