Files
vscode/extensions/markdown-language-features/server/src/workspace.ts
Matt Bierner 03ada8a3e8 Rework how markdown server works with documents (#160948)
* Rework how markdown server works with documents

This rewrites how the markdown server works with documents. The goal is to better handle switching between in-memory versions of a doc (from `TextDocument`) and versions of the same doc on disk. From the markdown service's POV, there is only one type of document

As part of this, I've also adopted the newest markdown language service version

* Bump package-lock versions
2022-09-15 09:27:02 +02:00

399 lines
12 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Connection, Emitter, FileChangeType, NotebookDocuments, Position, Range, TextDocuments } from 'vscode-languageserver';
import { TextDocument } from 'vscode-languageserver-textdocument';
import * as md from 'vscode-markdown-languageservice';
import { ContainingDocumentContext, FileWatcherOptions, IFileSystemWatcher } from 'vscode-markdown-languageservice/out/workspace';
import { URI } from 'vscode-uri';
import { LsConfiguration } from './config';
import * as protocol from './protocol';
import { isMarkdownFile, looksLikeMarkdownPath } from './util/file';
import { Limiter } from './util/limiter';
import { ResourceMap } from './util/resourceMap';
import { Schemes } from './util/schemes';
declare const TextDecoder: any;
class VsCodeDocument implements md.ITextDocument {
private inMemoryDoc?: TextDocument;
private onDiskDoc?: TextDocument;
readonly uri: string;
constructor(uri: string, init: { inMemoryDoc: TextDocument });
constructor(uri: string, init: { onDiskDoc: TextDocument });
constructor(uri: string, init: { inMemoryDoc?: TextDocument; onDiskDoc?: TextDocument }) {
this.uri = uri;
this.inMemoryDoc = init?.inMemoryDoc;
this.onDiskDoc = init?.onDiskDoc;
}
get version(): number {
return this.inMemoryDoc?.version ?? this.onDiskDoc?.version ?? 0;
}
get lineCount(): number {
return this.inMemoryDoc?.lineCount ?? this.onDiskDoc?.lineCount ?? 0;
}
getText(range?: Range): string {
if (this.inMemoryDoc) {
return this.inMemoryDoc.getText(range);
}
if (this.onDiskDoc) {
return this.onDiskDoc.getText(range);
}
throw new Error('Document has been closed');
}
positionAt(offset: number): Position {
if (this.inMemoryDoc) {
return this.inMemoryDoc.positionAt(offset);
}
if (this.onDiskDoc) {
return this.onDiskDoc.positionAt(offset);
}
throw new Error('Document has been closed');
}
isDetached(): boolean {
return !this.onDiskDoc && !this.inMemoryDoc;
}
setInMemoryDoc(doc: TextDocument | undefined) {
this.inMemoryDoc = doc;
}
setOnDiskDoc(doc: TextDocument | undefined) {
this.onDiskDoc = doc;
}
}
export class VsCodeClientWorkspace implements md.IWorkspaceWithWatching {
private readonly _onDidCreateMarkdownDocument = new Emitter<md.ITextDocument>();
public readonly onDidCreateMarkdownDocument = this._onDidCreateMarkdownDocument.event;
private readonly _onDidChangeMarkdownDocument = new Emitter<md.ITextDocument>();
public readonly onDidChangeMarkdownDocument = this._onDidChangeMarkdownDocument.event;
private readonly _onDidDeleteMarkdownDocument = new Emitter<URI>();
public readonly onDidDeleteMarkdownDocument = this._onDidDeleteMarkdownDocument.event;
private readonly _documentCache = new ResourceMap<VsCodeDocument>();
private readonly _utf8Decoder = new TextDecoder('utf-8');
private _watcherPool = 0;
private readonly _watchers = new Map<number, {
readonly resource: URI;
readonly options: FileWatcherOptions;
readonly onDidChange: Emitter<URI>;
readonly onDidCreate: Emitter<URI>;
readonly onDidDelete: Emitter<URI>;
}>();
constructor(
private readonly connection: Connection,
private readonly config: LsConfiguration,
private readonly documents: TextDocuments<TextDocument>,
private readonly notebooks: NotebookDocuments<TextDocument>,
private readonly logger: md.ILogger,
) {
documents.onDidOpen(e => {
if (!this.isRelevantMarkdownDocument(e.document)) {
return;
}
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: TextDocument.onDidOpen', `${e.document.uri}`);
const uri = URI.parse(e.document.uri);
const doc = this._documentCache.get(uri);
if (doc) {
// File already existed on disk
doc.setInMemoryDoc(e.document);
// The content visible to the language service may have changed since the in-memory doc
// may differ from the one on-disk. To be safe we always fire a change event.
this._onDidChangeMarkdownDocument.fire(doc);
} else {
// We're creating the file for the first time
const doc = new VsCodeDocument(e.document.uri, { inMemoryDoc: e.document });
this._documentCache.set(uri, doc);
this._onDidCreateMarkdownDocument.fire(doc);
}
});
documents.onDidChangeContent(e => {
if (!this.isRelevantMarkdownDocument(e.document)) {
return;
}
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: TextDocument.onDidChanceContent', `${e.document.uri}`);
const uri = URI.parse(e.document.uri);
const entry = this._documentCache.get(uri);
if (entry) {
entry.setInMemoryDoc(e.document);
this._onDidChangeMarkdownDocument.fire(entry);
}
});
documents.onDidClose(async e => {
if (!this.isRelevantMarkdownDocument(e.document)) {
return;
}
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: TextDocument.onDidClose', `${e.document.uri}`);
const uri = URI.parse(e.document.uri);
const doc = this._documentCache.get(uri);
if (!doc) {
// Document was never opened
return;
}
doc.setInMemoryDoc(undefined);
if (doc.isDetached()) {
// The document has been fully closed
this.doDeleteDocument(uri);
} else {
// The document still exists on disk
// To be safe, tell the service that the document has changed because the
// in-memory doc contents may be different than the disk doc contents.
this._onDidChangeMarkdownDocument.fire(doc);
}
});
connection.onDidChangeWatchedFiles(async ({ changes }) => {
for (const change of changes) {
const resource = URI.parse(change.uri);
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: onDidChangeWatchedFiles', `${change.type}: ${resource}`);
switch (change.type) {
case FileChangeType.Changed: {
const entry = this._documentCache.get(resource);
if (entry) {
// Refresh the on-disk state
const document = await this.openMarkdownDocumentFromFs(resource);
if (document) {
this._onDidChangeMarkdownDocument.fire(document);
}
}
break;
}
case FileChangeType.Created: {
const entry = this._documentCache.get(resource);
if (entry) {
// Create or update the on-disk state
const document = await this.openMarkdownDocumentFromFs(resource);
if (document) {
this._onDidCreateMarkdownDocument.fire(document);
}
}
break;
}
case FileChangeType.Deleted: {
const entry = this._documentCache.get(resource);
if (entry) {
entry.setOnDiskDoc(undefined);
if (entry.isDetached()) {
this.doDeleteDocument(resource);
}
}
break;
}
}
}
});
connection.onRequest(protocol.fs_watcher_onChange, params => {
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: fs_watcher_onChange', `${params.kind}: ${params.uri}`);
const watcher = this._watchers.get(params.id);
if (!watcher) {
return;
}
switch (params.kind) {
case 'create': watcher.onDidCreate.fire(URI.parse(params.uri)); return;
case 'change': watcher.onDidChange.fire(URI.parse(params.uri)); return;
case 'delete': watcher.onDidDelete.fire(URI.parse(params.uri)); return;
}
});
}
public listen() {
this.connection.workspace.onDidChangeWorkspaceFolders(async () => {
this.workspaceFolders = (await this.connection.workspace.getWorkspaceFolders() ?? []).map(x => URI.parse(x.uri));
});
}
private _workspaceFolders: readonly URI[] = [];
get workspaceFolders(): readonly URI[] {
return this._workspaceFolders;
}
set workspaceFolders(value: readonly URI[]) {
this._workspaceFolders = value;
}
async getAllMarkdownDocuments(): Promise<Iterable<md.ITextDocument>> {
// Add opened files (such as untitled files)
const openTextDocumentResults = this.documents.all()
.filter(doc => this.isRelevantMarkdownDocument(doc));
const allDocs = new ResourceMap<md.ITextDocument>();
for (const doc of openTextDocumentResults) {
allDocs.set(URI.parse(doc.uri), doc);
}
// And then add files on disk
const maxConcurrent = 20;
const limiter = new Limiter<md.ITextDocument | undefined>(maxConcurrent);
const resources = await this.connection.sendRequest(protocol.findMarkdownFilesInWorkspace, {});
await Promise.all(resources.map(strResource => {
return limiter.queue(async () => {
const resource = URI.parse(strResource);
if (allDocs.has(resource)) {
return;
}
const doc = await this.openMarkdownDocument(resource);
if (doc) {
allDocs.set(resource, doc);
}
return doc;
});
}));
return allDocs.values();
}
hasMarkdownDocument(resource: URI): boolean {
return !!this.documents.get(resource.toString());
}
async openMarkdownDocument(resource: URI): Promise<md.ITextDocument | undefined> {
const existing = this._documentCache.get(resource);
if (existing) {
return existing;
}
const matchingDocument = this.documents.get(resource.toString());
if (matchingDocument) {
let entry = this._documentCache.get(resource);
if (entry) {
entry.setInMemoryDoc(matchingDocument);
} else {
entry = new VsCodeDocument(resource.toString(), { inMemoryDoc: matchingDocument });
this._documentCache.set(resource, entry);
}
return entry;
}
return this.openMarkdownDocumentFromFs(resource);
}
private async openMarkdownDocumentFromFs(resource: URI): Promise<md.ITextDocument | undefined> {
if (!looksLikeMarkdownPath(this.config, resource)) {
return undefined;
}
try {
const response = await this.connection.sendRequest(protocol.fs_readFile, { uri: resource.toString() });
// TODO: LSP doesn't seem to handle Array buffers well
const bytes = new Uint8Array(response);
// We assume that markdown is in UTF-8
const text = this._utf8Decoder.decode(bytes);
const doc = new VsCodeDocument(resource.toString(), {
onDiskDoc: TextDocument.create(resource.toString(), 'markdown', 0, text)
});
this._documentCache.set(resource, doc);
return doc;
} catch (e) {
return undefined;
}
}
async stat(resource: URI): Promise<md.FileStat | undefined> {
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: stat', `${resource}`);
if (this._documentCache.has(resource) || this.documents.get(resource.toString())) {
return { isDirectory: false };
}
return this.connection.sendRequest(protocol.fs_stat, { uri: resource.toString() });
}
async readDirectory(resource: URI): Promise<[string, md.FileStat][]> {
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: readDir', `${resource}`);
return this.connection.sendRequest(protocol.fs_readDirectory, { uri: resource.toString() });
}
getContainingDocument(resource: URI): ContainingDocumentContext | undefined {
if (resource.scheme === Schemes.notebookCell) {
const nb = this.notebooks.findNotebookDocumentForCell(resource.toString());
if (nb) {
return {
uri: URI.parse(nb.uri),
children: nb.cells.map(cell => ({ uri: URI.parse(cell.document) })),
};
}
}
return undefined;
}
watchFile(resource: URI, options: FileWatcherOptions): IFileSystemWatcher {
const id = this._watcherPool++;
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: watchFile', `(${id}) ${resource}`);
const entry = {
resource,
options,
onDidCreate: new Emitter<URI>(),
onDidChange: new Emitter<URI>(),
onDidDelete: new Emitter<URI>(),
};
this._watchers.set(id, entry);
this.connection.sendRequest(protocol.fs_watcher_create, {
id,
uri: resource.toString(),
options,
watchParentDirs: true,
});
return {
onDidCreate: entry.onDidCreate.event,
onDidChange: entry.onDidChange.event,
onDidDelete: entry.onDidDelete.event,
dispose: () => {
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: disposeWatcher', `(${id}) ${resource}`);
this.connection.sendRequest(protocol.fs_watcher_delete, { id });
this._watchers.delete(id);
}
};
}
private isRelevantMarkdownDocument(doc: TextDocument) {
return isMarkdownFile(doc) && URI.parse(doc.uri).scheme !== 'vscode-bulkeditpreview';
}
private doDeleteDocument(uri: URI) {
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: deleteDocument', `${uri}`);
this._documentCache.delete(uri);
this._onDidDeleteMarkdownDocument.fire(uri);
}
}