mirror of
https://github.com/Microsoft/vscode
synced 2024-10-02 17:32:41 +00:00
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:
parent
db4ba2062d
commit
32f5e49082
|
@ -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",
|
||||
|
|
|
@ -6,9 +6,11 @@
|
|||
"name": "Attach",
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"port": 7692,
|
||||
"port": 7997,
|
||||
"sourceMaps": true,
|
||||
"outFiles": ["${workspaceFolder}/out/**/*.js"]
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/out/**/*.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
2
extensions/markdown-language-features/server/.vscode/settings.json
vendored
Normal file
2
extensions/markdown-language-features/server/.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
{
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
|
|
|
@ -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), []);
|
||||
}));
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue