Move MD diagnostics to language server (#155653)

* Move MD diagnostics to language server

This switches us to using the LSP pull diagnostic model with a new version of the language service

* Bump package version

* Delete unused file
This commit is contained in:
Matt Bierner 2022-07-19 16:34:09 -07:00 committed by GitHub
parent db4ba2062d
commit 32f5e49082
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 475 additions and 2474 deletions

View file

@ -478,6 +478,7 @@
"type": "string",
"scope": "resource",
"markdownDescription": "%configuration.markdown.experimental.validate.fileLinks.markdownFragmentLinks.description%",
"default": "ignore",
"enum": [
"ignore",
"warning",
@ -563,7 +564,8 @@
"@types/vscode-notebook-renderer": "^1.60.0",
"@types/vscode-webview": "^1.57.0",
"lodash.throttle": "^4.1.1",
"vscode-languageserver-types": "^3.17.2"
"vscode-languageserver-types": "^3.17.2",
"vscode-markdown-languageservice": "^0.0.0-alpha.10"
},
"repository": {
"type": "git",

View file

@ -6,9 +6,11 @@
"name": "Attach",
"type": "node",
"request": "attach",
"port": 7692,
"port": 7997,
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/out/**/*.js"]
"outFiles": [
"${workspaceFolder}/out/**/*.js"
]
}
]
}

View file

@ -0,0 +1,2 @@
{
}

View file

@ -13,7 +13,7 @@
"vscode-languageserver": "^8.0.2-next.5`",
"vscode-languageserver-textdocument": "^1.0.5",
"vscode-languageserver-types": "^3.17.1",
"vscode-markdown-languageservice": "^0.0.0-alpha.8",
"vscode-markdown-languageservice": "^0.0.0-alpha.10",
"vscode-uri": "^3.0.3"
},
"devDependencies": {

View file

@ -0,0 +1,59 @@
/*---------------------------------------------------------------------------------------------
* 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 } from 'vscode-languageserver';
import { Disposable } from './util/dispose';
export type ValidateEnabled = 'ignore' | 'warning' | 'error';
interface Settings {
readonly markdown: {
readonly suggest: {
readonly paths: {
readonly enabled: boolean;
};
};
readonly experimental: {
readonly validate: {
readonly enabled: true;
readonly referenceLinks: {
readonly enabled: ValidateEnabled;
};
readonly fragmentLinks: {
readonly enabled: ValidateEnabled;
};
readonly fileLinks: {
readonly enabled: ValidateEnabled;
readonly markdownFragmentLinks: ValidateEnabled;
};
readonly ignoreLinks: readonly string[];
};
};
};
}
export class ConfigurationManager extends Disposable {
private readonly _onDidChangeConfiguration = this._register(new Emitter<Settings>());
public readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event;
private _settings?: Settings;
constructor(connection: Connection) {
super();
// The settings have changed. Is send on server activation as well.
this._register(connection.onDidChangeConfiguration((change) => {
this._settings = change.settings;
this._onDidChangeConfiguration.fire(this._settings!);
}));
}
public getSettings(): Settings | undefined {
return this._settings;
}
}

View file

@ -0,0 +1,86 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Connection, FullDocumentDiagnosticReport, UnchangedDocumentDiagnosticReport } from 'vscode-languageserver';
import * as md from 'vscode-markdown-languageservice';
import { Disposable } from 'vscode-notebook-renderer/events';
import { URI } from 'vscode-uri';
import { ConfigurationManager, ValidateEnabled } from '../configuration';
import { VsCodeClientWorkspace } from '../workspace';
const defaultDiagnosticOptions: md.DiagnosticOptions = {
validateFileLinks: md.DiagnosticLevel.ignore,
validateReferences: md.DiagnosticLevel.ignore,
validateFragmentLinks: md.DiagnosticLevel.ignore,
validateMarkdownFileLinkFragments: md.DiagnosticLevel.ignore,
ignoreLinks: [],
};
function convertDiagnosticLevel(enabled: ValidateEnabled): md.DiagnosticLevel | undefined {
switch (enabled) {
case 'error': return md.DiagnosticLevel.error;
case 'warning': return md.DiagnosticLevel.warning;
case 'ignore': return md.DiagnosticLevel.ignore;
default: return md.DiagnosticLevel.ignore;
}
}
function getDiagnosticsOptions(config: ConfigurationManager): md.DiagnosticOptions {
const settings = config.getSettings();
if (!settings) {
return defaultDiagnosticOptions;
}
return {
validateFileLinks: convertDiagnosticLevel(settings.markdown.experimental.validate.fileLinks.enabled),
validateReferences: convertDiagnosticLevel(settings.markdown.experimental.validate.referenceLinks.enabled),
validateFragmentLinks: convertDiagnosticLevel(settings.markdown.experimental.validate.fragmentLinks.enabled),
validateMarkdownFileLinkFragments: convertDiagnosticLevel(settings.markdown.experimental.validate.fileLinks.markdownFragmentLinks),
ignoreLinks: settings.markdown.experimental.validate.ignoreLinks,
};
}
export function registerValidateSupport(
connection: Connection,
workspace: VsCodeClientWorkspace,
ls: md.IMdLanguageService,
config: ConfigurationManager,
): Disposable {
let diagnosticOptions: md.DiagnosticOptions = defaultDiagnosticOptions;
function updateDiagnosticsSetting(): void {
diagnosticOptions = getDiagnosticsOptions(config);
}
const manager = ls.createPullDiagnosticsManager();
connection.languages.diagnostics.on(async (params, token): Promise<FullDocumentDiagnosticReport | UnchangedDocumentDiagnosticReport> => {
if (!config.getSettings()?.markdown.experimental.validate.enabled) {
return { kind: 'full', items: [] };
}
const document = await workspace.openMarkdownDocument(URI.parse(params.textDocument.uri));
if (!document) {
return { kind: 'full', items: [] };
}
const diagnostics = await manager.computeDiagnostics(document, diagnosticOptions, token);
return {
kind: 'full',
items: diagnostics,
};
});
updateDiagnosticsSetting();
const configChangeSub = config.onDidChangeConfiguration(() => {
updateDiagnosticsSetting();
connection.languages.diagnostics.refresh();
});
return {
dispose: () => {
manager.dispose();
configChangeSub.dispose();
}
};
}

View file

@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ILogger } from 'vscode-markdown-languageservice';
import { ILogger, LogLevel } from 'vscode-markdown-languageservice';
export class LogFunctionLogger implements ILogger {
@ -31,8 +31,9 @@ export class LogFunctionLogger implements ILogger {
private readonly _logFn: typeof console.log
) { }
public verbose(title: string, message: string, data?: any): void {
this.appendLine(`[Verbose ${LogFunctionLogger.now()}] ${title}: ${message}`);
public log(level: LogLevel, title: string, message: string, data?: any): void {
this.appendLine(`[${level} ${LogFunctionLogger.now()}] ${title}: ${message}`);
if (data) {
this.appendLine(LogFunctionLogger.data2String(data));
}

View file

@ -4,8 +4,8 @@
*--------------------------------------------------------------------------------------------*/
import { RequestType } from 'vscode-languageserver';
import * as md from 'vscode-markdown-languageservice';
import * as lsp from 'vscode-languageserver-types';
import * as md from 'vscode-markdown-languageservice';
// From server
export const parseRequestType: RequestType<{ uri: string }, md.Token[], any> = new RequestType('markdown/parse');
@ -14,5 +14,10 @@ export const statFileRequestType: RequestType<{ uri: string }, md.FileStat | und
export const readDirectoryRequestType: RequestType<{ uri: string }, [string, md.FileStat][], any> = new RequestType('markdown/readDirectory');
export const findFilesRequestTypes: RequestType<{}, string[], any> = new RequestType('markdown/findFiles');
export const createFileWatcher: RequestType<{ id: number; uri: string; options: md.FileWatcherOptions }, void, any> = new RequestType('markdown/createFileWatcher');
export const deleteFileWatcher: RequestType<{ id: number }, void, any> = new RequestType('markdown/deleteFileWatcher');
// To server
export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace');
export const onWatcherChange: RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any> = new RequestType('markdown/onWatcherChange');

View file

@ -7,8 +7,11 @@ import { CancellationToken, Connection, InitializeParams, InitializeResult, Note
import { TextDocument } from 'vscode-languageserver-textdocument';
import * as lsp from 'vscode-languageserver-types';
import * as md from 'vscode-markdown-languageservice';
import { IDisposable } from 'vscode-markdown-languageservice/out/util/dispose';
import { URI } from 'vscode-uri';
import { getLsConfiguration } from './config';
import { ConfigurationManager } from './configuration';
import { registerValidateSupport } from './languageFeatures/diagnostics';
import { LogFunctionLogger } from './logging';
import * as protocol from './protocol';
import { VsCodeClientWorkspace } from './workspace';
@ -17,6 +20,11 @@ export async function startServer(connection: Connection) {
const documents = new TextDocuments(TextDocument);
const notebooks = new NotebookDocuments(documents);
const configurationManager = new ConfigurationManager(connection);
let provider: md.IMdLanguageService | undefined;
let workspace: VsCodeClientWorkspace | undefined;
connection.onInitialize((params: InitializeParams): InitializeResult => {
const parser = new class implements md.IMdParser {
slugifier = md.githubSlugifier;
@ -30,8 +38,8 @@ export async function startServer(connection: Connection) {
markdownFileExtensions: params.initializationOptions.markdownFileExtensions,
});
const workspace = new VsCodeClientWorkspace(connection, config, documents, notebooks);
const logger = new LogFunctionLogger(connection.console.log.bind(connection.console));
workspace = new VsCodeClientWorkspace(connection, config, documents, notebooks, logger);
provider = md.createLanguageService({
workspace,
parser,
@ -39,9 +47,18 @@ export async function startServer(connection: Connection) {
markdownFileExtensions: config.markdownFileExtensions,
});
registerCompletionsSupport(connection, documents, provider, configurationManager);
registerValidateSupport(connection, workspace, provider, configurationManager);
workspace.workspaceFolders = (params.workspaceFolders ?? []).map(x => URI.parse(x.uri));
return {
capabilities: {
diagnosticProvider: {
documentSelector: null,
identifier: 'markdown',
interFileDependencies: true,
workspaceDiagnostics: false,
},
completionProvider: { triggerCharacters: ['.', '/', '#'] },
definitionProvider: true,
documentLinkProvider: { resolveProvider: true },
@ -61,8 +78,6 @@ export async function startServer(connection: Connection) {
});
let provider: md.IMdLanguageService | undefined;
connection.onDocumentLinks(async (params, token): Promise<lsp.DocumentLink[]> => {
try {
const document = documents.get(params.textDocument.uri);
@ -129,18 +144,6 @@ export async function startServer(connection: Connection) {
return [];
});
connection.onCompletion(async (params, token): Promise<lsp.CompletionItem[]> => {
try {
const document = documents.get(params.textDocument.uri);
if (document) {
return await provider!.getCompletionItems(document, params.position, params.context!, token);
}
} catch (e) {
console.error(e.stack);
}
return [];
});
connection.onReferences(async (params, token): Promise<lsp.Location[]> => {
try {
const document = documents.get(params.textDocument.uri);
@ -204,3 +207,46 @@ export async function startServer(connection: Connection) {
notebooks.listen(connection);
connection.listen();
}
function registerCompletionsSupport(
connection: Connection,
documents: TextDocuments<TextDocument>,
ls: md.IMdLanguageService,
config: ConfigurationManager,
): IDisposable {
// let registration: Promise<IDisposable> | undefined;
function update() {
// TODO: client still makes the request in this case. Figure our how to properly unregister.
return;
// const settings = config.getSettings();
// if (settings?.markdown.suggest.paths.enabled) {
// if (!registration) {
// registration = connection.client.register(CompletionRequest.type);
// }
// } else {
// registration?.then(x => x.dispose());
// registration = undefined;
// }
}
connection.onCompletion(async (params, token): Promise<lsp.CompletionItem[]> => {
try {
const settings = config.getSettings();
if (!settings?.markdown.suggest.paths.enabled) {
return [];
}
const document = documents.get(params.textDocument.uri);
if (document) {
return await ls.getCompletionItems(document, params.position, params.context!, token);
}
} catch (e) {
console.error(e.stack);
}
return [];
});
update();
return config.onDidChangeConfiguration(() => update());
}

View file

@ -0,0 +1,80 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export class MultiDisposeError extends Error {
constructor(
public readonly errors: any[]
) {
super(`Encountered errors while disposing of store. Errors: [${errors.join(', ')}]`);
}
}
export function disposeAll(disposables: Iterable<IDisposable>) {
const errors: any[] = [];
for (const disposable of disposables) {
try {
disposable.dispose();
} catch (e) {
errors.push(e);
}
}
if (errors.length === 1) {
throw errors[0];
} else if (errors.length > 1) {
throw new MultiDisposeError(errors);
}
}
export interface IDisposable {
dispose(): void;
}
export abstract class Disposable {
private _isDisposed = false;
protected _disposables: IDisposable[] = [];
public dispose(): any {
if (this._isDisposed) {
return;
}
this._isDisposed = true;
disposeAll(this._disposables);
}
protected _register<T extends IDisposable>(value: T): T {
if (this._isDisposed) {
value.dispose();
} else {
this._disposables.push(value);
}
return value;
}
protected get isDisposed() {
return this._isDisposed;
}
}
export class DisposableStore extends Disposable {
private readonly items = new Set<IDisposable>();
public override dispose() {
super.dispose();
disposeAll(this.items);
this.items.clear();
}
public add<T extends IDisposable>(item: T): T {
if (this.isDisposed) {
console.warn('Adding to disposed store. Item will be leaked');
}
this.items.add(item);
return item;
}
}

View file

@ -6,7 +6,7 @@
import { Connection, Emitter, FileChangeType, NotebookDocuments, TextDocuments } from 'vscode-languageserver';
import { TextDocument } from 'vscode-languageserver-textdocument';
import * as md from 'vscode-markdown-languageservice';
import { ContainingDocumentContext } from 'vscode-markdown-languageservice/out/workspace';
import { ContainingDocumentContext, FileWatcherOptions, IFileSystemWatcher } from 'vscode-markdown-languageservice/out/workspace';
import { URI } from 'vscode-uri';
import { LsConfiguration } from './config';
import * as protocol from './protocol';
@ -18,7 +18,7 @@ import { Schemes } from './util/schemes';
declare const TextDecoder: any;
export class VsCodeClientWorkspace implements md.IWorkspace {
export class VsCodeClientWorkspace implements md.IWorkspaceWithWatching {
private readonly _onDidCreateMarkdownDocument = new Emitter<md.ITextDocument>();
public readonly onDidCreateMarkdownDocument = this._onDidCreateMarkdownDocument.event;
@ -33,11 +33,21 @@ export class VsCodeClientWorkspace implements md.IWorkspace {
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 => {
this._documentCache.delete(URI.parse(e.document.uri));
@ -83,6 +93,18 @@ export class VsCodeClientWorkspace implements md.IWorkspace {
}
}
});
connection.onRequest(protocol.onWatcherChange, params => {
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() {
@ -154,7 +176,7 @@ export class VsCodeClientWorkspace implements md.IWorkspace {
// We assume that markdown is in UTF-8
const text = this._utf8Decoder.decode(bytes);
const doc = new md.InMemoryDocument(resource, text, 0);
const doc = TextDocument.create(resource.toString(), 'markdown', 0, text);
this._documentCache.set(resource, doc);
return doc;
} catch (e) {
@ -163,6 +185,7 @@ export class VsCodeClientWorkspace implements md.IWorkspace {
}
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 };
}
@ -170,6 +193,7 @@ export class VsCodeClientWorkspace implements md.IWorkspace {
}
async readDirectory(resource: URI): Promise<[string, md.FileStat][]> {
this.logger.log(md.LogLevel.Trace, 'VsCodeClientWorkspace: readDir', `${resource}`);
return this.connection.sendRequest(protocol.readDirectoryRequestType, { uri: resource.toString() });
}
@ -186,6 +210,34 @@ export class VsCodeClientWorkspace implements md.IWorkspace {
return undefined;
}
watchFile(resource: URI, options: FileWatcherOptions): IFileSystemWatcher {
const entry = {
resource,
options,
onDidCreate: new Emitter<URI>(),
onDidChange: new Emitter<URI>(),
onDidDelete: new Emitter<URI>(),
};
const id = this._watcherPool++;
this._watchers.set(id, entry);
this.connection.sendRequest(protocol.createFileWatcher, {
id,
uri: resource.toString(),
options,
});
return {
onDidCreate: entry.onDidCreate.event,
onDidChange: entry.onDidChange.event,
onDidDelete: entry.onDidDelete.event,
dispose: () => {
this.connection.sendRequest(protocol.deleteFileWatcher, { id });
this._watchers.delete(id);
}
};
}
private isRelevantMarkdownDocument(doc: TextDocument) {
return isMarkdownFile(doc) && URI.parse(doc.uri).scheme !== 'vscode-bulkeditpreview';
}

View file

@ -7,6 +7,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.43.tgz#555e5a743f76b6b897d47f945305b618525ddbe6"
integrity sha512-GqWykok+3uocgfAJM8imbozrqLnPyTrpFlrryURQlw1EesPUCx5XxTiucWDSFF9/NUEXDuD4bnvHm8xfVGWTpQ==
picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
vscode-jsonrpc@8.0.2-next.1:
version "8.0.2-next.1"
resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.0.2-next.1.tgz#6bdc39fd194782032e34047eeefce562941259c6"
@ -42,15 +47,22 @@ vscode-languageserver@^8.0.2-next.5`:
dependencies:
vscode-languageserver-protocol "3.17.2-next.6"
vscode-markdown-languageservice@^0.0.0-alpha.8:
version "0.0.0-alpha.8"
resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.0.0-alpha.8.tgz#05d4f86cf0514fd71479847eef742fcc8cdbe87f"
integrity sha512-si8weZsY4LtyonyZwxpFYk8WucRFiKJisErNTt1HDjUCglSDIZqsMNuMIcz3t0nVNfG0LrpdMFVLGhmET5D71Q==
vscode-markdown-languageservice@^0.0.0-alpha.10:
version "0.0.0-alpha.10"
resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.0.0-alpha.10.tgz#53b69c981eed7fd5efa155ab8c0f169995568681"
integrity sha512-rJ85nJ+d45yCz9lBhipavoWXz/vW5FknqqUpLqhe3/2xkrhxt8zcekhSoDepgkKFcTORAFV6g1SnnqxbVhX+uA==
dependencies:
picomatch "^2.3.1"
vscode-languageserver-textdocument "^1.0.5"
vscode-languageserver-types "^3.17.1"
vscode-nls "^5.0.1"
vscode-uri "^3.0.3"
vscode-nls@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-5.0.1.tgz#ba23fc4d4420d25e7f886c8e83cbdcec47aa48b2"
integrity sha512-hHQV6iig+M21lTdItKPkJAaWrxALQb/nqpVffakO4knJOh3DrU2SXOMzUzNgo1eADPzu3qSsJY1weCzvR52q9A==
vscode-uri@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.3.tgz#a95c1ce2e6f41b7549f86279d19f47951e4f4d84"

View file

@ -3,22 +3,16 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import Token = require('markdown-it/lib/token');
import * as vscode from 'vscode';
import { BaseLanguageClient, LanguageClientOptions, NotebookDocumentSyncRegistrationType, RequestType } from 'vscode-languageclient';
import { BaseLanguageClient, LanguageClientOptions, NotebookDocumentSyncRegistrationType } from 'vscode-languageclient';
import * as nls from 'vscode-nls';
import { IMdParser } from './markdownEngine';
import { markdownFileExtensions } from './util/file';
import * as proto from './protocol';
import { looksLikeMarkdownPath, markdownFileExtensions } from './util/file';
import { IMdWorkspace } from './workspace';
const localize = nls.loadMessageBundle();
const parseRequestType: RequestType<{ uri: string }, Token[], any> = new RequestType('markdown/parse');
const readFileRequestType: RequestType<{ uri: string }, number[], any> = new RequestType('markdown/readFile');
const statFileRequestType: RequestType<{ uri: string }, { isDirectory: boolean } | undefined, any> = new RequestType('markdown/statFile');
const readDirectoryRequestType: RequestType<{ uri: string }, [string, { isDirectory: boolean }][], any> = new RequestType('markdown/readDirectory');
const findFilesRequestTypes: RequestType<{}, string[], any> = new RequestType('markdown/findFiles');
export type LanguageClientConstructor = (name: string, description: string, clientOptions: LanguageClientOptions) => BaseLanguageClient;
@ -34,7 +28,16 @@ export async function startClient(factory: LanguageClientConstructor, workspace:
},
initializationOptions: {
markdownFileExtensions,
}
},
diagnosticPullOptions: {
onChange: true,
onSave: true,
onTabs: true,
match(_documentSelector, resource) {
return looksLikeMarkdownPath(resource);
},
},
};
const client = factory('markdown', localize('markdownServer.name', 'Markdown Language Server'), clientOptions);
@ -54,7 +57,7 @@ export async function startClient(factory: LanguageClientConstructor, workspace:
});
}
client.onRequest(parseRequestType, async (e) => {
client.onRequest(proto.parseRequestType, async (e) => {
const uri = vscode.Uri.parse(e.uri);
const doc = await workspace.getOrLoadMarkdownDocument(uri);
if (doc) {
@ -64,12 +67,12 @@ export async function startClient(factory: LanguageClientConstructor, workspace:
}
});
client.onRequest(readFileRequestType, async (e): Promise<number[]> => {
client.onRequest(proto.readFileRequestType, async (e): Promise<number[]> => {
const uri = vscode.Uri.parse(e.uri);
return Array.from(await vscode.workspace.fs.readFile(uri));
});
client.onRequest(statFileRequestType, async (e): Promise<{ isDirectory: boolean } | undefined> => {
client.onRequest(proto.statFileRequestType, async (e): Promise<{ isDirectory: boolean } | undefined> => {
const uri = vscode.Uri.parse(e.uri);
try {
const stat = await vscode.workspace.fs.stat(uri);
@ -79,16 +82,32 @@ export async function startClient(factory: LanguageClientConstructor, workspace:
}
});
client.onRequest(readDirectoryRequestType, async (e): Promise<[string, { isDirectory: boolean }][]> => {
client.onRequest(proto.readDirectoryRequestType, async (e): Promise<[string, { isDirectory: boolean }][]> => {
const uri = vscode.Uri.parse(e.uri);
const result = await vscode.workspace.fs.readDirectory(uri);
return result.map(([name, type]) => [name, { isDirectory: type === vscode.FileType.Directory }]);
});
client.onRequest(findFilesRequestTypes, async (): Promise<string[]> => {
client.onRequest(proto.findFilesRequestTypes, async (): Promise<string[]> => {
return (await vscode.workspace.findFiles(mdFileGlob, '**/node_modules/**')).map(x => x.toString());
});
const watchers = new Map<number, vscode.FileSystemWatcher>();
client.onRequest(proto.createFileWatcher, async (params): Promise<void> => {
const id = params.id;
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(vscode.Uri.parse(params.uri), '*'), params.options.ignoreCreate, params.options.ignoreChange, params.options.ignoreDelete);
watchers.set(id, watcher);
watcher.onDidCreate(() => { client.sendRequest(proto.onWatcherChange, { id, uri: params.uri, kind: 'create' }); });
watcher.onDidChange(() => { client.sendRequest(proto.onWatcherChange, { id, uri: params.uri, kind: 'change' }); });
watcher.onDidDelete(() => { client.sendRequest(proto.onWatcherChange, { id, uri: params.uri, kind: 'delete' }); });
});
client.onRequest(proto.deleteFileWatcher, async (params): Promise<void> => {
watchers.get(params.id)?.dispose();
watchers.delete(params.id);
});
await client.start();
return client;

View file

@ -26,6 +26,9 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(workspace);
const client = await startServer(context, workspace, engine);
context.subscriptions.push({
dispose: () => client.stop()
});
activateShared(context, client, workspace, engine, logger, contributions);
}

View file

@ -9,12 +9,10 @@ import { CommandManager } from './commandManager';
import * as commands from './commands/index';
import { registerPasteSupport } from './languageFeatures/copyPaste';
import { registerDiagnosticSupport } from './languageFeatures/diagnostics';
import { MdLinkProvider } from './languageFeatures/documentLinks';
import { registerDropIntoEditorSupport } from './languageFeatures/dropIntoEditor';
import { registerFindFileReferenceSupport } from './languageFeatures/fileReferences';
import { MdReferencesProvider } from './languageFeatures/references';
import { ILogger } from './logging';
import { IMdParser, MarkdownItEngine, MdParsingProvider } from './markdownEngine';
import { MarkdownItEngine, MdParsingProvider } from './markdownEngine';
import { MarkdownContributionProvider } from './markdownExtensions';
import { MdDocumentRenderer } from './preview/documentRenderer';
import { MarkdownPreviewManager } from './preview/previewManager';
@ -45,7 +43,7 @@ export function activateShared(
const previewManager = new MarkdownPreviewManager(contentProvider, workspace, logger, contributions, tocProvider);
context.subscriptions.push(previewManager);
context.subscriptions.push(registerMarkdownLanguageFeatures(client, parser, workspace, commandManager, tocProvider, logger));
context.subscriptions.push(registerMarkdownLanguageFeatures(client, commandManager));
context.subscriptions.push(registerMarkdownCommands(commandManager, previewManager, telemetryReporter, cspArbiter, engine, tocProvider));
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(() => {
@ -55,23 +53,12 @@ export function activateShared(
function registerMarkdownLanguageFeatures(
client: BaseLanguageClient,
parser: IMdParser,
workspace: IMdWorkspace,
commandManager: CommandManager,
tocProvider: MdTableOfContentsProvider,
logger: ILogger,
): vscode.Disposable {
const selector: vscode.DocumentSelector = { language: 'markdown', scheme: '*' };
const linkProvider = new MdLinkProvider(parser, workspace, logger);
const referencesProvider = new MdReferencesProvider(parser, workspace, tocProvider, logger);
return vscode.Disposable.from(
linkProvider,
referencesProvider,
// Language features
registerDiagnosticSupport(selector, workspace, linkProvider, commandManager, referencesProvider, tocProvider, logger),
registerDiagnosticSupport(selector, commandManager),
registerDropIntoEditorSupport(selector),
registerFindFileReferenceSupport(commandManager, client),
registerPasteSupport(selector),

View file

@ -26,6 +26,9 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(workspace);
const client = await startServer(context, workspace, engine);
context.subscriptions.push({
dispose: () => client.stop()
});
activateShared(context, client, workspace, engine, logger, contributions);
}

View file

@ -3,609 +3,20 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as picomatch from 'picomatch';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { CommandManager } from '../commandManager';
import { ILogger } from '../logging';
import { MdTableOfContentsProvider } from '../tableOfContents';
import { ITextDocument } from '../types/textDocument';
import { Delayer } from '../util/async';
import { noopToken } from '../util/cancellation';
import { Disposable } from '../util/dispose';
import { isMarkdownFile, looksLikeMarkdownPath } from '../util/file';
import { Limiter } from '../util/limiter';
import { ResourceMap } from '../util/resourceMap';
import { MdTableOfContentsWatcher } from '../util/tableOfContentsWatcher';
import { IMdWorkspace } from '../workspace';
import { InternalHref, LinkDefinitionSet, MdLink, MdLinkProvider, MdLinkSource } from './documentLinks';
import { MdReferencesProvider, tryResolveLinkPath } from './references';
const localize = nls.loadMessageBundle();
export interface DiagnosticConfiguration {
/**
* Fired when the configuration changes.
*/
readonly onDidChange: vscode.Event<void>;
getOptions(resource: vscode.Uri): DiagnosticOptions;
// Copied from markdown language service
export enum DiagnosticCode {
link_noSuchReferences = 'link.no-such-reference',
link_noSuchHeaderInOwnFile = 'link.no-such-header-in-own-file',
link_noSuchFile = 'link.no-such-file',
link_noSuchHeaderInFile = 'link.no-such-header-in-file',
}
export enum DiagnosticLevel {
ignore = 'ignore',
warning = 'warning',
error = 'error',
}
export interface DiagnosticOptions {
readonly enabled: boolean;
readonly validateReferences: DiagnosticLevel | undefined;
readonly validateFragmentLinks: DiagnosticLevel | undefined;
readonly validateFileLinks: DiagnosticLevel | undefined;
readonly validateMarkdownFileLinkFragments: DiagnosticLevel | undefined;
readonly ignoreLinks: readonly string[];
}
function toSeverity(level: DiagnosticLevel | undefined): vscode.DiagnosticSeverity | undefined {
switch (level) {
case DiagnosticLevel.error: return vscode.DiagnosticSeverity.Error;
case DiagnosticLevel.warning: return vscode.DiagnosticSeverity.Warning;
case DiagnosticLevel.ignore: return undefined;
case undefined: return undefined;
}
}
class VSCodeDiagnosticConfiguration extends Disposable implements DiagnosticConfiguration {
private readonly _onDidChange = this._register(new vscode.EventEmitter<void>());
public readonly onDidChange = this._onDidChange.event;
constructor() {
super();
this._register(vscode.workspace.onDidChangeConfiguration(e => {
if (
e.affectsConfiguration('markdown.experimental.validate.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.referenceLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.fragmentLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.fileLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.fileLinks.markdownFragmentLinks')
|| e.affectsConfiguration('markdown.experimental.validate.ignoreLinks')
) {
this._onDidChange.fire();
}
}));
}
public getOptions(resource: vscode.Uri): DiagnosticOptions {
const config = vscode.workspace.getConfiguration('markdown', resource);
const validateFragmentLinks = config.get<DiagnosticLevel>('experimental.validate.fragmentLinks.enabled');
return {
enabled: config.get<boolean>('experimental.validate.enabled', false),
validateReferences: config.get<DiagnosticLevel>('experimental.validate.referenceLinks.enabled'),
validateFragmentLinks,
validateFileLinks: config.get<DiagnosticLevel>('experimental.validate.fileLinks.enabled'),
validateMarkdownFileLinkFragments: config.get<DiagnosticLevel | undefined>('markdown.experimental.validate.fileLinks.markdownFragmentLinks', validateFragmentLinks),
ignoreLinks: config.get('experimental.validate.ignoreLinks', []),
};
}
}
class InflightDiagnosticRequests {
private readonly inFlightRequests = new ResourceMap<{ readonly cts: vscode.CancellationTokenSource }>();
public async trigger(resource: vscode.Uri, compute: (token: vscode.CancellationToken) => Promise<void>): Promise<void> {
this.cancel(resource);
const cts = new vscode.CancellationTokenSource();
const entry = { cts };
this.inFlightRequests.set(resource, entry);
try {
return await compute(cts.token);
} finally {
if (this.inFlightRequests.get(resource) === entry) {
this.inFlightRequests.delete(resource);
}
cts.dispose();
}
}
public cancel(resource: vscode.Uri) {
const existing = this.inFlightRequests.get(resource);
if (existing) {
existing.cts.cancel();
this.inFlightRequests.delete(resource);
}
}
public dispose() {
this.clear();
}
public clear() {
for (const { cts } of this.inFlightRequests.values()) {
cts.dispose();
}
this.inFlightRequests.clear();
}
}
class LinkWatcher extends Disposable {
private readonly _onDidChangeLinkedToFile = this._register(new vscode.EventEmitter<Iterable<vscode.Uri>>);
/**
* Event fired with a list of document uri when one of the links in the document changes
*/
public readonly onDidChangeLinkedToFile = this._onDidChangeLinkedToFile.event;
private readonly _watchers = new ResourceMap<{
/**
* Watcher for this link path
*/
readonly watcher: vscode.Disposable;
/**
* List of documents that reference the link
*/
readonly documents: ResourceMap</* document resource*/ vscode.Uri>;
}>();
override dispose() {
super.dispose();
for (const entry of this._watchers.values()) {
entry.watcher.dispose();
}
this._watchers.clear();
}
/**
* Set the known links in a markdown document, adding and removing file watchers as needed
*/
updateLinksForDocument(document: vscode.Uri, links: readonly MdLink[]) {
const linkedToResource = new Set<vscode.Uri>(
links
.filter(link => link.href.kind === 'internal')
.map(link => (link.href as InternalHref).path));
// First decrement watcher counter for previous document state
for (const entry of this._watchers.values()) {
entry.documents.delete(document);
}
// Then create/update watchers for new document state
for (const path of linkedToResource) {
let entry = this._watchers.get(path);
if (!entry) {
entry = {
watcher: this.startWatching(path),
documents: new ResourceMap(),
};
this._watchers.set(path, entry);
}
entry.documents.set(document, document);
}
// Finally clean up watchers for links that are no longer are referenced anywhere
for (const [key, value] of this._watchers) {
if (value.documents.size === 0) {
value.watcher.dispose();
this._watchers.delete(key);
}
}
}
deleteDocument(resource: vscode.Uri) {
this.updateLinksForDocument(resource, []);
}
private startWatching(path: vscode.Uri): vscode.Disposable {
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(path, '*'), false, true, false);
const handler = (resource: vscode.Uri) => this.onLinkedResourceChanged(resource);
return vscode.Disposable.from(
watcher,
watcher.onDidDelete(handler),
watcher.onDidCreate(handler),
);
}
private onLinkedResourceChanged(resource: vscode.Uri) {
const entry = this._watchers.get(resource);
if (entry) {
this._onDidChangeLinkedToFile.fire(entry.documents.values());
}
}
}
class LinkDoesNotExistDiagnostic extends vscode.Diagnostic {
public readonly link: string;
constructor(range: vscode.Range, message: string, severity: vscode.DiagnosticSeverity, link: string) {
super(range, message, severity);
this.link = link;
}
}
export abstract class DiagnosticReporter extends Disposable {
private readonly pending = new Set<Promise<any>>();
public clear(): void {
this.pending.clear();
}
public abstract set(uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[]): void;
public abstract delete(uri: vscode.Uri): void;
public abstract isOpen(uri: vscode.Uri): boolean;
public abstract getOpenDocuments(): ITextDocument[];
public addWorkItem(promise: Promise<any>): Promise<any> {
this.pending.add(promise);
promise.finally(() => this.pending.delete(promise));
return promise;
}
public async waitPendingWork(): Promise<void> {
await Promise.all([...this.pending.values()]);
}
}
export class DiagnosticCollectionReporter extends DiagnosticReporter {
private readonly collection: vscode.DiagnosticCollection;
constructor() {
super();
this.collection = this._register(vscode.languages.createDiagnosticCollection('markdown'));
}
public override clear(): void {
super.clear();
this.collection.clear();
}
public set(uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[]): void {
this.collection.set(uri, this.isOpen(uri) ? diagnostics : []);
}
public isOpen(uri: vscode.Uri): boolean {
const tabs = this.getTabResources();
return tabs.has(uri);
}
public delete(uri: vscode.Uri): void {
this.collection.delete(uri);
}
public getOpenDocuments(): ITextDocument[] {
const tabs = this.getTabResources();
return vscode.workspace.textDocuments.filter(doc => tabs.has(doc.uri));
}
private getTabResources(): ResourceMap<void> {
const openedTabDocs = new ResourceMap<void>();
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputText) {
openedTabDocs.set(tab.input.uri);
}
}
}
return openedTabDocs;
}
}
export class DiagnosticManager extends Disposable {
private readonly diagnosticDelayer: Delayer<void>;
private readonly pendingDiagnostics = new Set<vscode.Uri>();
private readonly inFlightDiagnostics = this._register(new InflightDiagnosticRequests());
private readonly linkWatcher = this._register(new LinkWatcher());
private readonly tableOfContentsWatcher: MdTableOfContentsWatcher;
public readonly ready: Promise<void>;
constructor(
private readonly workspace: IMdWorkspace,
private readonly computer: DiagnosticComputer,
private readonly configuration: DiagnosticConfiguration,
private readonly reporter: DiagnosticReporter,
private readonly referencesProvider: MdReferencesProvider,
tocProvider: MdTableOfContentsProvider,
private readonly logger: ILogger,
delay = 300,
) {
super();
this.diagnosticDelayer = this._register(new Delayer(delay));
this._register(this.configuration.onDidChange(() => {
this.rebuild();
}));
this._register(workspace.onDidCreateMarkdownDocument(doc => {
this.triggerDiagnostics(doc.uri);
// Links in other files may have become valid
this.triggerForReferencingFiles(doc.uri);
}));
this._register(workspace.onDidChangeMarkdownDocument(doc => {
this.triggerDiagnostics(doc.uri);
}));
this._register(workspace.onDidDeleteMarkdownDocument(uri => {
this.triggerForReferencingFiles(uri);
}));
this._register(vscode.workspace.onDidCloseTextDocument(({ uri }) => {
this.pendingDiagnostics.delete(uri);
this.inFlightDiagnostics.cancel(uri);
this.linkWatcher.deleteDocument(uri);
this.reporter.delete(uri);
}));
this._register(this.linkWatcher.onDidChangeLinkedToFile(changedDocuments => {
for (const resource of changedDocuments) {
const doc = vscode.workspace.textDocuments.find(doc => doc.uri.toString() === resource.toString());
if (doc && isMarkdownFile(doc)) {
this.triggerDiagnostics(doc.uri);
}
}
}));
this.tableOfContentsWatcher = this._register(new MdTableOfContentsWatcher(workspace, tocProvider, delay / 2));
this._register(this.tableOfContentsWatcher.onTocChanged(e => {
return this.triggerForReferencingFiles(e.uri);
}));
this.ready = this.rebuild();
}
private triggerForReferencingFiles(uri: vscode.Uri): Promise<void> {
return this.reporter.addWorkItem(
(async () => {
const triggered = new ResourceMap<Promise<void>>();
for (const ref of await this.referencesProvider.getReferencesToFileInDocs(uri, this.reporter.getOpenDocuments(), noopToken)) {
const file = ref.location.uri;
if (!triggered.has(file)) {
triggered.set(file, this.triggerDiagnostics(file));
}
}
await Promise.all(triggered.values());
})());
}
public override dispose() {
super.dispose();
this.pendingDiagnostics.clear();
}
private async recomputeDiagnosticState(doc: ITextDocument, token: vscode.CancellationToken): Promise<{ diagnostics: readonly vscode.Diagnostic[]; links: readonly MdLink[]; config: DiagnosticOptions }> {
this.logger.verbose('DiagnosticManager', `recomputeDiagnosticState - ${doc.uri}`);
const config = this.configuration.getOptions(doc.uri);
if (!config.enabled) {
return { diagnostics: [], links: [], config };
}
return { ...await this.computer.getDiagnostics(doc, config, token), config };
}
private async recomputePendingDiagnostics(): Promise<void> {
const pending = [...this.pendingDiagnostics];
this.pendingDiagnostics.clear();
await Promise.all(pending.map(async resource => {
const doc = await this.workspace.getOrLoadMarkdownDocument(resource);
if (doc) {
await this.inFlightDiagnostics.trigger(doc.uri, async (token) => {
if (this.reporter.isOpen(doc.uri)) {
const state = await this.recomputeDiagnosticState(doc, token);
this.linkWatcher.updateLinksForDocument(doc.uri, state.config.enabled && state.config.validateFileLinks ? state.links : []);
this.reporter.set(doc.uri, state.diagnostics);
} else {
this.linkWatcher.deleteDocument(doc.uri);
this.reporter.delete(doc.uri);
}
});
}
}));
}
private rebuild(): Promise<void> {
this.reporter.clear();
this.pendingDiagnostics.clear();
this.inFlightDiagnostics.clear();
return this.reporter.addWorkItem(
Promise.all(Array.from(this.reporter.getOpenDocuments(), doc => this.triggerDiagnostics(doc.uri)))
);
}
private async triggerDiagnostics(uri: vscode.Uri): Promise<void> {
this.inFlightDiagnostics.cancel(uri);
this.pendingDiagnostics.add(uri);
return this.reporter.addWorkItem(
this.diagnosticDelayer.trigger(() => this.recomputePendingDiagnostics())
);
}
}
/**
* Map of file paths to markdown links to that file.
*/
class FileLinkMap {
private readonly _filesToLinksMap = new ResourceMap<{
readonly outgoingLinks: Array<{
readonly source: MdLinkSource;
readonly fragment: string;
}>;
}>();
constructor(links: Iterable<MdLink>) {
for (const link of links) {
if (link.href.kind !== 'internal') {
continue;
}
const existingFileEntry = this._filesToLinksMap.get(link.href.path);
const linkData = { source: link.source, fragment: link.href.fragment };
if (existingFileEntry) {
existingFileEntry.outgoingLinks.push(linkData);
} else {
this._filesToLinksMap.set(link.href.path, { outgoingLinks: [linkData] });
}
}
}
public get size(): number {
return this._filesToLinksMap.size;
}
public entries() {
return this._filesToLinksMap.entries();
}
}
export class DiagnosticComputer {
constructor(
private readonly workspace: IMdWorkspace,
private readonly linkProvider: MdLinkProvider,
private readonly tocProvider: MdTableOfContentsProvider,
) { }
public async getDiagnostics(doc: ITextDocument, options: DiagnosticOptions, token: vscode.CancellationToken): Promise<{ readonly diagnostics: vscode.Diagnostic[]; readonly links: readonly MdLink[] }> {
const { links, definitions } = await this.linkProvider.getLinks(doc);
if (token.isCancellationRequested || !options.enabled) {
return { links, diagnostics: [] };
}
return {
links,
diagnostics: (await Promise.all([
this.validateFileLinks(options, links, token),
Array.from(this.validateReferenceLinks(options, links, definitions)),
this.validateFragmentLinks(doc, options, links, token),
])).flat()
};
}
private async validateFragmentLinks(doc: ITextDocument, options: DiagnosticOptions, links: readonly MdLink[], token: vscode.CancellationToken): Promise<vscode.Diagnostic[]> {
const severity = toSeverity(options.validateFragmentLinks);
if (typeof severity === 'undefined') {
return [];
}
const toc = await this.tocProvider.getForDocument(doc);
if (token.isCancellationRequested) {
return [];
}
const diagnostics: vscode.Diagnostic[] = [];
for (const link of links) {
if (link.href.kind === 'internal'
&& link.source.hrefText.startsWith('#')
&& link.href.path.toString() === doc.uri.toString()
&& link.href.fragment
&& !toc.lookup(link.href.fragment)
) {
if (!this.isIgnoredLink(options, link.source.hrefText)) {
diagnostics.push(new LinkDoesNotExistDiagnostic(
link.source.hrefRange,
localize('invalidHeaderLink', 'No header found: \'{0}\'', link.href.fragment),
severity,
link.source.hrefText));
}
}
}
return diagnostics;
}
private *validateReferenceLinks(options: DiagnosticOptions, links: readonly MdLink[], definitions: LinkDefinitionSet): Iterable<vscode.Diagnostic> {
const severity = toSeverity(options.validateReferences);
if (typeof severity === 'undefined') {
return [];
}
for (const link of links) {
if (link.href.kind === 'reference' && !definitions.lookup(link.href.ref)) {
yield new vscode.Diagnostic(
link.source.hrefRange,
localize('invalidReferenceLink', 'No link definition found: \'{0}\'', link.href.ref),
severity);
}
}
}
private async validateFileLinks(options: DiagnosticOptions, links: readonly MdLink[], token: vscode.CancellationToken): Promise<vscode.Diagnostic[]> {
const pathErrorSeverity = toSeverity(options.validateFileLinks);
if (typeof pathErrorSeverity === 'undefined') {
return [];
}
const fragmentErrorSeverity = toSeverity(typeof options.validateMarkdownFileLinkFragments === 'undefined' ? options.validateFragmentLinks : options.validateMarkdownFileLinkFragments);
// We've already validated our own fragment links in `validateOwnHeaderLinks`
const linkSet = new FileLinkMap(links.filter(link => !link.source.hrefText.startsWith('#')));
if (linkSet.size === 0) {
return [];
}
const limiter = new Limiter(10);
const diagnostics: vscode.Diagnostic[] = [];
await Promise.all(
Array.from(linkSet.entries()).map(([path, { outgoingLinks: links }]) => {
return limiter.queue(async () => {
if (token.isCancellationRequested) {
return;
}
const resolvedHrefPath = await tryResolveLinkPath(path, this.workspace);
if (!resolvedHrefPath) {
const msg = localize('invalidPathLink', 'File does not exist at path: {0}', path.fsPath);
for (const link of links) {
if (!this.isIgnoredLink(options, link.source.pathText)) {
diagnostics.push(new LinkDoesNotExistDiagnostic(link.source.hrefRange, msg, pathErrorSeverity, link.source.pathText));
}
}
} else if (typeof fragmentErrorSeverity !== 'undefined' && this.isMarkdownPath(resolvedHrefPath)) {
// Validate each of the links to headers in the file
const fragmentLinks = links.filter(x => x.fragment);
if (fragmentLinks.length) {
const toc = await this.tocProvider.get(resolvedHrefPath);
for (const link of fragmentLinks) {
if (!toc.lookup(link.fragment) && !this.isIgnoredLink(options, link.source.pathText) && !this.isIgnoredLink(options, link.source.hrefText)) {
const msg = localize('invalidLinkToHeaderInOtherFile', 'Header does not exist in file: {0}', link.fragment);
const range = link.source.fragmentRange?.with({ start: link.source.fragmentRange.start.translate(0, -1) }) ?? link.source.hrefRange;
diagnostics.push(new LinkDoesNotExistDiagnostic(range, msg, fragmentErrorSeverity, link.source.hrefText));
}
}
}
}
});
}));
return diagnostics;
}
private isMarkdownPath(resolvedHrefPath: vscode.Uri) {
return this.workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownPath(resolvedHrefPath);
}
private isIgnoredLink(options: DiagnosticOptions, link: string): boolean {
return options.ignoreLinks.some(glob => picomatch.isMatch(link, glob));
}
}
class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider {
@ -636,17 +47,26 @@ class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider {
const fixes: vscode.CodeAction[] = [];
for (const diagnostic of context.diagnostics) {
if (diagnostic instanceof LinkDoesNotExistDiagnostic) {
const fix = new vscode.CodeAction(
localize('ignoreLinksQuickFix.title', "Exclude '{0}' from link validation.", diagnostic.link),
vscode.CodeActionKind.QuickFix);
switch (diagnostic.code) {
case DiagnosticCode.link_noSuchReferences:
case DiagnosticCode.link_noSuchHeaderInOwnFile:
case DiagnosticCode.link_noSuchFile:
case DiagnosticCode.link_noSuchHeaderInFile: {
const hrefText = (diagnostic as any).data?.hrefText;
if (hrefText) {
const fix = new vscode.CodeAction(
localize('ignoreLinksQuickFix.title', "Exclude '{0}' from link validation.", hrefText),
vscode.CodeActionKind.QuickFix);
fix.command = {
command: AddToIgnoreLinksQuickFixProvider._addToIgnoreLinksCommandId,
title: '',
arguments: [document.uri, diagnostic.link]
};
fixes.push(fix);
fix.command = {
command: AddToIgnoreLinksQuickFixProvider._addToIgnoreLinksCommandId,
title: '',
arguments: [document.uri, hrefText],
};
fixes.push(fix);
}
break;
}
}
}
@ -654,26 +74,10 @@ class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider {
}
}
export function registerDiagnosticSupport(
selector: vscode.DocumentSelector,
workspace: IMdWorkspace,
linkProvider: MdLinkProvider,
commandManager: CommandManager,
referenceProvider: MdReferencesProvider,
tocProvider: MdTableOfContentsProvider,
logger: ILogger,
): vscode.Disposable {
const configuration = new VSCodeDiagnosticConfiguration();
const manager = new DiagnosticManager(
workspace,
new DiagnosticComputer(workspace, linkProvider, tocProvider),
configuration,
new DiagnosticCollectionReporter(),
referenceProvider,
tocProvider,
logger);
return vscode.Disposable.from(
configuration,
manager,
AddToIgnoreLinksQuickFixProvider.register(selector, commandManager));
return AddToIgnoreLinksQuickFixProvider.register(selector, commandManager);
}

View file

@ -1,540 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as uri from 'vscode-uri';
import { ILogger } from '../logging';
import { IMdParser } from '../markdownEngine';
import { getLine, ITextDocument } from '../types/textDocument';
import { noopToken } from '../util/cancellation';
import { Disposable } from '../util/dispose';
import { Schemes } from '../util/schemes';
import { MdDocumentInfoCache } from '../util/workspaceCache';
import { IMdWorkspace } from '../workspace';
export interface ExternalHref {
readonly kind: 'external';
readonly uri: vscode.Uri;
}
export interface InternalHref {
readonly kind: 'internal';
readonly path: vscode.Uri;
readonly fragment: string;
}
export interface ReferenceHref {
readonly kind: 'reference';
readonly ref: string;
}
export type LinkHref = ExternalHref | InternalHref | ReferenceHref;
function resolveLink(
document: ITextDocument,
link: string,
): ExternalHref | InternalHref | undefined {
const cleanLink = stripAngleBrackets(link);
if (/^[a-z\-][a-z\-]+:/i.test(cleanLink)) {
// Looks like a uri
return { kind: 'external', uri: vscode.Uri.parse(cleanLink) };
}
// Assume it must be an relative or absolute file path
// Use a fake scheme to avoid parse warnings
const tempUri = vscode.Uri.parse(`vscode-resource:${link}`);
let resourceUri: vscode.Uri | undefined;
if (!tempUri.path) {
resourceUri = document.uri;
} else if (tempUri.path[0] === '/') {
const root = getWorkspaceFolder(document);
if (root) {
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
}
} else {
if (document.uri.scheme === Schemes.untitled) {
const root = getWorkspaceFolder(document);
if (root) {
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
}
} else {
const base = uri.Utils.dirname(document.uri);
resourceUri = vscode.Uri.joinPath(base, tempUri.path);
}
}
if (!resourceUri) {
return undefined;
}
// If we are in a notebook cell, resolve relative to notebook instead
if (resourceUri.scheme === Schemes.notebookCell) {
const notebook = vscode.workspace.notebookDocuments
.find(notebook => notebook.getCells().some(cell => cell.document === document));
if (notebook) {
resourceUri = resourceUri.with({ scheme: notebook.uri.scheme });
}
}
return {
kind: 'internal',
path: resourceUri.with({ fragment: '' }),
fragment: tempUri.fragment,
};
}
function getWorkspaceFolder(document: ITextDocument) {
return vscode.workspace.getWorkspaceFolder(document.uri)?.uri
|| vscode.workspace.workspaceFolders?.[0]?.uri;
}
export interface MdLinkSource {
/**
* The full range of the link.
*/
readonly range: vscode.Range;
/**
* The file where the link is defined.
*/
readonly resource: vscode.Uri;
/**
* The original text of the link destination in code.
*/
readonly hrefText: string;
/**
* The original text of just the link's path in code.
*/
readonly pathText: string;
/**
* The range of the path.
*/
readonly hrefRange: vscode.Range;
/**
* The range of the fragment within the path.
*/
readonly fragmentRange: vscode.Range | undefined;
}
export interface MdInlineLink {
readonly kind: 'link';
readonly source: MdLinkSource;
readonly href: LinkHref;
}
export interface MdLinkDefinition {
readonly kind: 'definition';
readonly source: MdLinkSource;
readonly ref: {
readonly range: vscode.Range;
readonly text: string;
};
readonly href: ExternalHref | InternalHref;
}
export type MdLink = MdInlineLink | MdLinkDefinition;
function extractDocumentLink(
document: ITextDocument,
pre: string,
rawLink: string,
matchIndex: number,
fullMatch: string,
): MdLink | undefined {
const isAngleBracketLink = rawLink.startsWith('<');
const link = stripAngleBrackets(rawLink);
let linkTarget: ExternalHref | InternalHref | undefined;
try {
linkTarget = resolveLink(document, link);
} catch {
return undefined;
}
if (!linkTarget) {
return undefined;
}
const linkStart = document.positionAt(matchIndex);
const linkEnd = linkStart.translate(0, fullMatch.length);
const hrefStart = linkStart.translate(0, pre.length + (isAngleBracketLink ? 1 : 0));
const hrefEnd = hrefStart.translate(0, link.length);
return {
kind: 'link',
href: linkTarget,
source: {
hrefText: link,
resource: document.uri,
range: new vscode.Range(linkStart, linkEnd),
hrefRange: new vscode.Range(hrefStart, hrefEnd),
...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd),
}
};
}
function getFragmentRange(text: string, start: vscode.Position, end: vscode.Position): vscode.Range | undefined {
const index = text.indexOf('#');
if (index < 0) {
return undefined;
}
return new vscode.Range(start.translate({ characterDelta: index + 1 }), end);
}
function getLinkSourceFragmentInfo(document: ITextDocument, link: string, linkStart: vscode.Position, linkEnd: vscode.Position): { fragmentRange: vscode.Range | undefined; pathText: string } {
const fragmentRange = getFragmentRange(link, linkStart, linkEnd);
return {
pathText: document.getText(new vscode.Range(linkStart, fragmentRange ? fragmentRange.start.translate(0, -1) : linkEnd)),
fragmentRange,
};
}
const angleBracketLinkRe = /^<(.*)>$/;
/**
* Used to strip brackets from the markdown link
*
* <http://example.com> will be transformed to http://example.com
*/
function stripAngleBrackets(link: string) {
return link.replace(angleBracketLinkRe, '$1');
}
const r = String.raw;
/**
* Matches `[text](link)` or `[text](<link>)`
*/
const linkPattern = new RegExp(
// text
r`(\[` + // open prefix match -->
/**/r`(?:` +
/*****/r`[^\[\]\\]|` + // Non-bracket chars, or...
/*****/r`\\.|` + // Escaped char, or...
/*****/r`\[[^\[\]]*\]` + // Matched bracket pair
/**/r`)*` +
r`\]` +
// Destination
r`\(\s*)` + // <-- close prefix match
/**/r`(` +
/*****/r`[^\s\(\)\<](?:[^\s\(\)]|\([^\s\(\)]*?\))*|` + // Link without whitespace, or...
/*****/r`<[^<>]+>` + // In angle brackets
/**/r`)` +
// Title
/**/r`\s*(?:"[^"]*"|'[^']*'|\([^\(\)]*\))?\s*` +
r`\)`,
'g');
/**
* Matches `[text][ref]` or `[shorthand]`
*/
const referenceLinkPattern = /(^|[^\]\\])(?:(?:(\[((?:\\\]|[^\]])+)\]\[\s*?)([^\s\]]*?)\]|\[\s*?([^\s\\\]]*?)\])(?![\:\(]))/gm;
/**
* Matches `<http://example.com>`
*/
const autoLinkPattern = /\<(\w+:[^\>\s]+)\>/g;
/**
* Matches `[text]: link`
*/
const definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)([^<]\S*|<[^>]+>)/gm;
const inlineCodePattern = /(?:^|[^`])(`+)(?:.+?|.*?(?:(?:\r?\n).+?)*?)(?:\r?\n)?\1(?:$|[^`])/gm;
class NoLinkRanges {
public static async compute(tokenizer: IMdParser, document: ITextDocument): Promise<NoLinkRanges> {
const tokens = await tokenizer.tokenize(document);
const multiline = tokens.filter(t => (t.type === 'code_block' || t.type === 'fence' || t.type === 'html_block') && !!t.map).map(t => t.map) as [number, number][];
const inlineRanges = new Map</* line number */ number, vscode.Range[]>();
const text = document.getText();
for (const match of text.matchAll(inlineCodePattern)) {
const startOffset = match.index ?? 0;
const startPosition = document.positionAt(startOffset);
const range = new vscode.Range(startPosition, document.positionAt(startOffset + match[0].length));
for (let line = range.start.line; line <= range.end.line; ++line) {
let entry = inlineRanges.get(line);
if (!entry) {
entry = [];
inlineRanges.set(line, entry);
}
entry.push(range);
}
}
return new NoLinkRanges(multiline, inlineRanges);
}
private constructor(
/**
* code blocks and fences each represented by [line_start,line_end).
*/
public readonly multiline: ReadonlyArray<[number, number]>,
/**
* Inline code spans where links should not be detected
*/
public readonly inline: Map</* line number */ number, vscode.Range[]>
) { }
contains(position: vscode.Position): boolean {
return this.multiline.some(interval => position.line >= interval[0] && position.line < interval[1]) ||
!!this.inline.get(position.line)?.some(inlineRange => inlineRange.contains(position));
}
concatInline(inlineRanges: Iterable<vscode.Range>): NoLinkRanges {
const newInline = new Map(this.inline);
for (const range of inlineRanges) {
for (let line = range.start.line; line <= range.end.line; ++line) {
let entry = newInline.get(line);
if (!entry) {
entry = [];
newInline.set(line, entry);
}
entry.push(range);
}
}
return new NoLinkRanges(this.multiline, newInline);
}
}
/**
* Stateless object that extracts link information from markdown files.
*/
export class MdLinkComputer {
constructor(
private readonly tokenizer: IMdParser,
) { }
public async getAllLinks(document: ITextDocument, token: vscode.CancellationToken): Promise<MdLink[]> {
const noLinkRanges = await NoLinkRanges.compute(this.tokenizer, document);
if (token.isCancellationRequested) {
return [];
}
const inlineLinks = Array.from(this.getInlineLinks(document, noLinkRanges));
return Array.from([
...inlineLinks,
...this.getReferenceLinks(document, noLinkRanges.concatInline(inlineLinks.map(x => x.source.range))),
...this.getLinkDefinitions(document, noLinkRanges),
...this.getAutoLinks(document, noLinkRanges),
]);
}
private *getInlineLinks(document: ITextDocument, noLinkRanges: NoLinkRanges): Iterable<MdLink> {
const text = document.getText();
for (const match of text.matchAll(linkPattern)) {
const matchLinkData = extractDocumentLink(document, match[1], match[2], match.index ?? 0, match[0]);
if (matchLinkData && !noLinkRanges.contains(matchLinkData.source.hrefRange.start)) {
yield matchLinkData;
// Also check link destination for links
for (const innerMatch of match[1].matchAll(linkPattern)) {
const innerData = extractDocumentLink(document, innerMatch[1], innerMatch[2], (match.index ?? 0) + (innerMatch.index ?? 0), innerMatch[0]);
if (innerData) {
yield innerData;
}
}
}
}
}
private *getAutoLinks(document: ITextDocument, noLinkRanges: NoLinkRanges): Iterable<MdLink> {
const text = document.getText();
for (const match of text.matchAll(autoLinkPattern)) {
const linkOffset = (match.index ?? 0);
const linkStart = document.positionAt(linkOffset);
if (noLinkRanges.contains(linkStart)) {
continue;
}
const link = match[1];
const linkTarget = resolveLink(document, link);
if (!linkTarget) {
continue;
}
const linkEnd = linkStart.translate(0, match[0].length);
const hrefStart = linkStart.translate(0, 1);
const hrefEnd = hrefStart.translate(0, link.length);
yield {
kind: 'link',
href: linkTarget,
source: {
hrefText: link,
resource: document.uri,
hrefRange: new vscode.Range(hrefStart, hrefEnd),
range: new vscode.Range(linkStart, linkEnd),
...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd),
}
};
}
}
private *getReferenceLinks(document: ITextDocument, noLinkRanges: NoLinkRanges): Iterable<MdLink> {
const text = document.getText();
for (const match of text.matchAll(referenceLinkPattern)) {
const linkStartOffset = (match.index ?? 0) + match[1].length;
const linkStart = document.positionAt(linkStartOffset);
if (noLinkRanges.contains(linkStart)) {
continue;
}
let hrefStart: vscode.Position;
let hrefEnd: vscode.Position;
let reference = match[4];
if (reference === '') { // [ref][],
reference = match[3];
const offset = linkStartOffset + 1;
hrefStart = document.positionAt(offset);
hrefEnd = document.positionAt(offset + reference.length);
} else if (reference) { // [text][ref]
const pre = match[2];
const offset = linkStartOffset + pre.length;
hrefStart = document.positionAt(offset);
hrefEnd = document.positionAt(offset + reference.length);
} else if (match[5]) { // [ref]
reference = match[5];
const offset = linkStartOffset + 1;
hrefStart = document.positionAt(offset);
const line = getLine(document, hrefStart.line);
// See if link looks like a checkbox
const checkboxMatch = line.match(/^\s*[\-\*]\s*\[x\]/i);
if (checkboxMatch && hrefStart.character <= checkboxMatch[0].length) {
continue;
}
hrefEnd = document.positionAt(offset + reference.length);
} else {
continue;
}
const linkEnd = linkStart.translate(0, match[0].length - match[1].length);
yield {
kind: 'link',
source: {
hrefText: reference,
pathText: reference,
resource: document.uri,
range: new vscode.Range(linkStart, linkEnd),
hrefRange: new vscode.Range(hrefStart, hrefEnd),
fragmentRange: undefined,
},
href: {
kind: 'reference',
ref: reference,
}
};
}
}
private *getLinkDefinitions(document: ITextDocument, noLinkRanges: NoLinkRanges): Iterable<MdLinkDefinition> {
const text = document.getText();
for (const match of text.matchAll(definitionPattern)) {
const offset = (match.index ?? 0);
const linkStart = document.positionAt(offset);
if (noLinkRanges.contains(linkStart)) {
continue;
}
const pre = match[1];
const reference = match[2];
const rawLinkText = match[3].trim();
const target = resolveLink(document, rawLinkText);
if (!target) {
continue;
}
const isAngleBracketLink = angleBracketLinkRe.test(rawLinkText);
const linkText = stripAngleBrackets(rawLinkText);
const hrefStart = linkStart.translate(0, pre.length + (isAngleBracketLink ? 1 : 0));
const hrefEnd = hrefStart.translate(0, linkText.length);
const hrefRange = new vscode.Range(hrefStart, hrefEnd);
const refStart = linkStart.translate(0, 1);
const refRange = new vscode.Range(refStart, refStart.translate({ characterDelta: reference.length }));
const linkEnd = linkStart.translate(0, match[0].length);
yield {
kind: 'definition',
source: {
hrefText: linkText,
resource: document.uri,
range: new vscode.Range(linkStart, linkEnd),
hrefRange,
...getLinkSourceFragmentInfo(document, rawLinkText, hrefStart, hrefEnd),
},
ref: { text: reference, range: refRange },
href: target,
};
}
}
}
interface MdDocumentLinks {
readonly links: readonly MdLink[];
readonly definitions: LinkDefinitionSet;
}
/**
* Stateful object which provides links for markdown files the workspace.
*/
export class MdLinkProvider extends Disposable {
private readonly _linkCache: MdDocumentInfoCache<MdDocumentLinks>;
private readonly linkComputer: MdLinkComputer;
constructor(
tokenizer: IMdParser,
workspace: IMdWorkspace,
logger: ILogger,
) {
super();
this.linkComputer = new MdLinkComputer(tokenizer);
this._linkCache = this._register(new MdDocumentInfoCache(workspace, async doc => {
logger.verbose('LinkProvider', `compute - ${doc.uri}`);
const links = await this.linkComputer.getAllLinks(doc, noopToken);
return {
links,
definitions: new LinkDefinitionSet(links),
};
}));
}
public async getLinks(document: ITextDocument): Promise<MdDocumentLinks> {
return this._linkCache.getForDocument(document);
}
}
export class LinkDefinitionSet implements Iterable<[string, MdLinkDefinition]> {
private readonly _map = new Map<string, MdLinkDefinition>();
constructor(links: Iterable<MdLink>) {
for (const link of links) {
if (link.kind === 'definition') {
this._map.set(link.ref.text, link);
}
}
}
public [Symbol.iterator](): Iterator<[string, MdLinkDefinition]> {
return this._map.entries();
}
public lookup(ref: string): MdLinkDefinition | undefined {
return this._map.get(ref);
}
}

View file

@ -1,329 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as uri from 'vscode-uri';
import { ILogger } from '../logging';
import { IMdParser } from '../markdownEngine';
import { MdTableOfContentsProvider, TocEntry } from '../tableOfContents';
import { ITextDocument } from '../types/textDocument';
import { noopToken } from '../util/cancellation';
import { Disposable } from '../util/dispose';
import { looksLikeMarkdownPath } from '../util/file';
import { MdWorkspaceInfoCache } from '../util/workspaceCache';
import { IMdWorkspace } from '../workspace';
import { InternalHref, MdLink, MdLinkComputer } from './documentLinks';
/**
* A link in a markdown file.
*/
export interface MdLinkReference {
readonly kind: 'link';
readonly isTriggerLocation: boolean;
readonly isDefinition: boolean;
readonly location: vscode.Location;
readonly link: MdLink;
}
/**
* A header in a markdown file.
*/
export interface MdHeaderReference {
readonly kind: 'header';
readonly isTriggerLocation: boolean;
readonly isDefinition: boolean;
/**
* The range of the header.
*
* In `# a b c #` this would be the range of `# a b c #`
*/
readonly location: vscode.Location;
/**
* The text of the header.
*
* In `# a b c #` this would be `a b c`
*/
readonly headerText: string;
/**
* The range of the header text itself.
*
* In `# a b c #` this would be the range of `a b c`
*/
readonly headerTextLocation: vscode.Location;
}
export type MdReference = MdLinkReference | MdHeaderReference;
/**
* Stateful object that computes references for markdown files.
*/
export class MdReferencesProvider extends Disposable {
private readonly _linkCache: MdWorkspaceInfoCache<readonly MdLink[]>;
public constructor(
private readonly parser: IMdParser,
private readonly workspace: IMdWorkspace,
private readonly tocProvider: MdTableOfContentsProvider,
private readonly logger: ILogger,
) {
super();
const linkComputer = new MdLinkComputer(parser);
this._linkCache = this._register(new MdWorkspaceInfoCache(workspace, doc => linkComputer.getAllLinks(doc, noopToken)));
}
public async getReferencesAtPosition(document: ITextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
this.logger.verbose('ReferencesProvider', `getReferencesAtPosition: ${document.uri}`);
const toc = await this.tocProvider.getForDocument(document);
if (token.isCancellationRequested) {
return [];
}
const header = toc.entries.find(entry => entry.line === position.line);
if (header) {
return this.getReferencesToHeader(document, header);
} else {
return this.getReferencesToLinkAtPosition(document, position, token);
}
}
public async getReferencesToFileInWorkspace(resource: vscode.Uri, token: vscode.CancellationToken): Promise<MdReference[]> {
this.logger.verbose('ReferencesProvider', `getAllReferencesToFileInWorkspace: ${resource}`);
const allLinksInWorkspace = (await this._linkCache.values()).flat();
if (token.isCancellationRequested) {
return [];
}
return Array.from(this.findLinksToFile(resource, allLinksInWorkspace, undefined));
}
public async getReferencesToFileInDocs(resource: vscode.Uri, otherDocs: readonly ITextDocument[], token: vscode.CancellationToken): Promise<MdReference[]> {
this.logger.verbose('ReferencesProvider', `getAllReferencesToFileInFiles: ${resource}`);
const links = (await this._linkCache.getForDocs(otherDocs)).flat();
if (token.isCancellationRequested) {
return [];
}
return Array.from(this.findLinksToFile(resource, links, undefined));
}
private async getReferencesToHeader(document: ITextDocument, header: TocEntry): Promise<MdReference[]> {
const links = (await this._linkCache.values()).flat();
const references: MdReference[] = [];
references.push({
kind: 'header',
isTriggerLocation: true,
isDefinition: true,
location: header.headerLocation,
headerText: header.text,
headerTextLocation: header.headerTextLocation
});
for (const link of links) {
if (link.href.kind === 'internal'
&& this.looksLikeLinkToDoc(link.href, document.uri)
&& this.parser.slugifier.fromHeading(link.href.fragment).value === header.slug.value
) {
references.push({
kind: 'link',
isTriggerLocation: false,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, link.source.hrefRange),
});
}
}
return references;
}
private async getReferencesToLinkAtPosition(document: ITextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
const docLinks = (await this._linkCache.getForDocs([document]))[0];
for (const link of docLinks) {
if (link.kind === 'definition') {
// We could be in either the ref name or the definition
if (link.ref.range.contains(position)) {
return Array.from(this.getReferencesToLinkReference(docLinks, link.ref.text, { resource: document.uri, range: link.ref.range }));
} else if (link.source.hrefRange.contains(position)) {
return this.getReferencesToLink(link, position, token);
}
} else {
if (link.source.hrefRange.contains(position)) {
return this.getReferencesToLink(link, position, token);
}
}
}
return [];
}
private async getReferencesToLink(sourceLink: MdLink, triggerPosition: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
const allLinksInWorkspace = (await this._linkCache.values()).flat();
if (token.isCancellationRequested) {
return [];
}
if (sourceLink.href.kind === 'reference') {
return Array.from(this.getReferencesToLinkReference(allLinksInWorkspace, sourceLink.href.ref, { resource: sourceLink.source.resource, range: sourceLink.source.hrefRange }));
}
if (sourceLink.href.kind === 'external') {
const references: MdReference[] = [];
for (const link of allLinksInWorkspace) {
if (link.href.kind === 'external' && link.href.uri.toString() === sourceLink.href.uri.toString()) {
const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
references.push({
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, link.source.hrefRange),
});
}
}
return references;
}
const resolvedResource = await tryResolveLinkPath(sourceLink.href.path, this.workspace);
if (token.isCancellationRequested) {
return [];
}
const references: MdReference[] = [];
if (resolvedResource && this.isMarkdownPath(resolvedResource) && sourceLink.href.fragment && sourceLink.source.fragmentRange?.contains(triggerPosition)) {
const toc = await this.tocProvider.get(resolvedResource);
const entry = toc.lookup(sourceLink.href.fragment);
if (entry) {
references.push({
kind: 'header',
isTriggerLocation: false,
isDefinition: true,
location: entry.headerLocation,
headerText: entry.text,
headerTextLocation: entry.headerTextLocation
});
}
for (const link of allLinksInWorkspace) {
if (link.href.kind !== 'internal' || !this.looksLikeLinkToDoc(link.href, resolvedResource)) {
continue;
}
if (this.parser.slugifier.fromHeading(link.href.fragment).equals(this.parser.slugifier.fromHeading(sourceLink.href.fragment))) {
const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
references.push({
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, link.source.hrefRange),
});
}
}
} else { // Triggered on a link without a fragment so we only require matching the file and ignore fragments
references.push(...this.findLinksToFile(resolvedResource ?? sourceLink.href.path, allLinksInWorkspace, sourceLink));
}
return references;
}
private isMarkdownPath(resolvedHrefPath: vscode.Uri) {
return this.workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownPath(resolvedHrefPath);
}
private looksLikeLinkToDoc(href: InternalHref, targetDoc: vscode.Uri) {
return href.path.fsPath === targetDoc.fsPath
|| uri.Utils.extname(href.path) === '' && href.path.with({ path: href.path.path + '.md' }).fsPath === targetDoc.fsPath;
}
private *findLinksToFile(resource: vscode.Uri, links: readonly MdLink[], sourceLink: MdLink | undefined): Iterable<MdReference> {
for (const link of links) {
if (link.href.kind !== 'internal' || !this.looksLikeLinkToDoc(link.href, resource)) {
continue;
}
// Exclude cases where the file is implicitly referencing itself
if (link.source.hrefText.startsWith('#') && link.source.resource.fsPath === resource.fsPath) {
continue;
}
const isTriggerLocation = !!sourceLink && sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
const pathRange = this.getPathRange(link);
yield {
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, pathRange),
};
}
}
private *getReferencesToLinkReference(allLinks: Iterable<MdLink>, refToFind: string, from: { resource: vscode.Uri; range: vscode.Range }): Iterable<MdReference> {
for (const link of allLinks) {
let ref: string;
if (link.kind === 'definition') {
ref = link.ref.text;
} else if (link.href.kind === 'reference') {
ref = link.href.ref;
} else {
continue;
}
if (ref === refToFind && link.source.resource.fsPath === from.resource.fsPath) {
const isTriggerLocation = from.resource.fsPath === link.source.resource.fsPath && (
(link.href.kind === 'reference' && from.range.isEqual(link.source.hrefRange)) || (link.kind === 'definition' && from.range.isEqual(link.ref.range)));
const pathRange = this.getPathRange(link);
yield {
kind: 'link',
isTriggerLocation,
isDefinition: link.kind === 'definition',
link,
location: new vscode.Location(from.resource, pathRange),
};
}
}
}
/**
* Get just the range of the file path, dropping the fragment
*/
private getPathRange(link: MdLink): vscode.Range {
return link.source.fragmentRange
? link.source.hrefRange.with(undefined, link.source.fragmentRange.start.translate(0, -1))
: link.source.hrefRange;
}
}
export async function tryResolveLinkPath(originalUri: vscode.Uri, workspace: IMdWorkspace): Promise<vscode.Uri | undefined> {
if (await workspace.pathExists(originalUri)) {
return originalUri;
}
// We don't think the file exists. If it doesn't already have an extension, try tacking on a `.md` and using that instead
if (uri.Utils.extname(originalUri) === '') {
const dotMdResource = originalUri.with({ path: originalUri.path + '.md' });
if (await workspace.pathExists(dotMdResource)) {
return dotMdResource;
}
}
return undefined;
}

View file

@ -6,6 +6,7 @@
import Token = require('markdown-it/lib/token');
import { RequestType } from 'vscode-languageclient';
import type * as lsp from 'vscode-languageserver-types';
import type * as md from 'vscode-markdown-languageservice';
// From server
export const parseRequestType: RequestType<{ uri: string }, Token[], any> = new RequestType('markdown/parse');
@ -14,5 +15,10 @@ export const statFileRequestType: RequestType<{ uri: string }, { isDirectory: bo
export const readDirectoryRequestType: RequestType<{ uri: string }, [string, { isDirectory: boolean }][], any> = new RequestType('markdown/readDirectory');
export const findFilesRequestTypes = new RequestType<{}, string[], any>('markdown/findFiles');
export const createFileWatcher: RequestType<{ id: number; uri: string; options: md.FileWatcherOptions }, void, any> = new RequestType('markdown/createFileWatcher');
export const deleteFileWatcher: RequestType<{ id: number }, void, any> = new RequestType('markdown/deleteFileWatcher');
// To server
export const getReferencesToFileInWorkspace = new RequestType<{ uri: string }, lsp.Location[], any>('markdown/getReferencesToFileInWorkspace');
export const onWatcherChange: RequestType<{ id: number; uri: string; kind: 'create' | 'change' | 'delete' }, void, any> = new RequestType('markdown/onWatcherChange');

View file

@ -1,591 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import { DiagnosticCollectionReporter, DiagnosticComputer, DiagnosticConfiguration, DiagnosticLevel, DiagnosticManager, DiagnosticOptions, DiagnosticReporter } from '../languageFeatures/diagnostics';
import { MdLinkProvider } from '../languageFeatures/documentLinks';
import { MdReferencesProvider } from '../languageFeatures/references';
import { MdTableOfContentsProvider } from '../tableOfContents';
import { ITextDocument } from '../types/textDocument';
import { noopToken } from '../util/cancellation';
import { DisposableStore } from '../util/dispose';
import { InMemoryDocument } from '../util/inMemoryDocument';
import { ResourceMap } from '../util/resourceMap';
import { IMdWorkspace } from '../workspace';
import { createNewMarkdownEngine } from './engine';
import { InMemoryMdWorkspace } from './inMemoryWorkspace';
import { nulLogger } from './nulLogging';
import { assertRangeEqual, joinLines, withStore, workspacePath } from './util';
const defaultDiagnosticsOptions = Object.freeze<DiagnosticOptions>({
enabled: true,
validateFileLinks: DiagnosticLevel.warning,
validateMarkdownFileLinkFragments: undefined,
validateFragmentLinks: DiagnosticLevel.warning,
validateReferences: DiagnosticLevel.warning,
ignoreLinks: [],
});
async function getComputedDiagnostics(store: DisposableStore, doc: InMemoryDocument, workspace: IMdWorkspace, options: Partial<DiagnosticOptions> = {}): Promise<vscode.Diagnostic[]> {
const engine = createNewMarkdownEngine();
const linkProvider = store.add(new MdLinkProvider(engine, workspace, nulLogger));
const tocProvider = store.add(new MdTableOfContentsProvider(engine, workspace, nulLogger));
const computer = new DiagnosticComputer(workspace, linkProvider, tocProvider);
return (
await computer.getDiagnostics(doc, { ...defaultDiagnosticsOptions, ...options, }, noopToken)
).diagnostics;
}
function assertDiagnosticsEqual(actual: readonly vscode.Diagnostic[], expectedRanges: readonly vscode.Range[]) {
assert.strictEqual(actual.length, expectedRanges.length, "Diagnostic count equal");
for (let i = 0; i < actual.length; ++i) {
assertRangeEqual(actual[i].range, expectedRanges[i], `Range ${i} to be equal`);
}
}
function orderDiagnosticsByRange(diagnostics: Iterable<vscode.Diagnostic>): readonly vscode.Diagnostic[] {
return Array.from(diagnostics).sort((a, b) => a.range.start.compareTo(b.range.start));
}
class MemoryDiagnosticConfiguration implements DiagnosticConfiguration {
private readonly _onDidChange = new vscode.EventEmitter<void>();
public readonly onDidChange = this._onDidChange.event;
private _options: Partial<DiagnosticOptions>;
constructor(options: Partial<DiagnosticOptions>) {
this._options = options;
}
public getOptions(_resource: vscode.Uri): DiagnosticOptions {
return {
...defaultDiagnosticsOptions,
...this._options,
};
}
public update(newOptions: Partial<DiagnosticOptions>) {
this._options = newOptions;
this._onDidChange.fire();
}
}
class MemoryDiagnosticReporter extends DiagnosticReporter {
private readonly diagnostics = new ResourceMap<readonly vscode.Diagnostic[]>();
constructor(
private readonly workspace: InMemoryMdWorkspace,
) {
super();
}
override dispose(): void {
super.clear();
this.clear();
}
override clear(): void {
super.clear();
this.diagnostics.clear();
}
set(uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[]): void {
this.diagnostics.set(uri, diagnostics);
}
isOpen(_uri: vscode.Uri): boolean {
return true;
}
delete(uri: vscode.Uri): void {
this.diagnostics.delete(uri);
}
get(uri: vscode.Uri): readonly vscode.Diagnostic[] {
return orderDiagnosticsByRange(this.diagnostics.get(uri) ?? []);
}
getOpenDocuments(): ITextDocument[] {
return this.workspace.values();
}
}
suite('markdown: Diagnostic Computer', () => {
test('Should not return any diagnostics for empty document', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`text`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc]));
const diagnostics = await getComputedDiagnostics(store, doc, workspace);
assert.deepStrictEqual(diagnostics, []);
}));
test('Should generate diagnostic for link to file that does not exist', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`[bad](/no/such/file.md)`,
`[good](/doc.md)`,
`[good-ref]: /doc.md`,
`[bad-ref]: /no/such/file.md`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc]));
const diagnostics = await getComputedDiagnostics(store, doc, workspace);
assertDiagnosticsEqual(diagnostics, [
new vscode.Range(0, 6, 0, 22),
new vscode.Range(3, 11, 3, 27),
]);
}));
test('Should generate diagnostics for links to header that does not exist in current file', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`[good](#good-header)`,
`# Good Header`,
`[bad](#no-such-header)`,
`[good](#good-header)`,
`[good-ref]: #good-header`,
`[bad-ref]: #no-such-header`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc]));
const diagnostics = await getComputedDiagnostics(store, doc, workspace);
assertDiagnosticsEqual(diagnostics, [
new vscode.Range(2, 6, 2, 21),
new vscode.Range(5, 11, 5, 26),
]);
}));
test('Should generate diagnostics for links to non-existent headers in other files', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`# My header`,
`[good](#my-header)`,
`[good](/doc1.md#my-header)`,
`[good](doc1.md#my-header)`,
`[good](/doc2.md#other-header)`,
`[bad](/doc2.md#no-such-other-header)`,
));
const doc2 = new InMemoryDocument(workspacePath('doc2.md'), joinLines(
`# Other header`,
));
const diagnostics = await getComputedDiagnostics(store, doc1, new InMemoryMdWorkspace([doc1, doc2]));
assertDiagnosticsEqual(diagnostics, [
new vscode.Range(5, 14, 5, 35),
]);
}));
test('Should support links both with and without .md file extension', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`# My header`,
`[good](#my-header)`,
`[good](/doc.md#my-header)`,
`[good](doc.md#my-header)`,
`[good](/doc#my-header)`,
`[good](doc#my-header)`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc]));
const diagnostics = await getComputedDiagnostics(store, doc, workspace);
assertDiagnosticsEqual(diagnostics, []);
}));
test('Should generate diagnostics for non-existent link reference', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines(
`[good link][good]`,
`[bad link][no-such]`,
``,
`[good]: http://example.com`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc]));
const diagnostics = await getComputedDiagnostics(store, doc, workspace);
assertDiagnosticsEqual(diagnostics, [
new vscode.Range(1, 11, 1, 18),
]);
}));
test('Should not generate diagnostics when validate is disabled', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[text](#no-such-header)`,
`[text][no-such-ref]`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1]));
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, new MemoryDiagnosticConfiguration({ enabled: false }).getOptions(doc1.uri));
assertDiagnosticsEqual(diagnostics, []);
}));
test('Should not generate diagnostics for email autolink', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`a <user@example.com> c`,
));
const diagnostics = await getComputedDiagnostics(store, doc1, new InMemoryMdWorkspace([doc1]));
assertDiagnosticsEqual(diagnostics, []);
}));
test('Should not generate diagnostics for html tag that looks like an autolink', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`a <tag>b</tag> c`,
`a <scope:tag>b</scope:tag> c`,
));
const diagnostics = await getComputedDiagnostics(store, doc1, new InMemoryMdWorkspace([doc1]));
assertDiagnosticsEqual(diagnostics, []);
}));
test('Should allow ignoring invalid file link using glob', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[text](/no-such-file)`,
`![img](/no-such-file)`,
`[text]: /no-such-file`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1]));
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['/no-such-file'] });
assertDiagnosticsEqual(diagnostics, []);
}));
test('Should be able to disable fragment validation for external files', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`![i](/doc2.md#no-such)`,
));
const doc2 = new InMemoryDocument(workspacePath('doc2.md'), joinLines(''));
const workspace = new InMemoryMdWorkspace([doc1, doc2]);
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { validateMarkdownFileLinkFragments: DiagnosticLevel.ignore });
assertDiagnosticsEqual(diagnostics, []);
}));
test('Disabling own fragment validation should also disable path fragment validation by default', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[b](#no-head)`,
`![i](/doc2.md#no-such)`,
));
const doc2 = new InMemoryDocument(workspacePath('doc2.md'), joinLines(''));
const workspace = new InMemoryMdWorkspace([doc1, doc2]);
{
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { validateFragmentLinks: DiagnosticLevel.ignore });
assertDiagnosticsEqual(diagnostics, []);
}
{
// But we should be able to override the default
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { validateFragmentLinks: DiagnosticLevel.ignore, validateMarkdownFileLinkFragments: DiagnosticLevel.warning });
assertDiagnosticsEqual(diagnostics, [
new vscode.Range(1, 13, 1, 21),
]);
}
}));
test('ignoreLinks should allow skipping link to non-existent file', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[text](/no-such-file#header)`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1]));
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['/no-such-file'] });
assertDiagnosticsEqual(diagnostics, []);
}));
test('ignoreLinks should not consider link fragment', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[text](/no-such-file#header)`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1]));
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['/no-such-file'] });
assertDiagnosticsEqual(diagnostics, []);
}));
test('ignoreLinks should support globs', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`![i](/images/aaa.png)`,
`![i](/images/sub/bbb.png)`,
`![i](/images/sub/sub2/ccc.png)`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1]));
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['/images/**/*.png'] });
assertDiagnosticsEqual(diagnostics, []);
}));
test('ignoreLinks should support ignoring header', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`![i](#no-such)`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1]));
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['#no-such'] });
assertDiagnosticsEqual(diagnostics, []);
}));
test('ignoreLinks should support ignoring header in file', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`![i](/doc2.md#no-such)`,
));
const doc2 = new InMemoryDocument(workspacePath('doc2.md'), joinLines(''));
const workspace = store.add(new InMemoryMdWorkspace([doc1, doc2]));
{
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['/doc2.md#no-such'] });
assertDiagnosticsEqual(diagnostics, []);
}
{
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['/doc2.md#*'] });
assertDiagnosticsEqual(diagnostics, []);
}
}));
test('ignoreLinks should support ignore header links if file is ignored', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`![i](/doc2.md#no-such)`,
));
const doc2 = new InMemoryDocument(workspacePath('doc2.md'), joinLines(''));
const workspace = new InMemoryMdWorkspace([doc1, doc2]);
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['/doc2.md'] });
assertDiagnosticsEqual(diagnostics, []);
}));
test('Should not detect checkboxes as invalid links', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`- [x]`,
`- [X]`,
`- [ ]`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1]));
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, { ignoreLinks: ['/doc2.md'] });
assertDiagnosticsEqual(diagnostics, []);
}));
test('Should detect invalid links with titles', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[link](<no such.md> "text")`,
`[link](<no such.md> 'text')`,
`[link](<no such.md> (text))`,
`[link](no-such.md "text")`,
`[link](no-such.md 'text')`,
`[link](no-such.md (text))`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc]));
const diagnostics = await getComputedDiagnostics(store, doc, workspace);
assertDiagnosticsEqual(diagnostics, [
new vscode.Range(0, 8, 0, 18),
new vscode.Range(1, 8, 1, 18),
new vscode.Range(2, 8, 2, 18),
new vscode.Range(3, 7, 3, 17),
new vscode.Range(4, 7, 4, 17),
new vscode.Range(5, 7, 5, 17),
]);
}));
test('Should generate diagnostics for non-existent header using file link to own file', withStore(async (store) => {
const doc = new InMemoryDocument(workspacePath('sub', 'doc.md'), joinLines(
`[bad](doc.md#no-such)`,
`[bad](doc#no-such)`,
`[bad](/sub/doc.md#no-such)`,
`[bad](/sub/doc#no-such)`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc]));
const diagnostics = await getComputedDiagnostics(store, doc, workspace);
assertDiagnosticsEqual(orderDiagnosticsByRange(diagnostics), [
new vscode.Range(0, 12, 0, 20),
new vscode.Range(1, 9, 1, 17),
new vscode.Range(2, 17, 2, 25),
new vscode.Range(3, 14, 3, 22),
]);
}));
test('Own header link using file path link should be controlled by "validateMarkdownFileLinkFragments" instead of "validateFragmentLinks"', withStore(async (store) => {
const doc1 = new InMemoryDocument(workspacePath('sub', 'doc.md'), joinLines(
`[bad](doc.md#no-such)`,
`[bad](doc#no-such)`,
`[bad](/sub/doc.md#no-such)`,
`[bad](/sub/doc#no-such)`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1]));
const diagnostics = await getComputedDiagnostics(store, doc1, workspace, {
validateFragmentLinks: DiagnosticLevel.ignore,
validateMarkdownFileLinkFragments: DiagnosticLevel.warning,
});
assertDiagnosticsEqual(orderDiagnosticsByRange(diagnostics), [
new vscode.Range(0, 12, 0, 20),
new vscode.Range(1, 9, 1, 17),
new vscode.Range(2, 17, 2, 25),
new vscode.Range(3, 14, 3, 22),
]);
}));
});
suite('Markdown: Diagnostics manager', () => {
function createDiagnosticsManager(
store: DisposableStore,
workspace: IMdWorkspace,
configuration = new MemoryDiagnosticConfiguration({}),
reporter: DiagnosticReporter = new DiagnosticCollectionReporter(),
) {
const engine = createNewMarkdownEngine();
const linkProvider = store.add(new MdLinkProvider(engine, workspace, nulLogger));
const tocProvider = store.add(new MdTableOfContentsProvider(engine, workspace, nulLogger));
const referencesProvider = store.add(new MdReferencesProvider(engine, workspace, tocProvider, nulLogger));
const manager = store.add(new DiagnosticManager(
workspace,
new DiagnosticComputer(workspace, linkProvider, tocProvider),
configuration,
reporter,
referencesProvider,
tocProvider,
nulLogger,
0));
return manager;
}
test('Changing enable/disable should recompute diagnostics', withStore(async (store) => {
const doc1Uri = workspacePath('doc1.md');
const doc2Uri = workspacePath('doc2.md');
const workspace = store.add(new InMemoryMdWorkspace([
new InMemoryDocument(doc1Uri, joinLines(
`[text](#no-such-1)`,
)),
new InMemoryDocument(doc2Uri, joinLines(
`[text](#no-such-2)`,
))
]));
const reporter = store.add(new MemoryDiagnosticReporter(workspace));
const config = new MemoryDiagnosticConfiguration({ enabled: true });
const manager = createDiagnosticsManager(store, workspace, config, reporter);
await manager.ready;
// Check initial state (Enabled)
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), [
new vscode.Range(0, 7, 0, 17),
]);
assertDiagnosticsEqual(reporter.get(doc2Uri), [
new vscode.Range(0, 7, 0, 17),
]);
// Disable
config.update({ enabled: false });
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), []);
assertDiagnosticsEqual(reporter.get(doc2Uri), []);
// Enable
config.update({ enabled: true });
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), [
new vscode.Range(0, 7, 0, 17),
]);
assertDiagnosticsEqual(reporter.get(doc2Uri), [
new vscode.Range(0, 7, 0, 17),
]);
}));
test('Should revalidate linked files when header changes', withStore(async (store) => {
const doc1Uri = workspacePath('doc1.md');
const doc1 = new InMemoryDocument(doc1Uri, joinLines(
`[text](#no-such)`,
`[text](/doc2.md#header)`,
));
const doc2Uri = workspacePath('doc2.md');
const doc2 = new InMemoryDocument(doc2Uri, joinLines(
`# Header`,
`[text](#header)`,
`[text](#no-such-2)`,
));
const workspace = store.add(new InMemoryMdWorkspace([doc1, doc2]));
const reporter = store.add(new MemoryDiagnosticReporter(workspace));
const manager = createDiagnosticsManager(store, workspace, new MemoryDiagnosticConfiguration({}), reporter);
await manager.ready;
// Check initial state
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), [
new vscode.Range(0, 7, 0, 15),
]);
assertDiagnosticsEqual(reporter.get(doc2Uri), [
new vscode.Range(2, 7, 2, 17),
]);
// Edit header
workspace.updateDocument(new InMemoryDocument(doc2Uri, joinLines(
`# new header`,
`[text](#new-header)`,
`[text](#no-such-2)`,
)));
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), [
new vscode.Range(0, 7, 0, 15),
new vscode.Range(1, 15, 1, 22),
]);
assertDiagnosticsEqual(reporter.get(doc2Uri), [
new vscode.Range(2, 7, 2, 17),
]);
// Revert to original file
workspace.updateDocument(new InMemoryDocument(doc2Uri, joinLines(
`# header`,
`[text](#header)`,
`[text](#no-such-2)`,
)));
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), [
new vscode.Range(0, 7, 0, 15)
]);
assertDiagnosticsEqual(reporter.get(doc2Uri), [
new vscode.Range(2, 7, 2, 17),
]);
}));
test('Should revalidate linked files when file is deleted/created', withStore(async (store) => {
const doc1Uri = workspacePath('doc1.md');
const doc1 = new InMemoryDocument(doc1Uri, joinLines(
`[text](/doc2.md)`,
`[text](/doc2.md#header)`,
));
const doc2Uri = workspacePath('doc2.md');
const doc2 = new InMemoryDocument(doc2Uri, joinLines(
`# Header`
));
const workspace = store.add(new InMemoryMdWorkspace([doc1, doc2]));
const reporter = store.add(new MemoryDiagnosticReporter(workspace));
const manager = createDiagnosticsManager(store, workspace, new MemoryDiagnosticConfiguration({}), reporter);
await manager.ready;
// Check initial state
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), []);
// Edit header
workspace.deleteDocument(doc2Uri);
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), [
new vscode.Range(0, 7, 0, 15),
new vscode.Range(1, 7, 1, 22),
]);
// Revert to original file
workspace.createDocument(doc2);
await reporter.waitPendingWork();
assertDiagnosticsEqual(reporter.get(doc1Uri), []);
}));
});

View file

@ -1,33 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import { InMemoryDocument } from '../util/inMemoryDocument';
import { MdDocumentInfoCache } from '../util/workspaceCache';
import { InMemoryMdWorkspace } from './inMemoryWorkspace';
import { workspacePath } from './util';
suite('DocumentInfoCache', () => {
test('Repeated calls should only compute value once', async () => {
const doc = workspacePath('doc.md');
const workspace = new InMemoryMdWorkspace([
new InMemoryDocument(doc, '')
]);
let i = 0;
const cache = new MdDocumentInfoCache<number>(workspace, async () => {
return ++i;
});
const a = cache.get(doc);
const b = cache.get(doc);
assert.strictEqual(await a, 1);
assert.strictEqual(i, 1);
assert.strictEqual(await b, 1);
assert.strictEqual(i, 1);
});
});

View file

@ -1,136 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import { TableOfContents } from '../tableOfContents';
import { ITextDocument } from '../types/textDocument';
import { InMemoryDocument } from '../util/inMemoryDocument';
import { createNewMarkdownEngine } from './engine';
const testFileName = vscode.Uri.file('test.md');
function createToc(doc: ITextDocument): Promise<TableOfContents> {
const engine = createNewMarkdownEngine();
return TableOfContents.create(engine, doc);
}
suite('markdown.TableOfContentsProvider', () => {
test('Lookup should not return anything for empty document', async () => {
const doc = new InMemoryDocument(testFileName, '');
const provider = await createToc(doc);
assert.strictEqual(provider.lookup(''), undefined);
assert.strictEqual(provider.lookup('foo'), undefined);
});
test('Lookup should not return anything for document with no headers', async () => {
const doc = new InMemoryDocument(testFileName, 'a *b*\nc');
const provider = await createToc(doc);
assert.strictEqual(provider.lookup(''), undefined);
assert.strictEqual(provider.lookup('foo'), undefined);
assert.strictEqual(provider.lookup('a'), undefined);
assert.strictEqual(provider.lookup('b'), undefined);
});
test('Lookup should return basic #header', async () => {
const doc = new InMemoryDocument(testFileName, `# a\nx\n# c`);
const provider = await createToc(doc);
{
const entry = provider.lookup('a');
assert.ok(entry);
assert.strictEqual(entry!.line, 0);
}
{
assert.strictEqual(provider.lookup('x'), undefined);
}
{
const entry = provider.lookup('c');
assert.ok(entry);
assert.strictEqual(entry!.line, 2);
}
});
test('Lookups should be case in-sensitive', async () => {
const doc = new InMemoryDocument(testFileName, `# fOo\n`);
const provider = await createToc(doc);
assert.strictEqual((provider.lookup('fOo'))!.line, 0);
assert.strictEqual((provider.lookup('foo'))!.line, 0);
assert.strictEqual((provider.lookup('FOO'))!.line, 0);
});
test('Lookups should ignore leading and trailing white-space, and collapse internal whitespace', async () => {
const doc = new InMemoryDocument(testFileName, `# f o o \n`);
const provider = await createToc(doc);
assert.strictEqual((provider.lookup('f o o'))!.line, 0);
assert.strictEqual((provider.lookup(' f o o'))!.line, 0);
assert.strictEqual((provider.lookup(' f o o '))!.line, 0);
assert.strictEqual((provider.lookup('f o o'))!.line, 0);
assert.strictEqual((provider.lookup('f o o'))!.line, 0);
assert.strictEqual(provider.lookup('f'), undefined);
assert.strictEqual(provider.lookup('foo'), undefined);
assert.strictEqual(provider.lookup('fo o'), undefined);
});
test('should handle special characters #44779', async () => {
const doc = new InMemoryDocument(testFileName, `# Indentação\n`);
const provider = await createToc(doc);
assert.strictEqual((provider.lookup('indentação'))!.line, 0);
});
test('should handle special characters 2, #48482', async () => {
const doc = new InMemoryDocument(testFileName, `# Инструкция - Делай Раз, Делай Два\n`);
const provider = await createToc(doc);
assert.strictEqual((provider.lookup('инструкция---делай-раз-делай-два'))!.line, 0);
});
test('should handle special characters 3, #37079', async () => {
const doc = new InMemoryDocument(testFileName, `## Header 2
### Header 3
## Заголовок 2
### Заголовок 3
### Заголовок Header 3
## Заголовок`);
const provider = await createToc(doc);
assert.strictEqual((provider.lookup('header-2'))!.line, 0);
assert.strictEqual((provider.lookup('header-3'))!.line, 1);
assert.strictEqual((provider.lookup('Заголовок-2'))!.line, 2);
assert.strictEqual((provider.lookup('Заголовок-3'))!.line, 3);
assert.strictEqual((provider.lookup('Заголовок-header-3'))!.line, 4);
assert.strictEqual((provider.lookup('Заголовок'))!.line, 5);
});
test('Lookup should support suffixes for repeated headers', async () => {
const doc = new InMemoryDocument(testFileName, `# a\n# a\n## a`);
const provider = await createToc(doc);
{
const entry = provider.lookup('a');
assert.ok(entry);
assert.strictEqual(entry!.line, 0);
}
{
const entry = provider.lookup('a-1');
assert.ok(entry);
assert.strictEqual(entry!.line, 1);
}
{
const entry = provider.lookup('a-2');
assert.ok(entry);
assert.strictEqual(entry!.line, 2);
}
});
});

View file

@ -1,89 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { MdTableOfContentsProvider, TableOfContents } from '../tableOfContents';
import { ITextDocument } from '../types/textDocument';
import { IMdWorkspace } from '../workspace';
import { equals } from './arrays';
import { Delayer } from './async';
import { Disposable } from './dispose';
import { ResourceMap } from './resourceMap';
/**
* Check if the items in a table of contents have changed.
*
* This only checks for changes in the entries themselves, not for any changes in their locations.
*/
function hasTableOfContentsChanged(a: TableOfContents, b: TableOfContents): boolean {
const aSlugs = a.entries.map(entry => entry.slug.value).sort();
const bSlugs = b.entries.map(entry => entry.slug.value).sort();
return !equals(aSlugs, bSlugs);
}
export class MdTableOfContentsWatcher extends Disposable {
private readonly _files = new ResourceMap<{
readonly toc: TableOfContents;
}>();
private readonly _pending = new ResourceMap<void>();
private readonly _onTocChanged = this._register(new vscode.EventEmitter<{ readonly uri: vscode.Uri }>);
public readonly onTocChanged = this._onTocChanged.event;
private readonly delayer: Delayer<void>;
public constructor(
private readonly workspace: IMdWorkspace,
private readonly tocProvider: MdTableOfContentsProvider,
private readonly delay: number,
) {
super();
this.delayer = this._register(new Delayer<void>(delay));
this._register(this.workspace.onDidChangeMarkdownDocument(this.onDidChangeDocument, this));
this._register(this.workspace.onDidCreateMarkdownDocument(this.onDidCreateDocument, this));
this._register(this.workspace.onDidDeleteMarkdownDocument(this.onDidDeleteDocument, this));
}
private async onDidCreateDocument(document: ITextDocument) {
const toc = await this.tocProvider.getForDocument(document);
this._files.set(document.uri, { toc });
}
private async onDidChangeDocument(document: ITextDocument) {
if (this.delay > 0) {
this._pending.set(document.uri);
this.delayer.trigger(() => this.flushPending());
} else {
this.updateForResource(document.uri);
}
}
private onDidDeleteDocument(resource: vscode.Uri) {
this._files.delete(resource);
this._pending.delete(resource);
}
private async flushPending() {
const pending = [...this._pending.keys()];
this._pending.clear();
return Promise.all(pending.map(resource => this.updateForResource(resource)));
}
private async updateForResource(resource: vscode.Uri) {
const existing = this._files.get(resource);
const newToc = await this.tocProvider.get(resource);
if (!existing || hasTableOfContentsChanged(existing.toc, newToc)) {
this._onTocChanged.fire({ uri: resource });
}
this._files.set(resource, { toc: newToc });
}
}

View file

@ -114,74 +114,3 @@ export class MdDocumentInfoCache<T> extends Disposable {
this._cache.delete(resource);
}
}
/**
* Cache of information across all markdown files in the workspace.
*
* Unlike {@link MdDocumentInfoCache}, the entries here are computed eagerly for every file in the workspace.
* However the computation of the values is still lazy.
*/
export class MdWorkspaceInfoCache<T> extends Disposable {
private readonly _cache = new LazyResourceMap<T>();
private _init?: Promise<void>;
public constructor(
private readonly workspace: IMdWorkspace,
private readonly getValue: (document: ITextDocument) => Promise<T>,
) {
super();
}
public async entries(): Promise<Array<[vscode.Uri, T]>> {
await this.ensureInit();
return this._cache.entries();
}
public async values(): Promise<Array<T>> {
await this.ensureInit();
return Array.from(await this._cache.entries(), x => x[1]);
}
public async getForDocs(docs: readonly ITextDocument[]): Promise<T[]> {
for (const doc of docs) {
if (!this._cache.has(doc.uri)) {
this.update(doc);
}
}
return Promise.all(docs.map(doc => this._cache.get(doc.uri) as Promise<T>));
}
private async ensureInit(): Promise<void> {
if (!this._init) {
this._init = this.populateCache();
this._register(this.workspace.onDidChangeMarkdownDocument(this.onDidChangeDocument, this));
this._register(this.workspace.onDidCreateMarkdownDocument(this.onDidChangeDocument, this));
this._register(this.workspace.onDidDeleteMarkdownDocument(this.onDidDeleteDocument, this));
}
await this._init;
}
private async populateCache(): Promise<void> {
const markdownDocumentUris = await this.workspace.getAllMarkdownDocuments();
for (const document of markdownDocumentUris) {
if (!this._cache.has(document.uri)) {
this.update(document);
}
}
}
private update(document: ITextDocument): void {
this._cache.set(document.uri, lazy(() => this.getValue(document)));
}
private onDidChangeDocument(document: ITextDocument) {
this.update(document);
}
private onDidDeleteDocument(resource: vscode.Uri) {
this._cache.delete(resource);
}
}

View file

@ -237,21 +237,42 @@ vscode-languageserver-textdocument@^1.0.4:
resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.4.tgz#3cd56dd14cec1d09e86c4bb04b09a246cb3df157"
integrity sha512-/xhqXP/2A2RSs+J8JNXpiiNVvvNM0oTosNVmQnunlKvq9o4mupHOBAnnzH0lwIPKazXKvAKsVp1kr+H/K4lgoQ==
vscode-languageserver-textdocument@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.5.tgz#838769940ece626176ec5d5a2aa2d0aa69f5095c"
integrity sha512-1ah7zyQjKBudnMiHbZmxz5bYNM9KKZYz+5VQLj+yr8l+9w3g+WAhCkUkWbhMEdC5u0ub4Ndiye/fDyS8ghIKQg==
vscode-languageserver-types@3.17.1:
version "3.17.1"
resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.1.tgz#c2d87fa7784f8cac389deb3ff1e2d9a7bef07e16"
integrity sha512-K3HqVRPElLZVVPtMeKlsyL9aK0GxGQpvtAUTfX4k7+iJ4mc1M+JM+zQwkgGy2LzY0f0IAafe8MKqIkJrxfGGjQ==
vscode-languageserver-types@^3.17.2:
vscode-languageserver-types@^3.17.1, vscode-languageserver-types@^3.17.2:
version "3.17.2"
resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.2.tgz#b2c2e7de405ad3d73a883e91989b850170ffc4f2"
integrity sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA==
vscode-markdown-languageservice@^0.0.0-alpha.10:
version "0.0.0-alpha.10"
resolved "https://registry.yarnpkg.com/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.0.0-alpha.10.tgz#53b69c981eed7fd5efa155ab8c0f169995568681"
integrity sha512-rJ85nJ+d45yCz9lBhipavoWXz/vW5FknqqUpLqhe3/2xkrhxt8zcekhSoDepgkKFcTORAFV6g1SnnqxbVhX+uA==
dependencies:
picomatch "^2.3.1"
vscode-languageserver-textdocument "^1.0.5"
vscode-languageserver-types "^3.17.1"
vscode-nls "^5.0.1"
vscode-uri "^3.0.3"
vscode-nls@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-5.0.0.tgz#99f0da0bd9ea7cda44e565a74c54b1f2bc257840"
integrity sha512-u0Lw+IYlgbEJFF6/qAqG2d1jQmJl0eyAGJHoAJqr2HT4M2BNuQYSEiSE75f52pXHSJm8AlTjnLLbBFPrdz2hpA==
vscode-nls@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-5.0.1.tgz#ba23fc4d4420d25e7f886c8e83cbdcec47aa48b2"
integrity sha512-hHQV6iig+M21lTdItKPkJAaWrxALQb/nqpVffakO4knJOh3DrU2SXOMzUzNgo1eADPzu3qSsJY1weCzvR52q9A==
vscode-uri@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.3.tgz#a95c1ce2e6f41b7549f86279d19f47951e4f4d84"