diff --git a/.vscode/launch.json b/.vscode/launch.json index 6529986a..b629cb2f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,6 @@ { "version": "0.2.0", "configurations": [ - { "name": "Run VS Code Extension", "type": "extensionHost", @@ -24,6 +23,6 @@ "port": 6009, "restart": true, "outFiles": ["${workspaceRoot}/out/**/*.js"] - }, + } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index f5aa9faf..6bd3fa3a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,9 @@ "editor.formatOnSave": true }, "typescript.tsdk": "node_modules/typescript/lib", + "editor.detectIndentation": false, "editor.tabSize": 2, + "editor.insertSpaces": true, "files.insertFinalNewline": true, "files.trimFinalNewlines": true, "files.trimTrailingWhitespace": true diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 00000000..45e560c3 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,12 @@ +{ + "languages": { + "TypeScript": { + "tab_size": 2, + "hard_tabs": false, + "ensure_final_newline_on_save": true, + "remove_trailing_whitespace_on_save": true, + "format_on_save": "on", + "formatter": "language_server" + } + } +} diff --git a/apps/lsp/src/config.ts b/apps/lsp/src/config.ts index 4d6e5ef3..a51cf1a2 100644 --- a/apps/lsp/src/config.ts +++ b/apps/lsp/src/config.ts @@ -18,233 +18,232 @@ import { Connection, DidChangeConfigurationNotification, Emitter } from 'vscode- import { Disposable } from 'core'; import { MathjaxSupportedExtension } from 'editor-types'; -import { - DiagnosticLevel, - DiagnosticOptions, - IncludeWorkspaceHeaderCompletions, - LsConfiguration, - defaultLsConfiguration, - PreferredMdPathExtensionStyle +import { + DiagnosticLevel, + DiagnosticOptions, + IncludeWorkspaceHeaderCompletions, + LsConfiguration, + defaultLsConfiguration, + PreferredMdPathExtensionStyle } from './service'; export type ValidateEnabled = 'ignore' | 'warning' | 'error' | 'hint'; export interface Settings { - readonly workbench: { - readonly colorTheme: string; - }; - readonly quarto: { - readonly path: string; - readonly mathjax: { - readonly scale: number; - readonly extensions: MathjaxSupportedExtension[]; - } - }; - readonly markdown: { - readonly server: { - readonly log: 'off' | 'debug' | 'trace'; - }; - - readonly preferredMdPathExtensionStyle: 'auto' | 'includeExtension' | 'removeExtension'; - - readonly suggest: { - readonly paths: { - readonly enabled: boolean; - readonly includeWorkspaceHeaderCompletions: 'never' | 'onSingleOrDoubleHash' | 'onDoubleHash'; - }; - }; - - readonly validate: { - readonly enabled: boolean; - readonly referenceLinks: { - readonly enabled: ValidateEnabled; - }; - readonly fragmentLinks: { - readonly enabled: ValidateEnabled; - }; - readonly fileLinks: { - readonly enabled: ValidateEnabled; - readonly markdownFragmentLinks: ValidateEnabled | 'inherit'; - }; - readonly ignoredLinks: readonly string[]; - readonly unusedLinkDefinitions: { - readonly enabled: ValidateEnabled; - }; - readonly duplicateLinkDefinitions: { - readonly enabled: ValidateEnabled; - }; - }; - }; + readonly workbench: { + readonly colorTheme: string; + }; + readonly quarto: { + readonly path: string; + readonly mathjax: { + readonly scale: number; + readonly extensions: MathjaxSupportedExtension[]; + } + }; + readonly markdown: { + readonly server: { + readonly log: 'off' | 'debug' | 'trace'; + }; + + readonly preferredMdPathExtensionStyle: 'auto' | 'includeExtension' | 'removeExtension'; + + readonly suggest: { + readonly paths: { + readonly enabled: boolean; + readonly includeWorkspaceHeaderCompletions: 'never' | 'onSingleOrDoubleHash' | 'onDoubleHash'; + }; + }; + + readonly validate: { + readonly enabled: boolean; + readonly referenceLinks: { + readonly enabled: ValidateEnabled; + }; + readonly fragmentLinks: { + readonly enabled: ValidateEnabled; + }; + readonly fileLinks: { + readonly enabled: ValidateEnabled; + readonly markdownFragmentLinks: ValidateEnabled | 'inherit'; + }; + readonly ignoredLinks: readonly string[]; + readonly unusedLinkDefinitions: { + readonly enabled: ValidateEnabled; + }; + readonly duplicateLinkDefinitions: { + readonly enabled: ValidateEnabled; + }; + }; + }; } -function defaultSettings() : Settings { - return { - workbench: { - colorTheme: 'Dark+' - }, - quarto: { - path: "", - mathjax: { - scale: 1, - extensions: [] - } - }, - markdown: { - server: { - log: 'off' - }, - preferredMdPathExtensionStyle: 'auto', - suggest: { - paths: { - enabled: true, - includeWorkspaceHeaderCompletions: 'never' - } - }, - validate: { - enabled: kDefaultDiagnosticOptions.enabled, - referenceLinks: { - enabled: kDefaultDiagnosticOptions.validateReferences!, - }, - fragmentLinks: { - enabled: kDefaultDiagnosticOptions.validateFragmentLinks!, - }, - fileLinks: { - enabled: kDefaultDiagnosticOptions.validateFileLinks!, - markdownFragmentLinks: 'inherit', - }, - ignoredLinks: [], - unusedLinkDefinitions: { - enabled: kDefaultDiagnosticOptions.validateUnusedLinkDefinitions!, - }, - duplicateLinkDefinitions: { - enabled: kDefaultDiagnosticOptions.validateDuplicateLinkDefinitions! - } - } - } - } +function defaultSettings(): Settings { + return { + workbench: { + colorTheme: 'Dark+' + }, + quarto: { + path: "", + mathjax: { + scale: 1, + extensions: [] + } + }, + markdown: { + server: { + log: 'off' + }, + preferredMdPathExtensionStyle: 'auto', + suggest: { + paths: { + enabled: true, + includeWorkspaceHeaderCompletions: 'never' + } + }, + validate: { + enabled: kDefaultDiagnosticOptions.enabled, + referenceLinks: { + enabled: kDefaultDiagnosticOptions.validateReferences!, + }, + fragmentLinks: { + enabled: kDefaultDiagnosticOptions.validateFragmentLinks!, + }, + fileLinks: { + enabled: kDefaultDiagnosticOptions.validateFileLinks!, + markdownFragmentLinks: 'inherit', + }, + ignoredLinks: [], + unusedLinkDefinitions: { + enabled: kDefaultDiagnosticOptions.validateUnusedLinkDefinitions!, + }, + duplicateLinkDefinitions: { + enabled: kDefaultDiagnosticOptions.validateDuplicateLinkDefinitions! + } + } + } + } } export class ConfigurationManager extends Disposable { - private readonly _onDidChangeConfiguration = this._register(new Emitter()); - public readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; - - private _settings: Settings; - - constructor(private readonly connection_: Connection) { - super(); - this._settings = defaultSettings(); - } - - public async update() { - const settings = await this.connection_.workspace.getConfiguration(); - - this._settings = { - ...defaultSettings(), - workbench: { - colorTheme: settings.workbench.colorTheme - }, - quarto: { - path: settings.quarto.path, - mathjax: { - scale: settings.quarto.mathjax.scale, - extensions: settings.quarto.mathjax.extensions - } - } - }; - this._onDidChangeConfiguration.fire(this._settings); - } - - public async subscribe() { - await this.update(); - await this.connection_.client.register( - DidChangeConfigurationNotification.type, - undefined - ); - this.connection_.onDidChangeConfiguration(() => { - this.update(); - }); - } - - public getSettings(): Settings { - return this._settings; - } + private readonly _onDidChangeConfiguration = this._register(new Emitter()); + public readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; + + private _settings: Settings; + + constructor(private readonly connection_: Connection) { + super(); + this._settings = defaultSettings(); + } + + public async update() { + const settings = await this.connection_.workspace.getConfiguration(); + + this._settings = { + ...defaultSettings(), + workbench: { + colorTheme: settings.workbench.colorTheme + }, + quarto: { + path: settings.quarto.path, + mathjax: { + scale: settings.quarto.mathjax.scale, + extensions: settings.quarto.mathjax.extensions + } + } + }; + this._onDidChangeConfiguration.fire(this._settings); + } + + public async subscribe() { + await this.update(); + await this.connection_.client.register( + DidChangeConfigurationNotification.type, + undefined + ); + this.connection_.onDidChangeConfiguration(() => { + this.update(); + }); + } + + public getSettings(): Settings { + return this._settings; + } } -export function lsConfiguration(configManager: ConfigurationManager) : LsConfiguration { - const config = defaultLsConfiguration(); - return { - ...config, - get preferredMdPathExtensionStyle() { - switch (configManager.getSettings().markdown.preferredMdPathExtensionStyle) { - case 'includeExtension': return PreferredMdPathExtensionStyle.includeExtension; - case 'removeExtension': return PreferredMdPathExtensionStyle.removeExtension; - case 'auto': - default: - return PreferredMdPathExtensionStyle.auto; - } - }, - get includeWorkspaceHeaderCompletions() : IncludeWorkspaceHeaderCompletions { - switch (configManager.getSettings().markdown.suggest.paths.includeWorkspaceHeaderCompletions || config.includeWorkspaceHeaderCompletions) { - case 'onSingleOrDoubleHash': return IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash; - case 'onDoubleHash': return IncludeWorkspaceHeaderCompletions.onDoubleHash; - case 'never': - default: return IncludeWorkspaceHeaderCompletions.never; - } - }, - get colorTheme(): "light" | "dark" { - const settings = configManager.getSettings(); - return settings.workbench.colorTheme.includes("Light") ? "light" : "dark"; - }, - get mathjaxScale(): number { - return configManager.getSettings().quarto.mathjax.scale; - }, - get mathjaxExtensions(): readonly MathjaxSupportedExtension[] { - return configManager.getSettings().quarto.mathjax.extensions; - } - } +export function lsConfiguration(configManager: ConfigurationManager): LsConfiguration { + const config = defaultLsConfiguration(); + return { + ...config, + get preferredMdPathExtensionStyle() { + switch (configManager.getSettings().markdown.preferredMdPathExtensionStyle) { + case 'includeExtension': return PreferredMdPathExtensionStyle.includeExtension; + case 'removeExtension': return PreferredMdPathExtensionStyle.removeExtension; + case 'auto': + default: + return PreferredMdPathExtensionStyle.auto; + } + }, + get includeWorkspaceHeaderCompletions(): IncludeWorkspaceHeaderCompletions { + switch (configManager.getSettings().markdown.suggest.paths.includeWorkspaceHeaderCompletions || config.includeWorkspaceHeaderCompletions) { + case 'onSingleOrDoubleHash': return IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash; + case 'onDoubleHash': return IncludeWorkspaceHeaderCompletions.onDoubleHash; + case 'never': + default: return IncludeWorkspaceHeaderCompletions.never; + } + }, + get colorTheme(): "light" | "dark" { + const settings = configManager.getSettings(); + return settings.workbench.colorTheme.includes("Light") ? "light" : "dark"; + }, + get mathjaxScale(): number { + return configManager.getSettings().quarto.mathjax.scale; + }, + get mathjaxExtensions(): readonly MathjaxSupportedExtension[] { + return configManager.getSettings().quarto.mathjax.extensions; + } + } } export function getDiagnosticsOptions(configManager: ConfigurationManager): DiagnosticOptions { - const settings = configManager.getSettings(); - if (!settings) { - return kDefaultDiagnosticOptions; - } - - const validateFragmentLinks = convertDiagnosticLevel(settings.markdown.validate.fragmentLinks.enabled); - return { - enabled: settings.markdown.validate.enabled, - validateFileLinks: convertDiagnosticLevel(settings.markdown.validate.fileLinks.enabled), - validateReferences: convertDiagnosticLevel(settings.markdown.validate.referenceLinks.enabled), - validateFragmentLinks: convertDiagnosticLevel(settings.markdown.validate.fragmentLinks.enabled), - validateMarkdownFileLinkFragments: settings.markdown.validate.fileLinks.markdownFragmentLinks === 'inherit' ? validateFragmentLinks : convertDiagnosticLevel(settings.markdown.validate.fileLinks.markdownFragmentLinks), - validateUnusedLinkDefinitions: convertDiagnosticLevel(settings.markdown.validate.unusedLinkDefinitions.enabled), - validateDuplicateLinkDefinitions: convertDiagnosticLevel(settings.markdown.validate.duplicateLinkDefinitions.enabled), - ignoreLinks: settings.markdown.validate.ignoredLinks, - }; + const settings = configManager.getSettings(); + if (!settings) { + return kDefaultDiagnosticOptions; + } + + const validateFragmentLinks = convertDiagnosticLevel(settings.markdown.validate.fragmentLinks.enabled); + return { + enabled: settings.markdown.validate.enabled, + validateFileLinks: convertDiagnosticLevel(settings.markdown.validate.fileLinks.enabled), + validateReferences: convertDiagnosticLevel(settings.markdown.validate.referenceLinks.enabled), + validateFragmentLinks: convertDiagnosticLevel(settings.markdown.validate.fragmentLinks.enabled), + validateMarkdownFileLinkFragments: settings.markdown.validate.fileLinks.markdownFragmentLinks === 'inherit' ? validateFragmentLinks : convertDiagnosticLevel(settings.markdown.validate.fileLinks.markdownFragmentLinks), + validateUnusedLinkDefinitions: convertDiagnosticLevel(settings.markdown.validate.unusedLinkDefinitions.enabled), + validateDuplicateLinkDefinitions: convertDiagnosticLevel(settings.markdown.validate.duplicateLinkDefinitions.enabled), + ignoreLinks: settings.markdown.validate.ignoredLinks, + }; } export const kDefaultDiagnosticOptions: DiagnosticOptions = { - enabled: false, - validateFileLinks: DiagnosticLevel.ignore, - validateReferences: DiagnosticLevel.ignore, - validateFragmentLinks: DiagnosticLevel.ignore, - validateMarkdownFileLinkFragments: DiagnosticLevel.ignore, - validateUnusedLinkDefinitions: DiagnosticLevel.ignore, - validateDuplicateLinkDefinitions: DiagnosticLevel.ignore, - ignoreLinks: [], + enabled: false, + validateFileLinks: DiagnosticLevel.ignore, + validateReferences: DiagnosticLevel.ignore, + validateFragmentLinks: DiagnosticLevel.ignore, + validateMarkdownFileLinkFragments: DiagnosticLevel.ignore, + validateUnusedLinkDefinitions: DiagnosticLevel.ignore, + validateDuplicateLinkDefinitions: DiagnosticLevel.ignore, + ignoreLinks: [], }; function convertDiagnosticLevel(enabled: ValidateEnabled): DiagnosticLevel | undefined { - switch (enabled) { - case 'error': return DiagnosticLevel.error; - case 'warning': return DiagnosticLevel.warning; - case 'ignore': return DiagnosticLevel.ignore; - case 'hint': return DiagnosticLevel.hint; - default: return DiagnosticLevel.ignore; - } + switch (enabled) { + case 'error': return DiagnosticLevel.error; + case 'warning': return DiagnosticLevel.warning; + case 'ignore': return DiagnosticLevel.ignore; + case 'hint': return DiagnosticLevel.hint; + default: return DiagnosticLevel.ignore; + } } - diff --git a/apps/lsp/src/custom.ts b/apps/lsp/src/custom.ts index a990d216..393b5d84 100644 --- a/apps/lsp/src/custom.ts +++ b/apps/lsp/src/custom.ts @@ -14,10 +14,10 @@ */ import path from "path"; -import { - defaultEditorServerOptions, - dictionaryServerMethods, - editorServerMethods, +import { + defaultEditorServerOptions, + dictionaryServerMethods, + editorServerMethods, mathServerMethods, EditorServerOptions, sourceServerMethods, @@ -34,14 +34,14 @@ import { yamlHover } from "./service/providers/hover/hover-yaml"; import { Quarto, codeEditorContext } from "./service/quarto"; export function registerCustomMethods( - quarto: Quarto, + quarto: Quarto, connection: LspConnection, documents: TextDocuments ) { const resourcesDir = path.join(__dirname, "resources"); - const options : EditorServerOptions = { + const options: EditorServerOptions = { ...defaultEditorServerOptions( quarto, resourcesDir, @@ -67,20 +67,20 @@ export function registerCustomMethods( } -async function codeViewAssist(quarto: Quarto, context: CodeViewCellContext) : Promise { - +async function codeViewAssist(quarto: Quarto, context: CodeViewCellContext): Promise { + const edContext = codeEditorContext( context.filepath, context.language == "yaml" ? "yaml" : "script", context.code.join("\n"), Position.create(context.selection.start.line, context.selection.start.character), false - ); + ); return await yamlHover(quarto, edContext) || undefined; } -async function codeViewCompletions(quarto: Quarto, context: CodeViewCompletionContext) : Promise { +async function codeViewCompletions(quarto: Quarto, context: CodeViewCompletionContext): Promise { // handle yaml completions within the lsp (the rest are currently handled in the vscode extension) const edContext = codeEditorContext( context.filepath, @@ -95,4 +95,4 @@ async function codeViewCompletions(quarto: Quarto, context: CodeViewCompletionCo isIncomplete: false, items: completions || [] } -} \ No newline at end of file +} diff --git a/apps/lsp/src/diagnostics.ts b/apps/lsp/src/diagnostics.ts index 03909755..4a9ce14d 100644 --- a/apps/lsp/src/diagnostics.ts +++ b/apps/lsp/src/diagnostics.ts @@ -56,7 +56,7 @@ export async function registerDiagnostics( const subs: Disposable[] = []; - + // baseline diagnostics sent on save (and cleared on change) const saveDiagnosticsSources: Array<(doc: Document) => Promise> = []; diff --git a/apps/lsp/src/index.ts b/apps/lsp/src/index.ts index b196b150..19cf7448 100644 --- a/apps/lsp/src/index.ts +++ b/apps/lsp/src/index.ts @@ -60,7 +60,7 @@ documents.listen(connection); const configManager = new ConfigurationManager(connection); const config = lsConfiguration(configManager); -// Capabilities +// Capabilities let capabilities: ClientCapabilities | undefined; // Initialization options @@ -84,7 +84,7 @@ connection.onInitialize((params: InitializeParams) => { return mdLs?.getCompletionItems(document, params.position, params.context, config, token) || []; }) - connection.onHover(async (params, token) : Promise => { + connection.onHover(async (params, token): Promise => { const document = documents.get(params.textDocument.uri); if (!document) { return null; @@ -151,7 +151,7 @@ connection.onInitialize((params: InitializeParams) => { // register no-op methods to enable client middleware middlewareRegister(connection); - + return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, @@ -181,7 +181,7 @@ connection.onInitialize((params: InitializeParams) => { }); // further config dependent initialization -connection.onInitialized(async () => { +connection.onInitialized(async () => { // sync config if possible if (capabilities?.workspace?.configuration) { @@ -193,23 +193,23 @@ connection.onInitialized(async () => { const workspaceDir = workspaceFolders?.length ? URI.parse(workspaceFolders[0].uri).fsPath : undefined; - + // if we were passed a quarto bin path then use that - let quartoBinPath : string | undefined; + let quartoBinPath: string | undefined; if (initializationOptions?.quartoBinPath) { quartoBinPath = path.join(initializationOptions?.quartoBinPath, isWindows() ? "quarto.exe" : "quarto"); } // initialize quarto const quartoContext = initQuartoContext( - quartoBinPath || configManager.getSettings().quarto.path, + quartoBinPath || configManager.getSettings().quarto.path, workspaceDir ); const quarto = await initializeQuarto(quartoContext); // initialize logger const logger = new LogFunctionLogger( - console.log.bind(console), + console.log.bind(console), configManager ); @@ -218,7 +218,7 @@ connection.onInitialized(async () => { workspaceFolders?.map(value => URI.parse(value.uri)) || [], documents, connection, - capabilities!, + capabilities!, config, logger ) @@ -232,7 +232,7 @@ connection.onInitialized(async () => { quarto, workspace, documents, - parser, + parser, logger }); @@ -246,7 +246,7 @@ connection.onInitialized(async () => { logger ); - // create lsp connection (jsonrpc bridge) + // create lsp connection (jsonrpc bridge) const lspConnection: LspConnection = { onRequest(method: string, handler: (params: unknown[]) => Promise) { return connection.onRequest(method, handler); @@ -262,5 +262,5 @@ connection.onInitialized(async () => { // ensure that the deno runtime won't exit b/c of the event queue being empty setInterval(() => { /* */ }, 1000); -// listen +// listen connection.listen(); diff --git a/apps/lsp/src/logging.ts b/apps/lsp/src/logging.ts index d959a352..7bc2bf86 100644 --- a/apps/lsp/src/logging.ts +++ b/apps/lsp/src/logging.ts @@ -1,99 +1,98 @@ -/* - * logging.ts - * - * Copyright (C) 2023 by Posit Software, PBC - * Copyright (c) Microsoft Corporation. All rights reserved. - * - * Unless you have received this program directly from Posit Software pursuant - * to the terms of a commercial license agreement with Posit Software, then - * this program is licensed to you under the terms of version 3 of the - * GNU Affero General Public License. This program is distributed WITHOUT - * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, - * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the - * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. - * - */ - -// based on: -// https://github.com/microsoft/vscode/blob/main/extensions/markdown-language-features/server/src/logging.ts - - -import { Disposable } from 'core'; - -import { ILogger, LogLevel } from "./service"; - -import { ConfigurationManager } from './config'; - -export class LogFunctionLogger extends Disposable implements ILogger { - - private static now(): string { - const now = new Date(); - return String(now.getUTCHours()).padStart(2, '0') - + ':' + String(now.getMinutes()).padStart(2, '0') - + ':' + String(now.getUTCSeconds()).padStart(2, '0') + '.' + String(now.getMilliseconds()).padStart(3, '0'); - } - - private static data2String(data: unknown): string { - if (data instanceof Error) { - if (typeof data.stack === 'string') { - return data.stack; - } - return data.message; - } - if (typeof data === 'string') { - return data; - } - return JSON.stringify(data, undefined, 2); - } - - private _logLevel: LogLevel; - - constructor( - private readonly _logFn: typeof console.log, - private readonly _config: ConfigurationManager, - ) { - super(); - - this._register(this._config.onDidChangeConfiguration(() => { - this._logLevel = LogFunctionLogger.readLogLevel(this._config); - })); - - this._logLevel = LogFunctionLogger.readLogLevel(this._config); - } - - private static readLogLevel(config: ConfigurationManager): LogLevel { - switch (config.getSettings().markdown.server.log) { - case 'trace': return LogLevel.Trace; - case 'debug': return LogLevel.Debug; - case 'off': - default: - return LogLevel.Off; - } - } - - get level(): LogLevel { return this._logLevel; } - - public log(level: LogLevel, message: string, data?: unknown): void { - if (this.level < level) { - return; - } - - this.appendLine(`[${this.toLevelLabel(level)} ${LogFunctionLogger.now()}] ${message}`); - if (data) { - this.appendLine(LogFunctionLogger.data2String(data)); - } - } - - private toLevelLabel(level: LogLevel): string { - switch (level) { - case LogLevel.Off: return 'Off'; - case LogLevel.Debug: return 'Debug'; - case LogLevel.Trace: return 'Trace'; - } - } - - private appendLine(value: string): void { - this._logFn(value); - } -} - +/* + * logging.ts + * + * Copyright (C) 2023 by Posit Software, PBC + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +// based on: +// https://github.com/microsoft/vscode/blob/main/extensions/markdown-language-features/server/src/logging.ts + + +import { Disposable } from 'core'; + +import { ILogger, LogLevel } from "./service"; + +import { ConfigurationManager } from './config'; + +export class LogFunctionLogger extends Disposable implements ILogger { + + private static now(): string { + const now = new Date(); + return String(now.getUTCHours()).padStart(2, '0') + + ':' + String(now.getMinutes()).padStart(2, '0') + + ':' + String(now.getUTCSeconds()).padStart(2, '0') + '.' + String(now.getMilliseconds()).padStart(3, '0'); + } + + private static data2String(data: unknown): string { + if (data instanceof Error) { + if (typeof data.stack === 'string') { + return data.stack; + } + return data.message; + } + if (typeof data === 'string') { + return data; + } + return JSON.stringify(data, undefined, 2); + } + + private _logLevel: LogLevel; + + constructor( + private readonly _logFn: typeof console.log, + private readonly _config: ConfigurationManager, + ) { + super(); + + this._register(this._config.onDidChangeConfiguration(() => { + this._logLevel = LogFunctionLogger.readLogLevel(this._config); + })); + + this._logLevel = LogFunctionLogger.readLogLevel(this._config); + } + + private static readLogLevel(config: ConfigurationManager): LogLevel { + switch (config.getSettings().markdown.server.log) { + case 'trace': return LogLevel.Trace; + case 'debug': return LogLevel.Debug; + case 'off': + default: + return LogLevel.Off; + } + } + + get level(): LogLevel { return this._logLevel; } + + public log(level: LogLevel, message: string, data?: unknown): void { + if (this.level < level) { + return; + } + + this.appendLine(`[${this.toLevelLabel(level)} ${LogFunctionLogger.now()}] ${message}`); + if (data) { + this.appendLine(LogFunctionLogger.data2String(data)); + } + } + + private toLevelLabel(level: LogLevel): string { + switch (level) { + case LogLevel.Off: return 'Off'; + case LogLevel.Debug: return 'Debug'; + case LogLevel.Trace: return 'Trace'; + } + } + + private appendLine(value: string): void { + this._logFn(value); + } +} diff --git a/apps/lsp/src/middleware.ts b/apps/lsp/src/middleware.ts index a74f4f25..f61088a8 100644 --- a/apps/lsp/src/middleware.ts +++ b/apps/lsp/src/middleware.ts @@ -18,7 +18,7 @@ import { Connection, ServerCapabilities } from "vscode-languageserver" // capabilities provided just so we can intercept them w/ middleware on the client -export function middlewareCapabilities() : ServerCapabilities { +export function middlewareCapabilities(): ServerCapabilities { return { signatureHelpProvider: { // assume for now that these cover all languages (we can introduce @@ -34,15 +34,15 @@ export function middlewareCapabilities() : ServerCapabilities { // methods provided just so we can intercept them w/ middleware on the client export function middlewareRegister(connection: Connection) { - + connection.onSignatureHelp(async () => { return null; }); - + connection.onDocumentFormatting(async () => { return null; }); - + connection.onDocumentRangeFormatting(async () => { return null; }); @@ -52,4 +52,3 @@ export function middlewareRegister(connection: Connection) { }); } - diff --git a/apps/lsp/src/quarto.ts b/apps/lsp/src/quarto.ts index e77f8143..12a05bf1 100644 --- a/apps/lsp/src/quarto.ts +++ b/apps/lsp/src/quarto.ts @@ -30,19 +30,19 @@ import { import { QuartoContext } from "quarto-core"; -import { +import { Quarto, - CompletionResult, - EditorContext, - HoverResult, + CompletionResult, + EditorContext, + HoverResult, LintItem, - AttrContext, - AttrToken, - kContextDiv, - kContextDivSimple + AttrContext, + AttrToken, + kContextDiv, + kContextDivSimple } from "./service/quarto"; -export async function initializeQuarto(context: QuartoContext) : Promise { +export async function initializeQuarto(context: QuartoContext): Promise { const quartoModule = await initializeQuartoYamlModule(context.resourcePath) as QuartoYamlModule; return { ...context, @@ -53,7 +53,7 @@ export async function initializeQuarto(context: QuartoContext) : Promise getYamlDiagnostics: quartoModule.getLint, getHover: quartoModule.getHover }; - + } interface Attr { diff --git a/apps/lsp/src/service/config.ts b/apps/lsp/src/service/config.ts index ceaeb539..3f3f2281 100644 --- a/apps/lsp/src/service/config.ts +++ b/apps/lsp/src/service/config.ts @@ -22,97 +22,97 @@ import { MathjaxSupportedExtension } from 'editor-types'; * Preferred style for file paths to {@link markdownFileExtensions markdown files}. */ export enum PreferredMdPathExtensionStyle { - /** - * Try to maintain the existing of the path. - */ - auto = 'auto', - - /** - * Include the file extension when possible. - */ - includeExtension = 'includeExtension', - - /** - * Drop the file extension when possible. - */ - removeExtension = 'removeExtension', + /** + * Try to maintain the existing of the path. + */ + auto = 'auto', + + /** + * Include the file extension when possible. + */ + includeExtension = 'includeExtension', + + /** + * Drop the file extension when possible. + */ + removeExtension = 'removeExtension', } export interface LsConfiguration { - /** - * List of file extensions should be considered markdown. - * - * These should not include the leading `.`. - * - * The first entry is treated as the default file extension. - */ - readonly markdownFileExtensions: readonly string[]; - - /** - * List of file extension for files that are linked to from markdown . - * - * These should not include the leading `.`. - * - * These are used to avoid duplicate checks when resolving links without - * a file extension. - */ - readonly knownLinkedToFileExtensions: readonly string[]; - - /** - * List of path globs that should be excluded from cross-file operations. - */ - readonly excludePaths: readonly string[]; - - /** - * Preferred style for file paths to {@link markdownFileExtensions markdown files}. - * - * This is used for paths added by the language service, such as for path completions and on file renames. - */ - readonly preferredMdPathExtensionStyle?: PreferredMdPathExtensionStyle; - - - readonly includeWorkspaceHeaderCompletions: 'never' | 'onSingleOrDoubleHash' | 'onDoubleHash'; - - readonly colorTheme: "light" | "dark"; - readonly mathjaxScale: number; - readonly mathjaxExtensions: readonly MathjaxSupportedExtension[]; + /** + * List of file extensions should be considered markdown. + * + * These should not include the leading `.`. + * + * The first entry is treated as the default file extension. + */ + readonly markdownFileExtensions: readonly string[]; + + /** + * List of file extension for files that are linked to from markdown . + * + * These should not include the leading `.`. + * + * These are used to avoid duplicate checks when resolving links without + * a file extension. + */ + readonly knownLinkedToFileExtensions: readonly string[]; + + /** + * List of path globs that should be excluded from cross-file operations. + */ + readonly excludePaths: readonly string[]; + + /** + * Preferred style for file paths to {@link markdownFileExtensions markdown files}. + * + * This is used for paths added by the language service, such as for path completions and on file renames. + */ + readonly preferredMdPathExtensionStyle?: PreferredMdPathExtensionStyle; + + + readonly includeWorkspaceHeaderCompletions: 'never' | 'onSingleOrDoubleHash' | 'onDoubleHash'; + + readonly colorTheme: "light" | "dark"; + readonly mathjaxScale: number; + readonly mathjaxExtensions: readonly MathjaxSupportedExtension[]; } export const defaultMarkdownFileExtension = 'qmd'; const defaultConfig: LsConfiguration = { - markdownFileExtensions: [defaultMarkdownFileExtension, 'md'], - knownLinkedToFileExtensions: [ - 'jpg', - 'jpeg', - 'png', - 'gif', - 'webp', - 'bmp', - 'tiff', - 'svg', - 'pdf' - ], - excludePaths: [ - '**/.*', - '**/node_modules/**', - "**/renv/**", - "**/packrat/**", - "**/rsconnect/**", - "**/venv/**", - "**/env/**" - ], - includeWorkspaceHeaderCompletions: 'never', - colorTheme: "light", - mathjaxScale: 1, - mathjaxExtensions: [] + markdownFileExtensions: [defaultMarkdownFileExtension, 'md'], + knownLinkedToFileExtensions: [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'bmp', + 'tiff', + 'svg', + 'pdf' + ], + excludePaths: [ + '**/.*', + '**/node_modules/**', + "**/renv/**", + "**/packrat/**", + "**/rsconnect/**", + "**/venv/**", + "**/env/**" + ], + includeWorkspaceHeaderCompletions: 'never', + colorTheme: "light", + mathjaxScale: 1, + mathjaxExtensions: [] }; export function defaultLsConfiguration(): LsConfiguration { - return defaultConfig; + return defaultConfig; } export function isExcludedPath(configuration: LsConfiguration, uri: URI): boolean { - return configuration.excludePaths.some(excludePath => picomatch.isMatch(uri.path, excludePath)); + return configuration.excludePaths.some(excludePath => picomatch.isMatch(uri.path, excludePath)); } diff --git a/apps/lsp/src/service/index.ts b/apps/lsp/src/service/index.ts index 2bb41e1f..d9520f6f 100644 --- a/apps/lsp/src/service/index.ts +++ b/apps/lsp/src/service/index.ts @@ -18,7 +18,7 @@ import type { CancellationToken, CompletionContext, TextDocuments } from 'vscode import * as lsp from 'vscode-languageserver-types'; import { URI } from 'vscode-uri'; import { Document, Parser } from "quarto-core" -import { LsConfiguration} from './config'; +import { LsConfiguration } from './config'; import { MdDefinitionProvider } from './providers/definitions'; import { DiagnosticComputer, DiagnosticOnSaveComputer, DiagnosticOptions, DiagnosticsManager, IPullDiagnosticsManager } from './providers/diagnostics'; import { MdDocumentHighlightProvider } from './providers/document-highlights'; @@ -40,9 +40,9 @@ export type { MdCompletionProvider } from './providers/completion/completion'; export type { LsConfiguration } from './config'; export { PreferredMdPathExtensionStyle, defaultLsConfiguration } from './config'; export type { DiagnosticOptions, IPullDiagnosticsManager } from './providers/diagnostics'; -export { DiagnosticCode, DiagnosticLevel} from './providers/diagnostics'; +export { DiagnosticCode, DiagnosticLevel } from './providers/diagnostics'; export type { ResolvedDocumentLinkTarget } from './providers/document-links'; -export type { ILogger } from './logging'; +export type { ILogger } from './logging'; export { LogLevel } from './logging'; export type { ISlugifier } from './slugify' export { Slug, pandocSlugifier } from './slugify'; @@ -53,201 +53,201 @@ export type { ContainingDocumentContext, FileStat, FileWatcherOptions, IFileSyst */ export interface IMdLanguageService { - /** - * Get all links of a markdown file. - * - * Note that you must invoke {@link IMdLanguageService.resolveDocumentLink} on each link before executing the link. - */ - getDocumentLinks(document: Document, token: CancellationToken): Promise; - - /** - * Resolves a link from {@link IMdLanguageService.getDocumentLinks}. - * - * This fills in the target on the link. - * - * @returns The resolved link or `undefined` if the passed in link should be used - */ - resolveDocumentLink(link: lsp.DocumentLink, token: CancellationToken): Promise; - - /** - * Try to resolve the resources that a link in a markdown file points to. - * - * @param linkText The original text of the link - * @param fromResource The resource that contains the link. - * - * @returns The resolved target or undefined if it could not be resolved. - */ - resolveLinkTarget(linkText: string, fromResource: URI, token: CancellationToken): Promise; - - /** - * Get the symbols of a markdown file. - * - * @returns The headers and optionally also the link definitions in the file - */ - getDocumentSymbols(document: Document, options: { readonly includeLinkDefinitions?: boolean }, token: CancellationToken): Promise; - - /** - * Get the folding ranges of a markdown file. - * - * This returns folding ranges for: - * - * - Header sections - * - Regions - * - List and other block element - */ - getFoldingRanges(document: Document, token: CancellationToken): Promise; - - /** - * Get the selection ranges of a markdown file. - */ - getSelectionRanges(document: Document, positions: lsp.Position[], token: CancellationToken): Promise; - - /** - * Get the symbols for all markdown files in the current workspace. - * - * Returns all headers in the workspace. - */ - getWorkspaceSymbols(query: string, token: CancellationToken): Promise; - - /** - * Get completions items at a given position in a markdown file. - */ - getCompletionItems(document: Document, position: lsp.Position, context: CompletionContext | undefined, config: LsConfiguration, token: CancellationToken): Promise; - - /** - * Get hover at a given position in a markdown file. - */ - getHover( + /** + * Get all links of a markdown file. + * + * Note that you must invoke {@link IMdLanguageService.resolveDocumentLink} on each link before executing the link. + */ + getDocumentLinks(document: Document, token: CancellationToken): Promise; + + /** + * Resolves a link from {@link IMdLanguageService.getDocumentLinks}. + * + * This fills in the target on the link. + * + * @returns The resolved link or `undefined` if the passed in link should be used + */ + resolveDocumentLink(link: lsp.DocumentLink, token: CancellationToken): Promise; + + /** + * Try to resolve the resources that a link in a markdown file points to. + * + * @param linkText The original text of the link + * @param fromResource The resource that contains the link. + * + * @returns The resolved target or undefined if it could not be resolved. + */ + resolveLinkTarget(linkText: string, fromResource: URI, token: CancellationToken): Promise; + + /** + * Get the symbols of a markdown file. + * + * @returns The headers and optionally also the link definitions in the file + */ + getDocumentSymbols(document: Document, options: { readonly includeLinkDefinitions?: boolean }, token: CancellationToken): Promise; + + /** + * Get the folding ranges of a markdown file. + * + * This returns folding ranges for: + * + * - Header sections + * - Regions + * - List and other block element + */ + getFoldingRanges(document: Document, token: CancellationToken): Promise; + + /** + * Get the selection ranges of a markdown file. + */ + getSelectionRanges(document: Document, positions: lsp.Position[], token: CancellationToken): Promise; + + /** + * Get the symbols for all markdown files in the current workspace. + * + * Returns all headers in the workspace. + */ + getWorkspaceSymbols(query: string, token: CancellationToken): Promise; + + /** + * Get completions items at a given position in a markdown file. + */ + getCompletionItems(document: Document, position: lsp.Position, context: CompletionContext | undefined, config: LsConfiguration, token: CancellationToken): Promise; + + /** + * Get hover at a given position in a markdown file. + */ + getHover( doc: Document, pos: lsp.Position, - config: LsConfiguration, - token: CancellationToken + config: LsConfiguration, + token: CancellationToken ): Promise; - /** - * Get the references to a symbol at the current location. - * - * Supports finding references to headers and links. - */ - getReferences(document: Document, position: lsp.Position, context: lsp.ReferenceContext, token: CancellationToken): Promise; - - /** - * Get the references to a given file. - */ - getFileReferences(resource: URI, token: CancellationToken): Promise; - - /** - * Get the definition of the symbol at the current location. - * - * Supports finding headers from fragments links or reference link definitions. - */ - getDefinition(document: Document, position: lsp.Position, token: CancellationToken): Promise; - - /** - * Get document highlights for a position in the document. - */ - getDocumentHighlights(document: Document, position: lsp.Position, token: CancellationToken): Promise; - - /** - * Compute save diagnostics for a given file - * - * Compute diagnostics that should be scanned for on save (and cleared on edit) - */ - computeOnSaveDiagnostics(doc: Document): Promise; - - /** - * Compute diagnostics for a given file. - * - * Note that this function is stateless and re-validates all links every time you make the request. Use {@link IMdLanguageService.createPullDiagnosticsManager} - * to more efficiently get diagnostics. - */ - computeDiagnostics(doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise; - - /** - * Create a stateful object that is more efficient at computing diagnostics across repeated calls and workspace changes. - * - * This requires a {@link IWorkspace workspace} that {@link IWorkspaceWithWatching supports file watching}. - * - * Note that you must dispose of the returned object once you are done using it. - */ - createPullDiagnosticsManager(): IPullDiagnosticsManager; - - /** - * Dispose of the language service, freeing any associated resources. - */ - dispose(): void; + /** + * Get the references to a symbol at the current location. + * + * Supports finding references to headers and links. + */ + getReferences(document: Document, position: lsp.Position, context: lsp.ReferenceContext, token: CancellationToken): Promise; + + /** + * Get the references to a given file. + */ + getFileReferences(resource: URI, token: CancellationToken): Promise; + + /** + * Get the definition of the symbol at the current location. + * + * Supports finding headers from fragments links or reference link definitions. + */ + getDefinition(document: Document, position: lsp.Position, token: CancellationToken): Promise; + + /** + * Get document highlights for a position in the document. + */ + getDocumentHighlights(document: Document, position: lsp.Position, token: CancellationToken): Promise; + + /** + * Compute save diagnostics for a given file + * + * Compute diagnostics that should be scanned for on save (and cleared on edit) + */ + computeOnSaveDiagnostics(doc: Document): Promise; + + /** + * Compute diagnostics for a given file. + * + * Note that this function is stateless and re-validates all links every time you make the request. Use {@link IMdLanguageService.createPullDiagnosticsManager} + * to more efficiently get diagnostics. + */ + computeDiagnostics(doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise; + + /** + * Create a stateful object that is more efficient at computing diagnostics across repeated calls and workspace changes. + * + * This requires a {@link IWorkspace workspace} that {@link IWorkspaceWithWatching supports file watching}. + * + * Note that you must dispose of the returned object once you are done using it. + */ + createPullDiagnosticsManager(): IPullDiagnosticsManager; + + /** + * Dispose of the language service, freeing any associated resources. + */ + dispose(): void; } /** * Initialization options for creating a new {@link IMdLanguageService}. */ export interface LanguageServiceInitialization { - readonly config: LsConfiguration; - readonly quarto: Quarto; - readonly workspace: IWorkspace; - readonly documents: TextDocuments, - readonly parser: Parser; - readonly logger: ILogger; + readonly config: LsConfiguration; + readonly quarto: Quarto; + readonly workspace: IWorkspace; + readonly documents: TextDocuments, + readonly parser: Parser; + readonly logger: ILogger; } /** * Create a new instance of the {@link IMdLanguageService language service}. */ export function createLanguageService(init: LanguageServiceInitialization): IMdLanguageService { - const config = init.config; - const logger = init.logger; - - const tocProvider = new MdTableOfContentsProvider(init.parser, init.workspace, logger); - const smartSelectProvider = new MdSelectionRangeProvider(init.parser, tocProvider, logger); - const foldingProvider = new MdFoldingProvider(init.parser, tocProvider, logger); - const linkProvider = new MdLinkProvider(config, init.parser, init.workspace, tocProvider, logger); - const completionProvider = new MdCompletionProvider(config, init.quarto, init.workspace, init.documents, init.parser, linkProvider, tocProvider); - const hoverProvider = new MdHoverProvider(init.workspace, init.quarto, init.parser); - const linkCache = createWorkspaceLinkCache(init.parser, init.workspace); - const referencesProvider = new MdReferencesProvider(config, init.parser, init.workspace, tocProvider, linkCache, logger); - const definitionsProvider = new MdDefinitionProvider(config, init.workspace, tocProvider, linkCache); - const diagnosticOnSaveComputer = new DiagnosticOnSaveComputer(init.quarto); - const diagnosticsComputer = new DiagnosticComputer(config, init.workspace, linkProvider, tocProvider, logger); - const docSymbolProvider = new MdDocumentSymbolProvider(tocProvider, linkProvider, logger); - const workspaceSymbolProvider = new MdWorkspaceSymbolProvider(init.workspace, docSymbolProvider); - const documentHighlightProvider = new MdDocumentHighlightProvider(config, tocProvider, linkProvider); - - return Object.freeze({ - dispose: () => { - linkCache.dispose(); - tocProvider.dispose(); - workspaceSymbolProvider.dispose(); - linkProvider.dispose(); - referencesProvider.dispose(); - }, - getDocumentLinks: linkProvider.provideDocumentLinks.bind(linkProvider), - resolveDocumentLink: linkProvider.resolveDocumentLink.bind(linkProvider), - resolveLinkTarget: linkProvider.resolveLinkTarget.bind(linkProvider), - getDocumentSymbols: docSymbolProvider.provideDocumentSymbols.bind(docSymbolProvider), - getFoldingRanges: foldingProvider.provideFoldingRanges.bind(foldingProvider), - getSelectionRanges: smartSelectProvider.provideSelectionRanges.bind(smartSelectProvider), - getWorkspaceSymbols: workspaceSymbolProvider.provideWorkspaceSymbols.bind(workspaceSymbolProvider), - getCompletionItems: completionProvider.provideCompletionItems.bind(completionProvider), - getHover: hoverProvider.provideHover.bind(hoverProvider), - getReferences: referencesProvider.provideReferences.bind(referencesProvider), - getFileReferences: async (resource: URI, token: CancellationToken): Promise => { - return (await referencesProvider.getReferencesToFileInWorkspace(resource, token)).map(x => x.location); - }, - getDefinition: definitionsProvider.provideDefinition.bind(definitionsProvider), - getDocumentHighlights: (document: Document, position: lsp.Position, token: CancellationToken): Promise => { - return documentHighlightProvider.getDocumentHighlights(document, position, token); - }, - computeOnSaveDiagnostics: async (doc: Document) => { - return (await diagnosticOnSaveComputer.compute(doc)) - }, - computeDiagnostics: async (doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise => { - return (await diagnosticsComputer.compute(doc, options, token))?.diagnostics; - }, - createPullDiagnosticsManager: () => { - if (!isWorkspaceWithFileWatching(init.workspace)) { - throw new Error(`Workspace does not support file watching. Diagnostics manager not supported`); - } - return new DiagnosticsManager(config, init.workspace, linkProvider, tocProvider, logger); - } - }); + const config = init.config; + const logger = init.logger; + + const tocProvider = new MdTableOfContentsProvider(init.parser, init.workspace, logger); + const smartSelectProvider = new MdSelectionRangeProvider(init.parser, tocProvider, logger); + const foldingProvider = new MdFoldingProvider(init.parser, tocProvider, logger); + const linkProvider = new MdLinkProvider(config, init.parser, init.workspace, tocProvider, logger); + const completionProvider = new MdCompletionProvider(config, init.quarto, init.workspace, init.documents, init.parser, linkProvider, tocProvider); + const hoverProvider = new MdHoverProvider(init.workspace, init.quarto, init.parser); + const linkCache = createWorkspaceLinkCache(init.parser, init.workspace); + const referencesProvider = new MdReferencesProvider(config, init.parser, init.workspace, tocProvider, linkCache, logger); + const definitionsProvider = new MdDefinitionProvider(config, init.workspace, tocProvider, linkCache); + const diagnosticOnSaveComputer = new DiagnosticOnSaveComputer(init.quarto); + const diagnosticsComputer = new DiagnosticComputer(config, init.workspace, linkProvider, tocProvider, logger); + const docSymbolProvider = new MdDocumentSymbolProvider(tocProvider, linkProvider, logger); + const workspaceSymbolProvider = new MdWorkspaceSymbolProvider(init.workspace, docSymbolProvider); + const documentHighlightProvider = new MdDocumentHighlightProvider(config, tocProvider, linkProvider); + + return Object.freeze({ + dispose: () => { + linkCache.dispose(); + tocProvider.dispose(); + workspaceSymbolProvider.dispose(); + linkProvider.dispose(); + referencesProvider.dispose(); + }, + getDocumentLinks: linkProvider.provideDocumentLinks.bind(linkProvider), + resolveDocumentLink: linkProvider.resolveDocumentLink.bind(linkProvider), + resolveLinkTarget: linkProvider.resolveLinkTarget.bind(linkProvider), + getDocumentSymbols: docSymbolProvider.provideDocumentSymbols.bind(docSymbolProvider), + getFoldingRanges: foldingProvider.provideFoldingRanges.bind(foldingProvider), + getSelectionRanges: smartSelectProvider.provideSelectionRanges.bind(smartSelectProvider), + getWorkspaceSymbols: workspaceSymbolProvider.provideWorkspaceSymbols.bind(workspaceSymbolProvider), + getCompletionItems: completionProvider.provideCompletionItems.bind(completionProvider), + getHover: hoverProvider.provideHover.bind(hoverProvider), + getReferences: referencesProvider.provideReferences.bind(referencesProvider), + getFileReferences: async (resource: URI, token: CancellationToken): Promise => { + return (await referencesProvider.getReferencesToFileInWorkspace(resource, token)).map(x => x.location); + }, + getDefinition: definitionsProvider.provideDefinition.bind(definitionsProvider), + getDocumentHighlights: (document: Document, position: lsp.Position, token: CancellationToken): Promise => { + return documentHighlightProvider.getDocumentHighlights(document, position, token); + }, + computeOnSaveDiagnostics: async (doc: Document) => { + return (await diagnosticOnSaveComputer.compute(doc)) + }, + computeDiagnostics: async (doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise => { + return (await diagnosticsComputer.compute(doc, options, token))?.diagnostics; + }, + createPullDiagnosticsManager: () => { + if (!isWorkspaceWithFileWatching(init.workspace)) { + throw new Error(`Workspace does not support file watching. Diagnostics manager not supported`); + } + return new DiagnosticsManager(config, init.workspace, linkProvider, tocProvider, logger); + } + }); } diff --git a/apps/lsp/src/service/logging.ts b/apps/lsp/src/service/logging.ts index 0563e75d..a44b47ef 100644 --- a/apps/lsp/src/service/logging.ts +++ b/apps/lsp/src/service/logging.ts @@ -18,31 +18,31 @@ * The level of verbosity that the language service logs at. */ export enum LogLevel { - /** Disable logging */ - Off, + /** Disable logging */ + Off, - /** Log verbose info about language server operation, such as when references are re-computed for a md file. */ - Debug, + /** Log verbose info about language server operation, such as when references are re-computed for a md file. */ + Debug, - /** Log extremely verbose info about language server operation, such as calls into the file system */ - Trace, + /** Log extremely verbose info about language server operation, such as calls into the file system */ + Trace, } /** * Logs debug messages from the language service */ export interface ILogger { - /** - * Get the current log level. - */ - get level(): LogLevel; + /** + * Get the current log level. + */ + get level(): LogLevel; - /** - * Log a message at a given log level. - * - * @param level The level the message should be logged at. - * @param message The main text of the log. - * @param data Additional information about what is being logged. - */ - log(level: LogLevel, message: string, data?: Record): void; + /** + * Log a message at a given log level. + * + * @param level The level the message should be logged at. + * @param message The main text of the log. + * @param data Additional information about what is being logged. + */ + log(level: LogLevel, message: string, data?: Record): void; } diff --git a/apps/lsp/src/service/providers/completion/completion-attrs.ts b/apps/lsp/src/service/providers/completion/completion-attrs.ts index 6e5b5adc..f1a9dcc5 100644 --- a/apps/lsp/src/service/providers/completion/completion-attrs.ts +++ b/apps/lsp/src/service/providers/completion/completion-attrs.ts @@ -16,7 +16,7 @@ import { AttrContext, AttrToken, EditorContext, Quarto } from "../../quarto"; export async function attrCompletions(quarto: Quarto, context: EditorContext) { - + // validate trigger if (context.trigger && !["="].includes(context.trigger)) { return null; @@ -48,8 +48,8 @@ function blockCompletionToken(context: EditorContext): AttrToken | undefined { return type.indexOf(":") !== -1 ? "div" : type.indexOf("#") !== -1 - ? "heading" - : "codeblock"; + ? "heading" + : "codeblock"; }); } diff --git a/apps/lsp/src/service/providers/completion/completion-latex.ts b/apps/lsp/src/service/providers/completion/completion-latex.ts index b4fe9f73..e71a83e3 100644 --- a/apps/lsp/src/service/providers/completion/completion-latex.ts +++ b/apps/lsp/src/service/providers/completion/completion-latex.ts @@ -81,7 +81,7 @@ export async function latexCompletions( const text = line.slice(0, pos.character); const backslashPos = text.lastIndexOf("\\"); const spacePos = text.lastIndexOf(" "); - if (backslashPos !== -1 && backslashPos > spacePos && text[backslashPos-1] !== "\\") { + if (backslashPos !== -1 && backslashPos > spacePos && text[backslashPos - 1] !== "\\") { const loadedExtensions = mathjaxLoadedExtensions(config.mathjaxExtensions); const token = text.slice(backslashPos + 1); const completions: CompletionItem[] = Object.keys(kMathjaxCommands) @@ -129,4 +129,3 @@ export async function latexCompletions( return null; } - diff --git a/apps/lsp/src/service/providers/completion/completion-path.ts b/apps/lsp/src/service/providers/completion/completion-path.ts index dbc59535..6f7d6b7d 100644 --- a/apps/lsp/src/service/providers/completion/completion-path.ts +++ b/apps/lsp/src/service/providers/completion/completion-path.ts @@ -32,79 +32,79 @@ import { MdLinkProvider } from '../document-links'; import { IncludeWorkspaceHeaderCompletions, PathCompletionOptions } from './completion'; enum CompletionContextKind { - /** `[...](|)` */ - Link, + /** `[...](|)` */ + Link, - /** `[...][|]` */ - ReferenceLink, + /** `[...][|]` */ + ReferenceLink, - /** `[]: |` */ - LinkDefinition, + /** `[]: |` */ + LinkDefinition, } interface AnchorContext { - /** - * Link text before the `#`. - * - * For `[text](xy#z|abc)` this is `xy`. - */ - readonly beforeAnchor: string; - - /** - * Text of the anchor before the current position. - * - * For `[text](xy#z|abc)` this is `z`. - */ - readonly anchorPrefix: string; + /** + * Link text before the `#`. + * + * For `[text](xy#z|abc)` this is `xy`. + */ + readonly beforeAnchor: string; + + /** + * Text of the anchor before the current position. + * + * For `[text](xy#z|abc)` this is `z`. + */ + readonly anchorPrefix: string; } interface PathCompletionContext { - readonly kind: CompletionContextKind; - - /** - * Text of the link before the current position - * - * For `[text](xy#z|abc)` this is `xy#z`. - */ - readonly linkPrefix: string; - - /** - * Position of the start of the link. - * - * For `[text](xy#z|abc)` this is the position before `xy`. - */ - readonly linkTextStartPosition: lsp.Position; - - /** - * Text of the link after the current position. - * - * For `[text](xy#z|abc)` this is `abc`. - */ - readonly linkSuffix: string; - - /** - * Info if the link looks like it is for an anchor: `[](#header)` - */ - readonly anchorInfo?: AnchorContext; - - /** - * Indicates that the completion does not require encoding. - */ - readonly skipEncoding?: boolean; + readonly kind: CompletionContextKind; + + /** + * Text of the link before the current position + * + * For `[text](xy#z|abc)` this is `xy#z`. + */ + readonly linkPrefix: string; + + /** + * Position of the start of the link. + * + * For `[text](xy#z|abc)` this is the position before `xy`. + */ + readonly linkTextStartPosition: lsp.Position; + + /** + * Text of the link after the current position. + * + * For `[text](xy#z|abc)` this is `abc`. + */ + readonly linkSuffix: string; + + /** + * Info if the link looks like it is for an anchor: `[](#header)` + */ + readonly anchorInfo?: AnchorContext; + + /** + * Indicates that the completion does not require encoding. + */ + readonly skipEncoding?: boolean; } function tryDecodeUriComponent(str: string): string { - try { - return decodeURIComponent(str); - } catch { - return str; - } + try { + return decodeURIComponent(str); + } catch { + return str; + } } const sortTexts = Object.freeze({ - localHeader: '1', - workspaceHeader: '2', + localHeader: '1', + workspaceHeader: '2', }); /** @@ -112,385 +112,383 @@ const sortTexts = Object.freeze({ */ export class MdPathCompletionProvider { - readonly #configuration: LsConfiguration; - readonly #workspace: IWorkspace; - readonly #parser: Parser; - readonly #linkProvider: MdLinkProvider; - - readonly #workspaceTocCache: MdWorkspaceInfoCache; - - constructor( - configuration: LsConfiguration, - workspace: IWorkspace, - parser: Parser, - linkProvider: MdLinkProvider, - tocProvider: MdTableOfContentsProvider, - ) { - this.#configuration = configuration; - this.#workspace = workspace; - this.#parser = parser; - this.#linkProvider = linkProvider; - - this.#workspaceTocCache = new MdWorkspaceInfoCache(workspace, (doc) => tocProvider.getForDocument(doc)); - } - - public async provideCompletionItems(document: Document, position: lsp.Position, _context: CompletionContext, token: CancellationToken): Promise { - const pathContext = this.#getPathCompletionContext(document, position); - if (!pathContext) { - return []; - } - const pathOptions: PathCompletionOptions = { - includeWorkspaceHeaderCompletions: this.#configuration.includeWorkspaceHeaderCompletions as IncludeWorkspaceHeaderCompletions - } - - - const items: lsp.CompletionItem[] = []; - for await (const item of this.#provideCompletionItems(document, position, pathContext, pathOptions, token)) { - items.push(item); - } - return items.length > 0 ? items : null; - } - - async *#provideCompletionItems(document: Document, position: lsp.Position, context: PathCompletionContext, options: PathCompletionOptions, token: CancellationToken): AsyncIterable { - switch (context.kind) { - case CompletionContextKind.ReferenceLink: { - yield* this.#provideReferenceSuggestions(document, position, context, token); - return; - } - case CompletionContextKind.LinkDefinition: - case CompletionContextKind.Link: { - if ( - (context.linkPrefix.startsWith('#') && options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash) || - (context.linkPrefix.startsWith('##') && (options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onDoubleHash || options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash)) - ) { - const insertRange = makeRange(context.linkTextStartPosition, position); - yield* this.#provideWorkspaceHeaderSuggestions(document, position, context, insertRange, token); - return; - } - - const isAnchorInCurrentDoc = context.anchorInfo && context.anchorInfo.beforeAnchor.length === 0; - - // Add anchor #links in current doc - if (context.linkPrefix.length === 0 || isAnchorInCurrentDoc) { - const insertRange = makeRange(context.linkTextStartPosition, position); - yield* this.#provideHeaderSuggestions(document, position, context, insertRange, token); - } - - if (token.isCancellationRequested) { - return; - } - - if (!isAnchorInCurrentDoc) { - if (context.anchorInfo) { // Anchor to a different document - const rawUri = this.#resolveReference(document, context.anchorInfo.beforeAnchor); - if (rawUri) { - const otherDoc = await openLinkToMarkdownFile(this.#configuration, this.#workspace, rawUri); - if (token.isCancellationRequested) { - return; - } - - if (otherDoc) { - const anchorStartPosition = translatePosition(position, { characterDelta: -(context.anchorInfo.anchorPrefix.length + 1) }); - const range = makeRange(anchorStartPosition, position); - yield* this.#provideHeaderSuggestions(otherDoc, position, context, range, token); - } - } - } else { // Normal path suggestions - yield* this.#providePathSuggestions(document, position, context, token); - } - } - } - } - } - - /// [...](...| - readonly #linkStartPattern = new RegExp( - // text - r`\[` + + readonly #configuration: LsConfiguration; + readonly #workspace: IWorkspace; + readonly #parser: Parser; + readonly #linkProvider: MdLinkProvider; + + readonly #workspaceTocCache: MdWorkspaceInfoCache; + + constructor( + configuration: LsConfiguration, + workspace: IWorkspace, + parser: Parser, + linkProvider: MdLinkProvider, + tocProvider: MdTableOfContentsProvider, + ) { + this.#configuration = configuration; + this.#workspace = workspace; + this.#parser = parser; + this.#linkProvider = linkProvider; + + this.#workspaceTocCache = new MdWorkspaceInfoCache(workspace, (doc) => tocProvider.getForDocument(doc)); + } + + public async provideCompletionItems(document: Document, position: lsp.Position, _context: CompletionContext, token: CancellationToken): Promise { + const pathContext = this.#getPathCompletionContext(document, position); + if (!pathContext) { + return []; + } + const pathOptions: PathCompletionOptions = { + includeWorkspaceHeaderCompletions: this.#configuration.includeWorkspaceHeaderCompletions as IncludeWorkspaceHeaderCompletions + } + + + const items: lsp.CompletionItem[] = []; + for await (const item of this.#provideCompletionItems(document, position, pathContext, pathOptions, token)) { + items.push(item); + } + return items.length > 0 ? items : null; + } + + async *#provideCompletionItems(document: Document, position: lsp.Position, context: PathCompletionContext, options: PathCompletionOptions, token: CancellationToken): AsyncIterable { + switch (context.kind) { + case CompletionContextKind.ReferenceLink: { + yield* this.#provideReferenceSuggestions(document, position, context, token); + return; + } + case CompletionContextKind.LinkDefinition: + case CompletionContextKind.Link: { + if ( + (context.linkPrefix.startsWith('#') && options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash) || + (context.linkPrefix.startsWith('##') && (options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onDoubleHash || options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash)) + ) { + const insertRange = makeRange(context.linkTextStartPosition, position); + yield* this.#provideWorkspaceHeaderSuggestions(document, position, context, insertRange, token); + return; + } + + const isAnchorInCurrentDoc = context.anchorInfo && context.anchorInfo.beforeAnchor.length === 0; + + // Add anchor #links in current doc + if (context.linkPrefix.length === 0 || isAnchorInCurrentDoc) { + const insertRange = makeRange(context.linkTextStartPosition, position); + yield* this.#provideHeaderSuggestions(document, position, context, insertRange, token); + } + + if (token.isCancellationRequested) { + return; + } + + if (!isAnchorInCurrentDoc) { + if (context.anchorInfo) { // Anchor to a different document + const rawUri = this.#resolveReference(document, context.anchorInfo.beforeAnchor); + if (rawUri) { + const otherDoc = await openLinkToMarkdownFile(this.#configuration, this.#workspace, rawUri); + if (token.isCancellationRequested) { + return; + } + + if (otherDoc) { + const anchorStartPosition = translatePosition(position, { characterDelta: -(context.anchorInfo.anchorPrefix.length + 1) }); + const range = makeRange(anchorStartPosition, position); + yield* this.#provideHeaderSuggestions(otherDoc, position, context, range, token); + } + } + } else { // Normal path suggestions + yield* this.#providePathSuggestions(document, position, context, token); + } + } + } + } + } + + /// [...](...| + readonly #linkStartPattern = new RegExp( + // text + r`\[` + /**/r`(?:` + /*****/r`[^\[\]\\]|` + // Non-bracket chars, or... /*****/r`\\.|` + // Escaped char, or... /*****/r`\[[^\[\]]*\]` + // Matched bracket pair /**/r`)*` + - r`\]` + - // Destination start - r`\(\s*(<[^\>\)]*|[^\s\(\)]*)` + - r`$`// Must match cursor position - ); - - /// [...][...| - readonly #referenceLinkStartPattern = /\[([^\]]*?)\]\[\s*([^\s()]*)$/; - - /// [id]: | - readonly #definitionPattern = /^\s*\[[\w-]+\]:\s*([^\s]*)$/m; - - #getPathCompletionContext(document: Document, position: lsp.Position): PathCompletionContext | undefined { - const line = getLine(document, position.line); - - const linePrefixText = line.slice(0, position.character); - const lineSuffixText = line.slice(position.character); - - const linkPrefixMatch = linePrefixText.match(this.#linkStartPattern); - if (linkPrefixMatch) { - const isAngleBracketLink = linkPrefixMatch[1].startsWith('<'); - const prefix = linkPrefixMatch[1].slice(isAngleBracketLink ? 1 : 0); - if (this.#refLooksLikeUrl(prefix)) { - return undefined; - } - - const suffix = lineSuffixText.match(/^[^)\s][^)\s>]*/); - return { - kind: CompletionContextKind.Link, - linkPrefix: tryDecodeUriComponent(prefix), - linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }), - linkSuffix: suffix ? suffix[0] : '', - anchorInfo: this.#getAnchorContext(prefix), - skipEncoding: isAngleBracketLink, - }; - } - - const definitionLinkPrefixMatch = linePrefixText.match(this.#definitionPattern); - if (definitionLinkPrefixMatch) { - const isAngleBracketLink = definitionLinkPrefixMatch[1].startsWith('<'); - const prefix = definitionLinkPrefixMatch[1].slice(isAngleBracketLink ? 1 : 0); - if (this.#refLooksLikeUrl(prefix)) { - return undefined; - } - - const suffix = lineSuffixText.match(/^[^\s]*/); - return { - kind: CompletionContextKind.LinkDefinition, - linkPrefix: tryDecodeUriComponent(prefix), - linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }), - linkSuffix: suffix ? suffix[0] : '', - anchorInfo: this.#getAnchorContext(prefix), - skipEncoding: isAngleBracketLink, - }; - } - - const referenceLinkPrefixMatch = linePrefixText.match(this.#referenceLinkStartPattern); - if (referenceLinkPrefixMatch) { - const prefix = referenceLinkPrefixMatch[2]; - const suffix = lineSuffixText.match(/^[^\]\s]*/); - return { - kind: CompletionContextKind.ReferenceLink, - linkPrefix: prefix, - linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }), - linkSuffix: suffix ? suffix[0] : '', - }; - } - - return undefined; - } - - /** - * Check if {@param ref} looks like a 'http:' style url. - */ - #refLooksLikeUrl(prefix: string): boolean { - return /^\s*[\w\d-]+:/.test(prefix); - } - - #getAnchorContext(prefix: string): AnchorContext | undefined { - const anchorMatch = prefix.match(/^(.*)#([\w\d-]*)$/); - if (!anchorMatch) { - return undefined; - } - return { - beforeAnchor: anchorMatch[1], - anchorPrefix: anchorMatch[2], - }; - } - - async *#provideReferenceSuggestions(document: Document, position: lsp.Position, context: PathCompletionContext, token: CancellationToken): AsyncIterable { - const insertionRange = makeRange(context.linkTextStartPosition, position); - const replacementRange = makeRange(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length })); - - const { definitions } = await this.#linkProvider.getLinks(document); - if (token.isCancellationRequested) { - return; - } - - for (const def of definitions) { - yield { - kind: lsp.CompletionItemKind.Reference, - label: def.ref.text, - detail: l10n.t(`Reference link '{0}'`, def.ref.text), - textEdit: { - newText: def.ref.text, - insert: insertionRange, - replace: replacementRange, - }, - }; - } - } - - async *#provideHeaderSuggestions(document: Document, position: lsp.Position, context: PathCompletionContext, insertionRange: lsp.Range, token: CancellationToken): AsyncIterable { - const toc = await TableOfContents.createForContainingDoc(this.#parser, this.#workspace, document, token); - if (token.isCancellationRequested) { - return; - } - - const replacementRange = makeRange(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length })); - for (const entry of toc.entries) { - const completionItem = this.#createHeaderCompletion(entry, insertionRange, replacementRange); - completionItem.labelDetails = { - - }; - yield completionItem; - } - } - - #createHeaderCompletion(entry: TocEntry, insertionRange: lsp.Range, replacementRange: lsp.Range, filePath = ''): lsp.CompletionItem { - const label = '#' + decodeURIComponent(entry.slug.value); - const newText = filePath + '#' + decodeURIComponent(entry.slug.value); - return { - kind: lsp.CompletionItemKind.Reference, - label, - detail: this.#ownHeaderEntryDetails(entry), - textEdit: { - newText, - insert: insertionRange, - replace: replacementRange, - }, - }; - } - - #ownHeaderEntryDetails(entry: TocEntry): string | undefined { - return l10n.t(`Link to '{0}'`, '#'.repeat(entry.level) + ' ' +entry.text); - } - - /** - * Suggestions for headers across all md files in the workspace - */ - async *#provideWorkspaceHeaderSuggestions(document: Document, position: lsp.Position, context: PathCompletionContext, insertionRange: lsp.Range, token: CancellationToken): AsyncIterable { - const tocs = await this.#workspaceTocCache.entries(); - if (token.isCancellationRequested) { - return; - } - - const replacementRange = makeRange(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length })); - for (const [toDoc, toc] of tocs) { - const isHeaderInCurrentDocument = toDoc.toString() === getDocUri(document).toString(); - - const rawPath = isHeaderInCurrentDocument ? '' : computeRelativePath(getDocUri(document), toDoc); - if (typeof rawPath === 'undefined') { - continue; - } - - const normalizedPath = this.#normalizeFileNameCompletion(rawPath); - const path = context.skipEncoding ? normalizedPath : encodeURI(normalizedPath); - for (const entry of toc.entries) { - const completionItem = this.#createHeaderCompletion(entry, insertionRange, replacementRange, path); - completionItem.filterText = '#' + completionItem.label; - completionItem.sortText = isHeaderInCurrentDocument ? sortTexts.localHeader : sortTexts.workspaceHeader; - - if (isHeaderInCurrentDocument) { - completionItem.detail = this.#ownHeaderEntryDetails(entry); - } else if (path) { - completionItem.detail = l10n.t(`Link to '# {0}' in '{1}'`, entry.text, path); - completionItem.labelDetails = { description: path }; - } - yield completionItem; - } - } - } - - async *#providePathSuggestions(document: Document, position: lsp.Position, context: PathCompletionContext, token: CancellationToken): AsyncIterable { - const valueBeforeLastSlash = context.linkPrefix.substring(0, context.linkPrefix.lastIndexOf('/') + 1); // keep the last slash - - const parentDir = this.#resolveReference(document, valueBeforeLastSlash || '.'); - if (!parentDir) { - return; - } - - const pathSegmentStart = translatePosition(position, { characterDelta: valueBeforeLastSlash.length - context.linkPrefix.length }); - const insertRange = makeRange(pathSegmentStart, position); - - const pathSegmentEnd = translatePosition(position, { characterDelta: context.linkSuffix.length }); - const replacementRange = makeRange(pathSegmentStart, pathSegmentEnd); - - let dirInfo: Iterable; - try { - dirInfo = await this.#workspace.readDirectory(parentDir); - } catch { - return; - } - - if (token.isCancellationRequested) { - return; - } - - // eslint-disable-next-line prefer-const - for (let [name, type] of dirInfo) { - const uri = Utils.joinPath(parentDir, name); - if (isExcludedPath(this.#configuration, uri)) { - continue; - } - - if (!type.isDirectory) { - name = this.#normalizeFileNameCompletion(name); - } - - const isDir = type.isDirectory; - const newText = (context.skipEncoding ? name : encodeURIComponent(name)) + (isDir ? '/' : ''); - const label = isDir ? name + '/' : name; - yield { - label, - kind: isDir ? lsp.CompletionItemKind.Folder : lsp.CompletionItemKind.File, - detail: l10n.t(`Link to '{0}'`, label), - documentation: isDir ? uri.path + '/' : uri.path, - textEdit: { - newText, - insert: insertRange, - replace: replacementRange, - }, - command: isDir ? { command: 'editor.action.triggerSuggest', title: '' } : undefined, - }; - } - } - - #normalizeFileNameCompletion(name: string): string { - if (this.#configuration.preferredMdPathExtensionStyle === 'removeExtension') { - if (looksLikeMarkdownFilePath(this.#configuration, name)) { - const ext = extname(name); - name = name.slice(0, -ext.length); - } - } - return name; - } - - #resolveReference(document: Document, ref: string): URI | undefined { - const docUri = this.#getFileUriOfTextDocument(document); - - if (ref.startsWith('/')) { - const workspaceFolder = getWorkspaceFolder(this.#workspace, docUri); - if (workspaceFolder) { - return Utils.joinPath(workspaceFolder, ref); - } else { - return this.#resolvePath(docUri, ref.slice(1)); - } - } - - return this.#resolvePath(docUri, ref); - } - - #resolvePath(root: URI, ref: string): URI | undefined { - try { - if (root.scheme === Schemes.file) { - return URI.file(resolve(dirname(root.fsPath), ref)); - } else { - return root.with({ - path: resolve(dirname(root.path), ref), - }); - } - } catch { - return undefined; - } - } - - #getFileUriOfTextDocument(document: Document): URI { - return this.#workspace.getContainingDocument?.(getDocUri(document))?.uri ?? getDocUri(document); - } + r`\]` + + // Destination start + r`\(\s*(<[^\>\)]*|[^\s\(\)]*)` + + r`$`// Must match cursor position + ); + + /// [...][...| + readonly #referenceLinkStartPattern = /\[([^\]]*?)\]\[\s*([^\s()]*)$/; + + /// [id]: | + readonly #definitionPattern = /^\s*\[[\w-]+\]:\s*([^\s]*)$/m; + + #getPathCompletionContext(document: Document, position: lsp.Position): PathCompletionContext | undefined { + const line = getLine(document, position.line); + + const linePrefixText = line.slice(0, position.character); + const lineSuffixText = line.slice(position.character); + + const linkPrefixMatch = linePrefixText.match(this.#linkStartPattern); + if (linkPrefixMatch) { + const isAngleBracketLink = linkPrefixMatch[1].startsWith('<'); + const prefix = linkPrefixMatch[1].slice(isAngleBracketLink ? 1 : 0); + if (this.#refLooksLikeUrl(prefix)) { + return undefined; + } + + const suffix = lineSuffixText.match(/^[^)\s][^)\s>]*/); + return { + kind: CompletionContextKind.Link, + linkPrefix: tryDecodeUriComponent(prefix), + linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }), + linkSuffix: suffix ? suffix[0] : '', + anchorInfo: this.#getAnchorContext(prefix), + skipEncoding: isAngleBracketLink, + }; + } + + const definitionLinkPrefixMatch = linePrefixText.match(this.#definitionPattern); + if (definitionLinkPrefixMatch) { + const isAngleBracketLink = definitionLinkPrefixMatch[1].startsWith('<'); + const prefix = definitionLinkPrefixMatch[1].slice(isAngleBracketLink ? 1 : 0); + if (this.#refLooksLikeUrl(prefix)) { + return undefined; + } + + const suffix = lineSuffixText.match(/^[^\s]*/); + return { + kind: CompletionContextKind.LinkDefinition, + linkPrefix: tryDecodeUriComponent(prefix), + linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }), + linkSuffix: suffix ? suffix[0] : '', + anchorInfo: this.#getAnchorContext(prefix), + skipEncoding: isAngleBracketLink, + }; + } + + const referenceLinkPrefixMatch = linePrefixText.match(this.#referenceLinkStartPattern); + if (referenceLinkPrefixMatch) { + const prefix = referenceLinkPrefixMatch[2]; + const suffix = lineSuffixText.match(/^[^\]\s]*/); + return { + kind: CompletionContextKind.ReferenceLink, + linkPrefix: prefix, + linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }), + linkSuffix: suffix ? suffix[0] : '', + }; + } + + return undefined; + } + + /** + * Check if {@param ref} looks like a 'http:' style url. + */ + #refLooksLikeUrl(prefix: string): boolean { + return /^\s*[\w\d-]+:/.test(prefix); + } + + #getAnchorContext(prefix: string): AnchorContext | undefined { + const anchorMatch = prefix.match(/^(.*)#([\w\d-]*)$/); + if (!anchorMatch) { + return undefined; + } + return { + beforeAnchor: anchorMatch[1], + anchorPrefix: anchorMatch[2], + }; + } + + async *#provideReferenceSuggestions(document: Document, position: lsp.Position, context: PathCompletionContext, token: CancellationToken): AsyncIterable { + const insertionRange = makeRange(context.linkTextStartPosition, position); + const replacementRange = makeRange(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length })); + + const { definitions } = await this.#linkProvider.getLinks(document); + if (token.isCancellationRequested) { + return; + } + + for (const def of definitions) { + yield { + kind: lsp.CompletionItemKind.Reference, + label: def.ref.text, + detail: l10n.t(`Reference link '{0}'`, def.ref.text), + textEdit: { + newText: def.ref.text, + insert: insertionRange, + replace: replacementRange, + }, + }; + } + } + + async *#provideHeaderSuggestions(document: Document, position: lsp.Position, context: PathCompletionContext, insertionRange: lsp.Range, token: CancellationToken): AsyncIterable { + const toc = await TableOfContents.createForContainingDoc(this.#parser, this.#workspace, document, token); + if (token.isCancellationRequested) { + return; + } + + const replacementRange = makeRange(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length })); + for (const entry of toc.entries) { + const completionItem = this.#createHeaderCompletion(entry, insertionRange, replacementRange); + completionItem.labelDetails = { + + }; + yield completionItem; + } + } + + #createHeaderCompletion(entry: TocEntry, insertionRange: lsp.Range, replacementRange: lsp.Range, filePath = ''): lsp.CompletionItem { + const label = '#' + decodeURIComponent(entry.slug.value); + const newText = filePath + '#' + decodeURIComponent(entry.slug.value); + return { + kind: lsp.CompletionItemKind.Reference, + label, + detail: this.#ownHeaderEntryDetails(entry), + textEdit: { + newText, + insert: insertionRange, + replace: replacementRange, + }, + }; + } + + #ownHeaderEntryDetails(entry: TocEntry): string | undefined { + return l10n.t(`Link to '{0}'`, '#'.repeat(entry.level) + ' ' + entry.text); + } + + /** + * Suggestions for headers across all md files in the workspace + */ + async *#provideWorkspaceHeaderSuggestions(document: Document, position: lsp.Position, context: PathCompletionContext, insertionRange: lsp.Range, token: CancellationToken): AsyncIterable { + const tocs = await this.#workspaceTocCache.entries(); + if (token.isCancellationRequested) { + return; + } + + const replacementRange = makeRange(insertionRange.start, translatePosition(position, { characterDelta: context.linkSuffix.length })); + for (const [toDoc, toc] of tocs) { + const isHeaderInCurrentDocument = toDoc.toString() === getDocUri(document).toString(); + + const rawPath = isHeaderInCurrentDocument ? '' : computeRelativePath(getDocUri(document), toDoc); + if (typeof rawPath === 'undefined') { + continue; + } + + const normalizedPath = this.#normalizeFileNameCompletion(rawPath); + const path = context.skipEncoding ? normalizedPath : encodeURI(normalizedPath); + for (const entry of toc.entries) { + const completionItem = this.#createHeaderCompletion(entry, insertionRange, replacementRange, path); + completionItem.filterText = '#' + completionItem.label; + completionItem.sortText = isHeaderInCurrentDocument ? sortTexts.localHeader : sortTexts.workspaceHeader; + + if (isHeaderInCurrentDocument) { + completionItem.detail = this.#ownHeaderEntryDetails(entry); + } else if (path) { + completionItem.detail = l10n.t(`Link to '# {0}' in '{1}'`, entry.text, path); + completionItem.labelDetails = { description: path }; + } + yield completionItem; + } + } + } + + async *#providePathSuggestions(document: Document, position: lsp.Position, context: PathCompletionContext, token: CancellationToken): AsyncIterable { + const valueBeforeLastSlash = context.linkPrefix.substring(0, context.linkPrefix.lastIndexOf('/') + 1); // keep the last slash + + const parentDir = this.#resolveReference(document, valueBeforeLastSlash || '.'); + if (!parentDir) { + return; + } + + const pathSegmentStart = translatePosition(position, { characterDelta: valueBeforeLastSlash.length - context.linkPrefix.length }); + const insertRange = makeRange(pathSegmentStart, position); + + const pathSegmentEnd = translatePosition(position, { characterDelta: context.linkSuffix.length }); + const replacementRange = makeRange(pathSegmentStart, pathSegmentEnd); + + let dirInfo: Iterable; + try { + dirInfo = await this.#workspace.readDirectory(parentDir); + } catch { + return; + } + + if (token.isCancellationRequested) { + return; + } + + // eslint-disable-next-line prefer-const + for (let [name, type] of dirInfo) { + const uri = Utils.joinPath(parentDir, name); + if (isExcludedPath(this.#configuration, uri)) { + continue; + } + + if (!type.isDirectory) { + name = this.#normalizeFileNameCompletion(name); + } + + const isDir = type.isDirectory; + const newText = (context.skipEncoding ? name : encodeURIComponent(name)) + (isDir ? '/' : ''); + const label = isDir ? name + '/' : name; + yield { + label, + kind: isDir ? lsp.CompletionItemKind.Folder : lsp.CompletionItemKind.File, + detail: l10n.t(`Link to '{0}'`, label), + documentation: isDir ? uri.path + '/' : uri.path, + textEdit: { + newText, + insert: insertRange, + replace: replacementRange, + }, + command: isDir ? { command: 'editor.action.triggerSuggest', title: '' } : undefined, + }; + } + } + + #normalizeFileNameCompletion(name: string): string { + if (this.#configuration.preferredMdPathExtensionStyle === 'removeExtension') { + if (looksLikeMarkdownFilePath(this.#configuration, name)) { + const ext = extname(name); + name = name.slice(0, -ext.length); + } + } + return name; + } + + #resolveReference(document: Document, ref: string): URI | undefined { + const docUri = this.#getFileUriOfTextDocument(document); + + if (ref.startsWith('/')) { + const workspaceFolder = getWorkspaceFolder(this.#workspace, docUri); + if (workspaceFolder) { + return Utils.joinPath(workspaceFolder, ref); + } else { + return this.#resolvePath(docUri, ref.slice(1)); + } + } + + return this.#resolvePath(docUri, ref); + } + + #resolvePath(root: URI, ref: string): URI | undefined { + try { + if (root.scheme === Schemes.file) { + return URI.file(resolve(dirname(root.fsPath), ref)); + } else { + return root.with({ + path: resolve(dirname(root.path), ref), + }); + } + } catch { + return undefined; + } + } + + #getFileUriOfTextDocument(document: Document): URI { + return this.#workspace.getContainingDocument?.(getDocUri(document))?.uri ?? getDocUri(document); + } } - - diff --git a/apps/lsp/src/service/providers/completion/completion-shortcode.ts b/apps/lsp/src/service/providers/completion/completion-shortcode.ts index a9b50b78..01ba25c6 100644 --- a/apps/lsp/src/service/providers/completion/completion-shortcode.ts +++ b/apps/lsp/src/service/providers/completion/completion-shortcode.ts @@ -30,8 +30,8 @@ import { jupyterFromJSON, kCellId, kCellLabel, kCellTags, partitionCellOptions } const kShortcodeRegex = /(^\s*{{< )(embed|include)(\s+)([^\s]+)?.*? >}}\s*$/; -export async function shortcodeCompletions(context: EditorContext, workspace: IWorkspace) : Promise { - +export async function shortcodeCompletions(context: EditorContext, workspace: IWorkspace): Promise { + // bypass if the current line doesn't contain a {{< (performance optimization so we don't execute // the regexes below if we don't need to) if (context.line.indexOf("{{<") === -1) { @@ -40,7 +40,7 @@ export async function shortcodeCompletions(context: EditorContext, workspace: IW const match = context.line.match(kShortcodeRegex); if (match) { - // is the cursor in the file region (group 4) and is the + // is the cursor in the file region (group 4) and is the // next character a space? const beginFile = match[1].length + match[2].length + match[3].length; const endFile = beginFile + (match[4]?.length || 0); @@ -80,10 +80,10 @@ export async function shortcodeCompletions(context: EditorContext, workspace: IW } catch { return null; } - + const completions: CompletionItem[] = []; for (const [name, type] of dirInfo) { - + // screen out hidden if (name.startsWith(".")) { continue; @@ -98,7 +98,7 @@ export async function shortcodeCompletions(context: EditorContext, workspace: IW continue; } } - + // create completion const uri = Utils.joinPath(parentDir, name); const isDir = type.isDirectory; @@ -125,7 +125,7 @@ export async function shortcodeCompletions(context: EditorContext, workspace: IW }); } return completions; - } + } } return null; @@ -133,10 +133,10 @@ export async function shortcodeCompletions(context: EditorContext, workspace: IW } function resolveReference( - docUri: URI, + docUri: URI, workspace: IWorkspace, ref: string): URI | undefined { - + if (ref.startsWith('/')) { const workspaceFolder = getWorkspaceFolder(workspace, docUri); if (workspaceFolder) { @@ -163,7 +163,7 @@ function resolvePath(root: URI, ref: string): URI | undefined { } } -function ipynbCompletions(uri: URI) : CompletionItem[] | null { +function ipynbCompletions(uri: URI): CompletionItem[] | null { const ipynbPath = uri.fsPath; if (fs.existsSync(ipynbPath)) { const modified = fs.statSync(ipynbPath).mtime.getTime(); @@ -184,18 +184,18 @@ function ipynbCompletions(uri: URI) : CompletionItem[] | null { } } -const ipynbEmbedIds = new Map(); +const ipynbEmbedIds = new Map(); -function readIpynbEmbedIds(ipynbPath: string) : string[] | null { +function readIpynbEmbedIds(ipynbPath: string): string[] | null { const embedIds: string[] = []; const nbContents = fs.readFileSync(ipynbPath, { encoding: "utf-8" }); const nb = jupyterFromJSON(nbContents); for (const cell of nb.cells) { if (cell.cell_type === "code") { const { yaml } = partitionCellOptions(nb.metadata.kernelspec.language, cell.source); - if (typeof(yaml?.[kCellLabel]) === "string") { + if (typeof (yaml?.[kCellLabel]) === "string") { embedIds.push(yaml[kCellLabel]) - } else if (typeof(yaml?.[kCellId]) === "string") { + } else if (typeof (yaml?.[kCellId]) === "string") { embedIds.push(yaml[kCellId]) } else if (Array.isArray(cell.metadata[kCellTags]) && cell.metadata[kCellTags].length) { embedIds.push(String(cell.metadata[kCellTags][0])) @@ -211,7 +211,7 @@ function readIpynbEmbedIds(ipynbPath: string) : string[] | null { return embedIds.length ? embedIds : null; } -function idToCompletion(id: string) : CompletionItem { +function idToCompletion(id: string): CompletionItem { return { label: id, kind: CompletionItemKind.Field diff --git a/apps/lsp/src/service/providers/completion/completion-yaml.ts b/apps/lsp/src/service/providers/completion/completion-yaml.ts index 0d9c61ee..72679f30 100644 --- a/apps/lsp/src/service/providers/completion/completion-yaml.ts +++ b/apps/lsp/src/service/providers/completion/completion-yaml.ts @@ -15,20 +15,20 @@ */ import { lines } from "core"; -import { - Range, - TextEdit, - Command, - CompletionItem, - CompletionItemKind, - MarkupKind +import { + Range, + TextEdit, + Command, + CompletionItem, + CompletionItemKind, + MarkupKind } from "vscode-languageserver-types"; import { EditorContext, Quarto } from "../../quarto"; export async function yamlCompletions(quarto: Quarto, context: EditorContext, stripPadding: boolean) { - + // don't do completions from trigger characters (yaml has none) if (context.trigger) { return null; diff --git a/apps/lsp/src/service/providers/completion/completion.ts b/apps/lsp/src/service/providers/completion/completion.ts index 4f641de4..c15c7149 100644 --- a/apps/lsp/src/service/providers/completion/completion.ts +++ b/apps/lsp/src/service/providers/completion/completion.ts @@ -21,7 +21,7 @@ import { CompletionContext, CompletionItem, CompletionTriggerKind, - TextDocuments + TextDocuments } from "vscode-languageserver"; import { Quarto } from "../../quarto"; import { attrCompletions } from "./completion-attrs"; @@ -41,13 +41,13 @@ import { docEditorContext } from "../../quarto"; * Control the type of path completions returned. */ export interface PathCompletionOptions { - /** - * Should header completions for other files in the workspace be returned when - * you trigger completions. - * - * Defaults to {@link IncludeWorkspaceHeaderCompletions.never never} (not returned). - */ - readonly includeWorkspaceHeaderCompletions?: IncludeWorkspaceHeaderCompletions; + /** + * Should header completions for other files in the workspace be returned when + * you trigger completions. + * + * Defaults to {@link IncludeWorkspaceHeaderCompletions.never never} (not returned). + */ + readonly includeWorkspaceHeaderCompletions?: IncludeWorkspaceHeaderCompletions; } @@ -55,24 +55,24 @@ export interface PathCompletionOptions { * Controls if header completions for other files in the workspace be returned. */ export enum IncludeWorkspaceHeaderCompletions { - /** - * Never return workspace header completions. - */ - never = 'never', + /** + * Never return workspace header completions. + */ + never = 'never', - /** - * Return workspace header completions after `##` is typed. - * - * This lets the user signal - */ - onDoubleHash = 'onDoubleHash', + /** + * Return workspace header completions after `##` is typed. + * + * This lets the user signal + */ + onDoubleHash = 'onDoubleHash', - /** - * Return workspace header completions after either a single `#` is typed or after `##` - * - * For a single hash, this means the workspace header completions will be returned along side the current file header completions. - */ - onSingleOrDoubleHash = 'onSingleOrDoubleHash', + /** + * Return workspace header completions after either a single `#` is typed or after `##` + * + * For a single hash, this means the workspace header completions will be returned along side the current file header completions. + */ + onSingleOrDoubleHash = 'onSingleOrDoubleHash', } export class MdCompletionProvider { @@ -80,43 +80,43 @@ export class MdCompletionProvider { readonly pathCompletionProvider_: MdPathCompletionProvider; readonly quarto_: Quarto; - readonly parser_: Parser; - readonly workspace_: IWorkspace; - readonly documents_: TextDocuments; + readonly parser_: Parser; + readonly workspace_: IWorkspace; + readonly documents_: TextDocuments; - constructor( - configuration: LsConfiguration, + constructor( + configuration: LsConfiguration, quarto: Quarto, - workspace: IWorkspace, - documents: TextDocuments, - parser: Parser, - linkProvider: MdLinkProvider, - tocProvider: MdTableOfContentsProvider, - ) { + workspace: IWorkspace, + documents: TextDocuments, + parser: Parser, + linkProvider: MdLinkProvider, + tocProvider: MdTableOfContentsProvider, + ) { this.quarto_ = quarto; - this.parser_ = parser; - this.workspace_ = workspace; - this.documents_ = documents; + this.parser_ = parser; + this.workspace_ = workspace; + this.documents_ = documents; this.pathCompletionProvider_ = new MdPathCompletionProvider( - configuration, - workspace, - parser, - linkProvider, + configuration, + workspace, + parser, + linkProvider, tocProvider ); - } + } - public async provideCompletionItems( - doc: Document, - pos: Position, - context: CompletionContext, + public async provideCompletionItems( + doc: Document, + pos: Position, + context: CompletionContext, config: LsConfiguration, token: CancellationToken, ): Promise { - if (token.isCancellationRequested) { - return []; - } + if (token.isCancellationRequested) { + return []; + } const explicit = context.triggerKind === CompletionTriggerKind.TriggerCharacter; const trigger = context.triggerCharacter; @@ -124,11 +124,11 @@ export class MdCompletionProvider { return ( (await refsCompletions(this.quarto_, this.parser_, doc, pos, editorContext, this.documents_)) || (await attrCompletions(this.quarto_, editorContext)) || - (await shortcodeCompletions(editorContext, this.workspace_)) || + (await shortcodeCompletions(editorContext, this.workspace_)) || (await latexCompletions(this.parser_, doc, pos, context, config)) || (await yamlCompletions(this.quarto_, editorContext, true)) || (await this.pathCompletionProvider_.provideCompletionItems(doc, pos, context, token)) || [] ); - } + } } diff --git a/apps/lsp/src/service/providers/completion/mathjax-completions.json b/apps/lsp/src/service/providers/completion/mathjax-completions.json index d416f84d..147d6166 100644 --- a/apps/lsp/src/service/providers/completion/mathjax-completions.json +++ b/apps/lsp/src/service/providers/completion/mathjax-completions.json @@ -2227,4 +2227,4 @@ "command": "displaystyle", "snippet": "displaystyle" } -} \ No newline at end of file +} diff --git a/apps/lsp/src/service/providers/completion/refs/completion-biblio.ts b/apps/lsp/src/service/providers/completion/refs/completion-biblio.ts index 58612f0f..934e24a8 100644 --- a/apps/lsp/src/service/providers/completion/refs/completion-biblio.ts +++ b/apps/lsp/src/service/providers/completion/refs/completion-biblio.ts @@ -38,9 +38,9 @@ export async function biblioCompletions( label: ref.id, documentation: ref.cite ? { - kind: MarkupKind.Markdown, - value: ref.cite, - } + kind: MarkupKind.Markdown, + value: ref.cite, + } : undefined, })); } else { diff --git a/apps/lsp/src/service/providers/completion/refs/completion-crossref.ts b/apps/lsp/src/service/providers/completion/refs/completion-crossref.ts index 67fac5b4..d3856c9f 100644 --- a/apps/lsp/src/service/providers/completion/refs/completion-crossref.ts +++ b/apps/lsp/src/service/providers/completion/refs/completion-crossref.ts @@ -44,10 +44,10 @@ function xrefCompletion(includeFilename: boolean) { label: `${xref.type}-${xref.id}${xref.suffix || ""}`, documentation: xref.title ? { - kind: MarkupKind.Markdown, - value: - xref.title + (includeFilename ? " — _" + xref.file + "_" : ""), - } + kind: MarkupKind.Markdown, + value: + xref.title + (includeFilename ? " — _" + xref.file + "_" : ""), + } : undefined, }); } diff --git a/apps/lsp/src/service/providers/completion/refs/completion-refs.ts b/apps/lsp/src/service/providers/completion/refs/completion-refs.ts index a5771740..1f7d2361 100644 --- a/apps/lsp/src/service/providers/completion/refs/completion-refs.ts +++ b/apps/lsp/src/service/providers/completion/refs/completion-refs.ts @@ -32,7 +32,7 @@ export async function refsCompletions( context: EditorContext, documents: TextDocuments, ): Promise { - + // validate trigger if (context.trigger && !["@"].includes(context.trigger)) { return null; @@ -60,7 +60,7 @@ export async function refsCompletions( // construct path const path = filePathForDoc(doc); const projectDir = projectDirForDocument(path); - const biblioItems = await biblioCompletions(quarto, parser, tokenText, doc); + const biblioItems = await biblioCompletions(quarto, parser, tokenText, doc); const crossrefItems = await crossrefCompletions( quarto, tokenText, diff --git a/apps/lsp/src/service/providers/definitions.ts b/apps/lsp/src/service/providers/definitions.ts index b0948a43..553c3b18 100644 --- a/apps/lsp/src/service/providers/definitions.ts +++ b/apps/lsp/src/service/providers/definitions.ts @@ -25,82 +25,82 @@ import { HrefKind, LinkDefinitionSet, MdLink, MdLinkKind } from './document-link export class MdDefinitionProvider { - readonly #configuration: LsConfiguration; - readonly #workspace: IWorkspace; - readonly #tocProvider: MdTableOfContentsProvider; - readonly #linkCache: MdWorkspaceInfoCache; - - constructor( - configuration: LsConfiguration, - workspace: IWorkspace, - tocProvider: MdTableOfContentsProvider, - linkCache: MdWorkspaceInfoCache, - ) { - this.#configuration = configuration; - this.#workspace = workspace; - this.#tocProvider = tocProvider; - this.#linkCache = linkCache; - } - - async provideDefinition(document: Document, position: lsp.Position, token: CancellationToken): Promise { - - if (token.isCancellationRequested) { - return []; - } - - const toc = await this.#tocProvider.getForDocument(document); - - if (token.isCancellationRequested) { - return []; - } - - const header = toc.entries.find(entry => entry.line === position.line); - if (isTocHeaderEntry(header)) { - return header.headerLocation; - } - - return this.#getDefinitionOfLinkAtPosition(document, position, token); - } - - async #getDefinitionOfLinkAtPosition(document: Document, position: lsp.Position, token: CancellationToken): Promise { - const docLinks = (await this.#linkCache.getForDocs([document]))[0]; - - for (const link of docLinks) { - if (link.kind === MdLinkKind.Definition && rangeContains(link.ref.range, position)) { - return this.#getDefinitionOfRef(link.ref.text, docLinks); - } - if (rangeContains(link.source.hrefRange, position)) { - return this.#getDefinitionOfLink(link, docLinks, token); - } - } - - return undefined; - } - - async #getDefinitionOfLink(sourceLink: MdLink, allLinksInFile: readonly MdLink[], token: CancellationToken): Promise { - if (sourceLink.href.kind === HrefKind.Reference) { - return this.#getDefinitionOfRef(sourceLink.href.ref, allLinksInFile); - } - - if (sourceLink.href.kind === HrefKind.External || !sourceLink.href.fragment) { - return undefined; - } - - const resolvedResource = await statLinkToMarkdownFile(this.#configuration, this.#workspace, sourceLink.href.path); - if (!resolvedResource || token.isCancellationRequested) { - return undefined; - } - - const toc = await this.#tocProvider.get(resolvedResource); - const entry = toc.lookup(sourceLink.href.fragment); - if (isTocHeaderEntry(entry)) { - return entry.headerLocation; - } - } - - #getDefinitionOfRef(ref: string, allLinksInFile: readonly MdLink[]) { - const allDefinitions = new LinkDefinitionSet(allLinksInFile); - const def = allDefinitions.lookup(ref); - return def ? { range: def.source.range, uri: def.source.resource.toString() } : undefined; - } + readonly #configuration: LsConfiguration; + readonly #workspace: IWorkspace; + readonly #tocProvider: MdTableOfContentsProvider; + readonly #linkCache: MdWorkspaceInfoCache; + + constructor( + configuration: LsConfiguration, + workspace: IWorkspace, + tocProvider: MdTableOfContentsProvider, + linkCache: MdWorkspaceInfoCache, + ) { + this.#configuration = configuration; + this.#workspace = workspace; + this.#tocProvider = tocProvider; + this.#linkCache = linkCache; + } + + async provideDefinition(document: Document, position: lsp.Position, token: CancellationToken): Promise { + + if (token.isCancellationRequested) { + return []; + } + + const toc = await this.#tocProvider.getForDocument(document); + + if (token.isCancellationRequested) { + return []; + } + + const header = toc.entries.find(entry => entry.line === position.line); + if (isTocHeaderEntry(header)) { + return header.headerLocation; + } + + return this.#getDefinitionOfLinkAtPosition(document, position, token); + } + + async #getDefinitionOfLinkAtPosition(document: Document, position: lsp.Position, token: CancellationToken): Promise { + const docLinks = (await this.#linkCache.getForDocs([document]))[0]; + + for (const link of docLinks) { + if (link.kind === MdLinkKind.Definition && rangeContains(link.ref.range, position)) { + return this.#getDefinitionOfRef(link.ref.text, docLinks); + } + if (rangeContains(link.source.hrefRange, position)) { + return this.#getDefinitionOfLink(link, docLinks, token); + } + } + + return undefined; + } + + async #getDefinitionOfLink(sourceLink: MdLink, allLinksInFile: readonly MdLink[], token: CancellationToken): Promise { + if (sourceLink.href.kind === HrefKind.Reference) { + return this.#getDefinitionOfRef(sourceLink.href.ref, allLinksInFile); + } + + if (sourceLink.href.kind === HrefKind.External || !sourceLink.href.fragment) { + return undefined; + } + + const resolvedResource = await statLinkToMarkdownFile(this.#configuration, this.#workspace, sourceLink.href.path); + if (!resolvedResource || token.isCancellationRequested) { + return undefined; + } + + const toc = await this.#tocProvider.get(resolvedResource); + const entry = toc.lookup(sourceLink.href.fragment); + if (isTocHeaderEntry(entry)) { + return entry.headerLocation; + } + } + + #getDefinitionOfRef(ref: string, allLinksInFile: readonly MdLink[]) { + const allDefinitions = new LinkDefinitionSet(allLinksInFile); + const def = allDefinitions.lookup(ref); + return def ? { range: def.source.range, uri: def.source.resource.toString() } : undefined; + } } diff --git a/apps/lsp/src/service/providers/diagnostics-yaml.ts b/apps/lsp/src/service/providers/diagnostics-yaml.ts index 0597257d..0291a0a7 100644 --- a/apps/lsp/src/service/providers/diagnostics-yaml.ts +++ b/apps/lsp/src/service/providers/diagnostics-yaml.ts @@ -53,7 +53,7 @@ export async function provideYamlDiagnostics( source: "quarto", }; }); - + } function lintSeverity(item: LintItem) { diff --git a/apps/lsp/src/service/providers/diagnostics.ts b/apps/lsp/src/service/providers/diagnostics.ts index 37df3adb..6cd33afa 100644 --- a/apps/lsp/src/service/providers/diagnostics.ts +++ b/apps/lsp/src/service/providers/diagnostics.ts @@ -38,21 +38,21 @@ import { provideYamlDiagnostics } from './diagnostics-yaml'; * The severity at which diagnostics are reported */ export enum DiagnosticLevel { - /** Don't report this diagnostic. */ - ignore = 'ignore', + /** Don't report this diagnostic. */ + ignore = 'ignore', - /** - * Report the diagnostic at a hint level. - * - * Hints will typically not be directly reported by editors, but may show up as unused spans. - */ - hint = 'hint', + /** + * Report the diagnostic at a hint level. + * + * Hints will typically not be directly reported by editors, but may show up as unused spans. + */ + hint = 'hint', - /** Report the diagnostic as a warning. */ - warning = 'warning', + /** Report the diagnostic as a warning. */ + warning = 'warning', - /** Report the diagnostic as an error. */ - error = 'error', + /** Report the diagnostic as an error. */ + error = 'error', } /** @@ -60,78 +60,78 @@ export enum DiagnosticLevel { */ export interface DiagnosticOptions { - /** - * Should markdown be validated at all? (false disables all of the below) - */ - readonly enabled: boolean; - - /** - * Diagnostic level for invalid reference links, e.g. `[text][no-such-ref]`. - */ - readonly validateReferences: DiagnosticLevel | undefined; - - /** - * Diagnostic level for fragments links to headers in the current file that don't exist, e.g. `[text](#no-such-header)`. - */ - readonly validateFragmentLinks: DiagnosticLevel | undefined; - - /** - * Diagnostic level for links to local files that don't exist, e.g. `[text](./no-such-file.png)`. - */ - readonly validateFileLinks: DiagnosticLevel | undefined; - - /** - * Diagnostic level for the fragment part of links to other local markdown files , e.g. `[text](./file.md#no-such-header)`. - */ - readonly validateMarkdownFileLinkFragments: DiagnosticLevel | undefined; - - /** - * Diagnostic level for link definitions that aren't used anywhere. `[never-used]: http://example.com`. - */ - readonly validateUnusedLinkDefinitions: DiagnosticLevel | undefined; - - /** - * Diagnostic level for duplicate link definitions. - */ - readonly validateDuplicateLinkDefinitions: DiagnosticLevel | undefined; - - /** - * Glob of links that should not be validated. - */ - readonly ignoreLinks: readonly string[]; + /** + * Should markdown be validated at all? (false disables all of the below) + */ + readonly enabled: boolean; + + /** + * Diagnostic level for invalid reference links, e.g. `[text][no-such-ref]`. + */ + readonly validateReferences: DiagnosticLevel | undefined; + + /** + * Diagnostic level for fragments links to headers in the current file that don't exist, e.g. `[text](#no-such-header)`. + */ + readonly validateFragmentLinks: DiagnosticLevel | undefined; + + /** + * Diagnostic level for links to local files that don't exist, e.g. `[text](./no-such-file.png)`. + */ + readonly validateFileLinks: DiagnosticLevel | undefined; + + /** + * Diagnostic level for the fragment part of links to other local markdown files , e.g. `[text](./file.md#no-such-header)`. + */ + readonly validateMarkdownFileLinkFragments: DiagnosticLevel | undefined; + + /** + * Diagnostic level for link definitions that aren't used anywhere. `[never-used]: http://example.com`. + */ + readonly validateUnusedLinkDefinitions: DiagnosticLevel | undefined; + + /** + * Diagnostic level for duplicate link definitions. + */ + readonly validateDuplicateLinkDefinitions: DiagnosticLevel | undefined; + + /** + * Glob of links that should not be validated. + */ + readonly ignoreLinks: readonly string[]; } function toSeverity(level: DiagnosticLevel | undefined): DiagnosticSeverity | undefined { - switch (level) { - case DiagnosticLevel.error: return DiagnosticSeverity.Error; - case DiagnosticLevel.warning: return DiagnosticSeverity.Warning; - case DiagnosticLevel.hint: return DiagnosticSeverity.Hint; - case DiagnosticLevel.ignore: return undefined; - case undefined: return undefined; - } + switch (level) { + case DiagnosticLevel.error: return DiagnosticSeverity.Error; + case DiagnosticLevel.warning: return DiagnosticSeverity.Warning; + case DiagnosticLevel.hint: return DiagnosticSeverity.Hint; + case DiagnosticLevel.ignore: return undefined; + case undefined: return undefined; + } } /** * Error codes of Markdown diagnostics */ export enum DiagnosticCode { - /** The linked to reference does not exist. */ - link_noSuchReferences = 'link.no-such-reference', + /** The linked to reference does not exist. */ + link_noSuchReferences = 'link.no-such-reference', - /** The linked to heading does not exist in the current file. */ - link_noSuchHeaderInOwnFile = 'link.no-such-header-in-own-file', + /** The linked to heading does not exist in the current file. */ + link_noSuchHeaderInOwnFile = 'link.no-such-header-in-own-file', - /** The linked to local file does not exist. */ - link_noSuchFile = 'link.no-such-file', + /** The linked to local file does not exist. */ + link_noSuchFile = 'link.no-such-file', - /** The linked to heading does not exist in the another file. */ - link_noSuchHeaderInFile = 'link.no-such-header-in-file', + /** The linked to heading does not exist in the another file. */ + link_noSuchHeaderInFile = 'link.no-such-header-in-file', - /** The link definition is not used anywhere. */ - link_unusedDefinition = 'link.unused-definition', + /** The link definition is not used anywhere. */ + link_unusedDefinition = 'link.unused-definition', - /** The link definition is not used anywhere. */ - link_duplicateDefinition = 'link.duplicate-definition', + /** The link definition is not used anywhere. */ + link_duplicateDefinition = 'link.duplicate-definition', } /** @@ -139,336 +139,336 @@ export enum DiagnosticCode { */ class FileLinkMap { - readonly #filesToLinksMap = new ResourceMap<{ - readonly outgoingLinks: Array<{ - readonly source: MdLinkSource; - readonly fragment: string; - }>; - }>(); - - constructor(links: Iterable) { - for (const link of links) { - if (link.href.kind !== HrefKind.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(); - } + readonly #filesToLinksMap = new ResourceMap<{ + readonly outgoingLinks: Array<{ + readonly source: MdLinkSource; + readonly fragment: string; + }>; + }>(); + + constructor(links: Iterable) { + for (const link of links) { + if (link.href.kind !== HrefKind.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 DiagnosticOnSaveComputer { - constructor(private readonly quarto_: Quarto) {} + constructor(private readonly quarto_: Quarto) { } - public async compute(doc: Document) : Promise { - return provideYamlDiagnostics(this.quarto_, doc); - } + public async compute(doc: Document): Promise { + return provideYamlDiagnostics(this.quarto_, doc); + } } export class DiagnosticComputer { - readonly #configuration: LsConfiguration; - readonly #workspace: IWorkspace; - readonly #linkProvider: MdLinkProvider; - readonly #tocProvider: MdTableOfContentsProvider; - readonly #logger: ILogger; - - constructor( - configuration: LsConfiguration, - workspace: IWorkspace, - linkProvider: MdLinkProvider, - tocProvider: MdTableOfContentsProvider, - logger: ILogger, - ) { - this.#configuration = configuration; - this.#workspace = workspace; - this.#linkProvider = linkProvider; - this.#tocProvider = tocProvider; - this.#logger = logger; - } - - public async compute( - doc: Document, - options: DiagnosticOptions, - token: CancellationToken, - ): Promise<{ - readonly diagnostics: lsp.Diagnostic[]; - readonly links: readonly MdLink[]; - readonly statCache: ResourceMap<{ readonly exists: boolean }>; - }> { - this.#logger.log(LogLevel.Debug, 'DiagnosticComputer.compute', { document: doc.uri, version: doc.version }); - - const { links, definitions } = await this.#linkProvider.getLinks(doc); - const statCache = new ResourceMap<{ readonly exists: boolean }>(); - if (token.isCancellationRequested) { - return { links, diagnostics: [], statCache }; - } - - // Current doc always implicitly exists - statCache.set(getDocUri(doc), { exists: true }); - - // base diagnostics - const diagnostics: lsp.Diagnostic[] = []; - - // optionally produce markdown diagnostics - if (options.enabled) { - diagnostics.push(...(await Promise.all([ - this.#validateFileLinks(options, links, statCache, token), - this.#validateFragmentLinks(doc, options, links, token), - Array.from(this.#validateReferenceLinks(options, links, definitions)), - Array.from(this.#validateUnusedLinkDefinitions(options, links)), - Array.from(this.#validateDuplicateLinkDefinitions(options, links)), - ])).flat()); - } - - this.#logger.log(LogLevel.Trace, 'DiagnosticComputer.compute finished', { document: doc.uri, version: doc.version, diagnostics }); - - return { - links: links, - statCache, - diagnostics: diagnostics - }; - } - - async #validateFragmentLinks(doc: Document, options: DiagnosticOptions, links: readonly MdLink[], token: CancellationToken): Promise { - const severity = toSeverity(options.validateFragmentLinks); - if (typeof severity === 'undefined') { - return []; - } - - const toc = await this.#tocProvider.getForDocument(doc); - if (token.isCancellationRequested) { - return []; - } - - const diagnostics: lsp.Diagnostic[] = []; - for (const link of links) { - - if (link.href.kind === HrefKind.Internal - && link.source.hrefText.startsWith('#') - && link.href.path.toString() === doc.uri.toString() - && link.href.fragment - && !toc.lookup(link.href.fragment) - ) { - // Don't validate line number links - if (parseLocationInfoFromFragment(link.href.fragment)) { - continue; - } - - if (!this.#isIgnoredLink(options, link.source.hrefText)) { - diagnostics.push({ - code: DiagnosticCode.link_noSuchHeaderInOwnFile, - message: l10n.t('No header found: \'{0}\'', link.href.fragment), - range: link.source.hrefRange, - severity, - data: { - hrefText: link.source.hrefText - } - }); - } - } - } - - return diagnostics; - } - - *#validateReferenceLinks(options: DiagnosticOptions, links: readonly MdLink[], definitions: LinkDefinitionSet): Iterable { - const severity = toSeverity(options.validateReferences); - if (typeof severity === 'undefined') { - return []; - } - - for (const link of links) { - if (link.href.kind === HrefKind.Reference && !definitions.lookup(link.href.ref)) { - yield { - code: DiagnosticCode.link_noSuchReferences, - message: l10n.t('No link definition found: \'{0}\'', link.href.ref), - range: link.source.hrefRange, - severity, - data: { - ref: link.href.ref, - }, - }; - } - } - } - - *#validateUnusedLinkDefinitions(options: DiagnosticOptions, links: readonly MdLink[]): Iterable { - const errorSeverity = toSeverity(options.validateUnusedLinkDefinitions); - if (typeof errorSeverity === 'undefined') { - return; - } - - const usedRefs = new ReferenceLinkMap(); - for (const link of links) { - if (link.kind === MdLinkKind.Link && link.href.kind === HrefKind.Reference) { - usedRefs.set(link.href.ref, true); - } - } - - for (const link of links) { - if (link.kind === MdLinkKind.Definition && !usedRefs.lookup(link.ref.text)) { - yield { - code: DiagnosticCode.link_unusedDefinition, - message: l10n.t('Link definition is unused'), - range: link.source.range, - severity: errorSeverity, - tags: [ - lsp.DiagnosticTag.Unnecessary, - ], - data: link - }; - } - } - } - - *#validateDuplicateLinkDefinitions(options: DiagnosticOptions, links: readonly MdLink[]): Iterable { - const errorSeverity = toSeverity(options.validateDuplicateLinkDefinitions); - if (typeof errorSeverity === 'undefined') { - return; - } - - const definitionMultiMap = new Map(); - for (const link of links) { - if (link.kind === MdLinkKind.Definition) { - const existing = definitionMultiMap.get(link.ref.text); - if (existing) { - existing.push(link); - } else { - definitionMultiMap.set(link.ref.text, [link]); - } - } - } - - for (const [ref, defs] of definitionMultiMap) { - if (defs.length <= 1) { - continue; - } - - for (const duplicateDef of defs) { - yield { - code: DiagnosticCode.link_duplicateDefinition, - message: l10n.t('Link definition for \'{0}\' already exists', ref), - range: duplicateDef.ref.range, - severity: errorSeverity, - relatedInformation: - defs - .filter(x => x !== duplicateDef) - .map(def => lsp.DiagnosticRelatedInformation.create( - { uri: def.source.resource.toString(), range: def.ref.range }, - l10n.t('Link is also defined here'), - )), - data: duplicateDef - }; - } - } - } - - async #validateFileLinks( - options: DiagnosticOptions, - links: readonly MdLink[], - statCache: ResourceMap<{ readonly exists: boolean }>, - token: CancellationToken, - ): Promise { - 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: lsp.Diagnostic[] = []; - await Promise.all( - Array.from(linkSet.entries()).map(([path, { outgoingLinks: links }]) => { - return limiter.queue(async () => { - if (token.isCancellationRequested) { - return; - } - - const resolvedHrefPath = await statLinkToMarkdownFile(this.#configuration, this.#workspace, path, statCache); - if (token.isCancellationRequested) { - return; - } - - if (!resolvedHrefPath) { - for (const link of links) { - if (!this.#isIgnoredLink(options, link.source.pathText)) { - diagnostics.push({ - code: DiagnosticCode.link_noSuchFile, - message: l10n.t('File does not exist at path: {0}', path.fsPath), - range: link.source.hrefRange, - severity: pathErrorSeverity, - data: { - fsPath: path.fsPath, - hrefText: 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); - if (token.isCancellationRequested) { - return; - } - - for (const link of fragmentLinks) { - // Don't validate line number links - if (parseLocationInfoFromFragment(link.fragment)) { - continue; - } - - if (!toc.lookup(link.fragment) && !this.#isIgnoredLink(options, link.source.pathText) && !this.#isIgnoredLink(options, link.source.hrefText)) { - const range = (link.source.fragmentRange && modifyRange(link.source.fragmentRange, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 }), undefined)) ?? link.source.hrefRange; - diagnostics.push({ - code: DiagnosticCode.link_noSuchHeaderInFile, - message: l10n.t('Header does not exist in file: {0}', link.fragment), - range: range, - severity: fragmentErrorSeverity, - data: { - fragment: link.fragment, - hrefText: link.source.hrefText - }, - }); - } - } - } - } - }); - })); - return diagnostics; - } - - #isMarkdownPath(resolvedHrefPath: URI) { - return this.#workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownUri(this.#configuration, resolvedHrefPath); - } - - #isIgnoredLink(options: DiagnosticOptions, link: string): boolean { - return options.ignoreLinks.some(glob => picomatch.isMatch(link, glob)); - } + readonly #configuration: LsConfiguration; + readonly #workspace: IWorkspace; + readonly #linkProvider: MdLinkProvider; + readonly #tocProvider: MdTableOfContentsProvider; + readonly #logger: ILogger; + + constructor( + configuration: LsConfiguration, + workspace: IWorkspace, + linkProvider: MdLinkProvider, + tocProvider: MdTableOfContentsProvider, + logger: ILogger, + ) { + this.#configuration = configuration; + this.#workspace = workspace; + this.#linkProvider = linkProvider; + this.#tocProvider = tocProvider; + this.#logger = logger; + } + + public async compute( + doc: Document, + options: DiagnosticOptions, + token: CancellationToken, + ): Promise<{ + readonly diagnostics: lsp.Diagnostic[]; + readonly links: readonly MdLink[]; + readonly statCache: ResourceMap<{ readonly exists: boolean }>; + }> { + this.#logger.log(LogLevel.Debug, 'DiagnosticComputer.compute', { document: doc.uri, version: doc.version }); + + const { links, definitions } = await this.#linkProvider.getLinks(doc); + const statCache = new ResourceMap<{ readonly exists: boolean }>(); + if (token.isCancellationRequested) { + return { links, diagnostics: [], statCache }; + } + + // Current doc always implicitly exists + statCache.set(getDocUri(doc), { exists: true }); + + // base diagnostics + const diagnostics: lsp.Diagnostic[] = []; + + // optionally produce markdown diagnostics + if (options.enabled) { + diagnostics.push(...(await Promise.all([ + this.#validateFileLinks(options, links, statCache, token), + this.#validateFragmentLinks(doc, options, links, token), + Array.from(this.#validateReferenceLinks(options, links, definitions)), + Array.from(this.#validateUnusedLinkDefinitions(options, links)), + Array.from(this.#validateDuplicateLinkDefinitions(options, links)), + ])).flat()); + } + + this.#logger.log(LogLevel.Trace, 'DiagnosticComputer.compute finished', { document: doc.uri, version: doc.version, diagnostics }); + + return { + links: links, + statCache, + diagnostics: diagnostics + }; + } + + async #validateFragmentLinks(doc: Document, options: DiagnosticOptions, links: readonly MdLink[], token: CancellationToken): Promise { + const severity = toSeverity(options.validateFragmentLinks); + if (typeof severity === 'undefined') { + return []; + } + + const toc = await this.#tocProvider.getForDocument(doc); + if (token.isCancellationRequested) { + return []; + } + + const diagnostics: lsp.Diagnostic[] = []; + for (const link of links) { + + if (link.href.kind === HrefKind.Internal + && link.source.hrefText.startsWith('#') + && link.href.path.toString() === doc.uri.toString() + && link.href.fragment + && !toc.lookup(link.href.fragment) + ) { + // Don't validate line number links + if (parseLocationInfoFromFragment(link.href.fragment)) { + continue; + } + + if (!this.#isIgnoredLink(options, link.source.hrefText)) { + diagnostics.push({ + code: DiagnosticCode.link_noSuchHeaderInOwnFile, + message: l10n.t('No header found: \'{0}\'', link.href.fragment), + range: link.source.hrefRange, + severity, + data: { + hrefText: link.source.hrefText + } + }); + } + } + } + + return diagnostics; + } + + *#validateReferenceLinks(options: DiagnosticOptions, links: readonly MdLink[], definitions: LinkDefinitionSet): Iterable { + const severity = toSeverity(options.validateReferences); + if (typeof severity === 'undefined') { + return []; + } + + for (const link of links) { + if (link.href.kind === HrefKind.Reference && !definitions.lookup(link.href.ref)) { + yield { + code: DiagnosticCode.link_noSuchReferences, + message: l10n.t('No link definition found: \'{0}\'', link.href.ref), + range: link.source.hrefRange, + severity, + data: { + ref: link.href.ref, + }, + }; + } + } + } + + *#validateUnusedLinkDefinitions(options: DiagnosticOptions, links: readonly MdLink[]): Iterable { + const errorSeverity = toSeverity(options.validateUnusedLinkDefinitions); + if (typeof errorSeverity === 'undefined') { + return; + } + + const usedRefs = new ReferenceLinkMap(); + for (const link of links) { + if (link.kind === MdLinkKind.Link && link.href.kind === HrefKind.Reference) { + usedRefs.set(link.href.ref, true); + } + } + + for (const link of links) { + if (link.kind === MdLinkKind.Definition && !usedRefs.lookup(link.ref.text)) { + yield { + code: DiagnosticCode.link_unusedDefinition, + message: l10n.t('Link definition is unused'), + range: link.source.range, + severity: errorSeverity, + tags: [ + lsp.DiagnosticTag.Unnecessary, + ], + data: link + }; + } + } + } + + *#validateDuplicateLinkDefinitions(options: DiagnosticOptions, links: readonly MdLink[]): Iterable { + const errorSeverity = toSeverity(options.validateDuplicateLinkDefinitions); + if (typeof errorSeverity === 'undefined') { + return; + } + + const definitionMultiMap = new Map(); + for (const link of links) { + if (link.kind === MdLinkKind.Definition) { + const existing = definitionMultiMap.get(link.ref.text); + if (existing) { + existing.push(link); + } else { + definitionMultiMap.set(link.ref.text, [link]); + } + } + } + + for (const [ref, defs] of definitionMultiMap) { + if (defs.length <= 1) { + continue; + } + + for (const duplicateDef of defs) { + yield { + code: DiagnosticCode.link_duplicateDefinition, + message: l10n.t('Link definition for \'{0}\' already exists', ref), + range: duplicateDef.ref.range, + severity: errorSeverity, + relatedInformation: + defs + .filter(x => x !== duplicateDef) + .map(def => lsp.DiagnosticRelatedInformation.create( + { uri: def.source.resource.toString(), range: def.ref.range }, + l10n.t('Link is also defined here'), + )), + data: duplicateDef + }; + } + } + } + + async #validateFileLinks( + options: DiagnosticOptions, + links: readonly MdLink[], + statCache: ResourceMap<{ readonly exists: boolean }>, + token: CancellationToken, + ): Promise { + 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: lsp.Diagnostic[] = []; + await Promise.all( + Array.from(linkSet.entries()).map(([path, { outgoingLinks: links }]) => { + return limiter.queue(async () => { + if (token.isCancellationRequested) { + return; + } + + const resolvedHrefPath = await statLinkToMarkdownFile(this.#configuration, this.#workspace, path, statCache); + if (token.isCancellationRequested) { + return; + } + + if (!resolvedHrefPath) { + for (const link of links) { + if (!this.#isIgnoredLink(options, link.source.pathText)) { + diagnostics.push({ + code: DiagnosticCode.link_noSuchFile, + message: l10n.t('File does not exist at path: {0}', path.fsPath), + range: link.source.hrefRange, + severity: pathErrorSeverity, + data: { + fsPath: path.fsPath, + hrefText: 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); + if (token.isCancellationRequested) { + return; + } + + for (const link of fragmentLinks) { + // Don't validate line number links + if (parseLocationInfoFromFragment(link.fragment)) { + continue; + } + + if (!toc.lookup(link.fragment) && !this.#isIgnoredLink(options, link.source.pathText) && !this.#isIgnoredLink(options, link.source.hrefText)) { + const range = (link.source.fragmentRange && modifyRange(link.source.fragmentRange, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 }), undefined)) ?? link.source.hrefRange; + diagnostics.push({ + code: DiagnosticCode.link_noSuchHeaderInFile, + message: l10n.t('Header does not exist in file: {0}', link.fragment), + range: range, + severity: fragmentErrorSeverity, + data: { + fragment: link.fragment, + hrefText: link.source.hrefText + }, + }); + } + } + } + } + }); + })); + return diagnostics; + } + + #isMarkdownPath(resolvedHrefPath: URI) { + return this.#workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownUri(this.#configuration, resolvedHrefPath); + } + + #isIgnoredLink(options: DiagnosticOptions, link: string): boolean { + return options.ignoreLinks.some(glob => picomatch.isMatch(link, glob)); + } } /** @@ -476,234 +476,233 @@ export class DiagnosticComputer { */ export interface IPullDiagnosticsManager { - /** - * Dispose of the diagnostic manager and clean up any associated resources. - */ - dispose(): void; - - /** - * Event fired when a file that Markdown document is linking to changes. - */ - readonly onLinkedToFileChanged: Event<{ - readonly changedResource: URI; - readonly linkingResources: readonly URI[]; - }>; - - /** - * Compute the current diagnostics for a file. - */ - computeDiagnostics(doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise; - - /** - * Clean up resources that help provide diagnostics for a document. - * - * You should call this when you will no longer be making diagnostic requests for a document, for example - * when the file has been closed in the editor (but still exists on disk). - */ - disposeDocumentResources(document: URI): void; + /** + * Dispose of the diagnostic manager and clean up any associated resources. + */ + dispose(): void; + + /** + * Event fired when a file that Markdown document is linking to changes. + */ + readonly onLinkedToFileChanged: Event<{ + readonly changedResource: URI; + readonly linkingResources: readonly URI[]; + }>; + + /** + * Compute the current diagnostics for a file. + */ + computeDiagnostics(doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise; + + /** + * Clean up resources that help provide diagnostics for a document. + * + * You should call this when you will no longer be making diagnostic requests for a document, for example + * when the file has been closed in the editor (but still exists on disk). + */ + disposeDocumentResources(document: URI): void; } class FileLinkState extends Disposable { - readonly #onDidChangeLinkedToFile = this._register(new Emitter<{ - readonly changedResource: URI; - readonly linkingFiles: Iterable; - readonly exists: boolean; - }>); - /** - * Event fired with a list of document uri when one of the links in the document changes - */ - public readonly onDidChangeLinkedToFile = this.#onDidChangeLinkedToFile.event; - - readonly #linkedToFile = new ResourceMap<{ - /** - * Watcher for this link path - */ - readonly watcher: IDisposable; - - /** - * List of documents that reference the link - */ - readonly documents: ResourceMap; - - exists: boolean; - }>(); - - readonly #workspace: IWorkspaceWithWatching; - readonly #logger: ILogger; - - constructor(workspace: IWorkspaceWithWatching, logger: ILogger) { - super(); - - this.#workspace = workspace; - this.#logger = logger; - } - - override dispose() { - super.dispose(); - - for (const entry of this.#linkedToFile.values()) { - entry.watcher.dispose(); - } - this.#linkedToFile.clear(); - } - - /** - * Set the known links in a markdown document, adding and removing file watchers as needed - */ - updateLinksForDocument(document: URI, links: readonly MdLink[], statCache: ResourceMap<{ readonly exists: boolean }>) { - const linkedToResource = new Set<{ path: URI; exists: boolean }>( - links - .filter(link => link.href.kind === HrefKind.Internal) - .map(link => ({ path: (link.href as InternalHref).path, exists: !!(statCache.get((link.href as InternalHref).path)?.exists) }))); - - // First decrement watcher counter for previous document state - for (const entry of this.#linkedToFile.values()) { - entry.documents.delete(document); - } - - // Then create/update watchers for new document state - for (const { path, exists } of linkedToResource) { - let entry = this.#linkedToFile.get(path); - if (!entry) { - entry = { - watcher: this.#startWatching(path), - documents: new ResourceMap(), - exists - }; - this.#linkedToFile.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.#linkedToFile) { - if (value.documents.size === 0) { - value.watcher.dispose(); - this.#linkedToFile.delete(key); - } - } - } - - public deleteDocument(resource: URI) { - this.updateLinksForDocument(resource, [], new ResourceMap()); - } - - public tryStatFileLink(link: URI): { exists: boolean } | undefined { - const entry = this.#linkedToFile.get(link); - if (!entry) { - return undefined; - } - return { exists: entry.exists }; - } - - #startWatching(path: URI): IDisposable { - const watcher = this.#workspace.watchFile(path, { ignoreChange: true }); - const deleteReg = watcher.onDidDelete((resource: URI) => this.#onLinkedResourceChanged(resource, false)); - const createReg = watcher.onDidCreate((resource: URI) => this.#onLinkedResourceChanged(resource, true)); - return { - dispose: () => { - watcher.dispose(); - deleteReg.dispose(); - createReg.dispose(); - } - }; - } - - #onLinkedResourceChanged(resource: URI, exists: boolean) { - this.#logger.log(LogLevel.Trace, 'FileLinkState.onLinkedResourceChanged', { resource, exists }); - - const entry = this.#linkedToFile.get(resource); - if (entry) { - entry.exists = exists; - this.#onDidChangeLinkedToFile.fire({ - changedResource: resource, - linkingFiles: entry.documents.values(), - exists, - }); - } - } + readonly #onDidChangeLinkedToFile = this._register(new Emitter<{ + readonly changedResource: URI; + readonly linkingFiles: Iterable; + readonly exists: boolean; + }>); + /** + * Event fired with a list of document uri when one of the links in the document changes + */ + public readonly onDidChangeLinkedToFile = this.#onDidChangeLinkedToFile.event; + + readonly #linkedToFile = new ResourceMap<{ + /** + * Watcher for this link path + */ + readonly watcher: IDisposable; + + /** + * List of documents that reference the link + */ + readonly documents: ResourceMap; + + exists: boolean; + }>(); + + readonly #workspace: IWorkspaceWithWatching; + readonly #logger: ILogger; + + constructor(workspace: IWorkspaceWithWatching, logger: ILogger) { + super(); + + this.#workspace = workspace; + this.#logger = logger; + } + + override dispose() { + super.dispose(); + + for (const entry of this.#linkedToFile.values()) { + entry.watcher.dispose(); + } + this.#linkedToFile.clear(); + } + + /** + * Set the known links in a markdown document, adding and removing file watchers as needed + */ + updateLinksForDocument(document: URI, links: readonly MdLink[], statCache: ResourceMap<{ readonly exists: boolean }>) { + const linkedToResource = new Set<{ path: URI; exists: boolean }>( + links + .filter(link => link.href.kind === HrefKind.Internal) + .map(link => ({ path: (link.href as InternalHref).path, exists: !!(statCache.get((link.href as InternalHref).path)?.exists) }))); + + // First decrement watcher counter for previous document state + for (const entry of this.#linkedToFile.values()) { + entry.documents.delete(document); + } + + // Then create/update watchers for new document state + for (const { path, exists } of linkedToResource) { + let entry = this.#linkedToFile.get(path); + if (!entry) { + entry = { + watcher: this.#startWatching(path), + documents: new ResourceMap(), + exists + }; + this.#linkedToFile.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.#linkedToFile) { + if (value.documents.size === 0) { + value.watcher.dispose(); + this.#linkedToFile.delete(key); + } + } + } + + public deleteDocument(resource: URI) { + this.updateLinksForDocument(resource, [], new ResourceMap()); + } + + public tryStatFileLink(link: URI): { exists: boolean } | undefined { + const entry = this.#linkedToFile.get(link); + if (!entry) { + return undefined; + } + return { exists: entry.exists }; + } + + #startWatching(path: URI): IDisposable { + const watcher = this.#workspace.watchFile(path, { ignoreChange: true }); + const deleteReg = watcher.onDidDelete((resource: URI) => this.#onLinkedResourceChanged(resource, false)); + const createReg = watcher.onDidCreate((resource: URI) => this.#onLinkedResourceChanged(resource, true)); + return { + dispose: () => { + watcher.dispose(); + deleteReg.dispose(); + createReg.dispose(); + } + }; + } + + #onLinkedResourceChanged(resource: URI, exists: boolean) { + this.#logger.log(LogLevel.Trace, 'FileLinkState.onLinkedResourceChanged', { resource, exists }); + + const entry = this.#linkedToFile.get(resource); + if (entry) { + entry.exists = exists; + this.#onDidChangeLinkedToFile.fire({ + changedResource: resource, + linkingFiles: entry.documents.values(), + exists, + }); + } + } } export class DiagnosticsManager extends Disposable implements IPullDiagnosticsManager { - readonly #computer: DiagnosticComputer; - readonly #linkWatcher: FileLinkState; - - readonly #onLinkedToFileChanged = this._register(new Emitter<{ - readonly changedResource: URI; - readonly linkingResources: readonly URI[]; - }>()); - public readonly onLinkedToFileChanged = this.#onLinkedToFileChanged.event; - - constructor( - configuration: LsConfiguration, - workspace: IWorkspaceWithWatching, - linkProvider: MdLinkProvider, - tocProvider: MdTableOfContentsProvider, - logger: ILogger, - ) { - super(); - - const linkWatcher = new FileLinkState(workspace, logger); - this.#linkWatcher = this._register(linkWatcher); - - this._register(this.#linkWatcher.onDidChangeLinkedToFile(e => { - logger.log(LogLevel.Trace, 'DiagnosticsManager.onDidChangeLinkedToFile', { resource: e.changedResource }); - - this.#onLinkedToFileChanged.fire({ - changedResource: e.changedResource, - linkingResources: Array.from(e.linkingFiles), - }); - })); - - const stateCachedWorkspace = new Proxy(workspace, { - get(target, p, receiver) { - if (p !== 'stat') { - const value = Reflect.get(target, p, receiver); - return typeof value === 'function' ? value.bind(workspace) : value; - } - - return async function (this: unknown, resource: URI): Promise { - const stat = linkWatcher.tryStatFileLink(resource); - if (stat) { - if (stat.exists) { - return { isDirectory: false }; - } else { - return undefined; - } - } - return workspace.stat.call(this === receiver ? target : this, resource); - }; - }, - }); - - this.#computer = new DiagnosticComputer(configuration, stateCachedWorkspace, linkProvider, tocProvider, logger); - - this._register(workspace.onDidDeleteMarkdownDocument(uri => { - this.#linkWatcher.deleteDocument(uri); - })); - } - - public async computeDiagnostics(doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise { - - if (token.isCancellationRequested) { - return []; - } - - const results = await this.#computer.compute(doc, options, token); - - if (token.isCancellationRequested) { - return []; - } - - this.#linkWatcher.updateLinksForDocument(getDocUri(doc), results.links, results.statCache); - return results.diagnostics; - } - - public disposeDocumentResources(uri: URI): void { - this.#linkWatcher.deleteDocument(uri); - } + readonly #computer: DiagnosticComputer; + readonly #linkWatcher: FileLinkState; + + readonly #onLinkedToFileChanged = this._register(new Emitter<{ + readonly changedResource: URI; + readonly linkingResources: readonly URI[]; + }>()); + public readonly onLinkedToFileChanged = this.#onLinkedToFileChanged.event; + + constructor( + configuration: LsConfiguration, + workspace: IWorkspaceWithWatching, + linkProvider: MdLinkProvider, + tocProvider: MdTableOfContentsProvider, + logger: ILogger, + ) { + super(); + + const linkWatcher = new FileLinkState(workspace, logger); + this.#linkWatcher = this._register(linkWatcher); + + this._register(this.#linkWatcher.onDidChangeLinkedToFile(e => { + logger.log(LogLevel.Trace, 'DiagnosticsManager.onDidChangeLinkedToFile', { resource: e.changedResource }); + + this.#onLinkedToFileChanged.fire({ + changedResource: e.changedResource, + linkingResources: Array.from(e.linkingFiles), + }); + })); + + const stateCachedWorkspace = new Proxy(workspace, { + get(target, p, receiver) { + if (p !== 'stat') { + const value = Reflect.get(target, p, receiver); + return typeof value === 'function' ? value.bind(workspace) : value; + } + + return async function (this: unknown, resource: URI): Promise { + const stat = linkWatcher.tryStatFileLink(resource); + if (stat) { + if (stat.exists) { + return { isDirectory: false }; + } else { + return undefined; + } + } + return workspace.stat.call(this === receiver ? target : this, resource); + }; + }, + }); + + this.#computer = new DiagnosticComputer(configuration, stateCachedWorkspace, linkProvider, tocProvider, logger); + + this._register(workspace.onDidDeleteMarkdownDocument(uri => { + this.#linkWatcher.deleteDocument(uri); + })); + } + + public async computeDiagnostics(doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise { + + if (token.isCancellationRequested) { + return []; + } + + const results = await this.#computer.compute(doc, options, token); + + if (token.isCancellationRequested) { + return []; + } + + this.#linkWatcher.updateLinksForDocument(getDocUri(doc), results.links, results.statCache); + return results.diagnostics; + } + + public disposeDocumentResources(uri: URI): void { + this.#linkWatcher.deleteDocument(uri); + } } - diff --git a/apps/lsp/src/service/providers/document-highlights.ts b/apps/lsp/src/service/providers/document-highlights.ts index 64df74c8..bd31bc45 100644 --- a/apps/lsp/src/service/providers/document-highlights.ts +++ b/apps/lsp/src/service/providers/document-highlights.ts @@ -25,161 +25,161 @@ import { HrefKind, InternalHref, looksLikeLinkToResource, MdLink, MdLinkKind, Md export class MdDocumentHighlightProvider { - readonly #configuration: LsConfiguration; - readonly #tocProvider: MdTableOfContentsProvider; - readonly #linkProvider: MdLinkProvider; - - constructor( - configuration: LsConfiguration, - tocProvider: MdTableOfContentsProvider, - linkProvider: MdLinkProvider, - ) { - this.#configuration = configuration; - this.#tocProvider = tocProvider; - this.#linkProvider = linkProvider; - } - - public async getDocumentHighlights(document: Document, position: lsp.Position, token: CancellationToken): Promise { - - if (token.isCancellationRequested) { - return []; - } - - const toc = await this.#tocProvider.getForDocument(document); - - if (token.isCancellationRequested) { - return []; - } - - const { links } = await this.#linkProvider.getLinks(document); - if (token.isCancellationRequested) { - return []; - } - - const header = toc.entries.find(entry => entry.line === position.line); - if (isTocHeaderEntry(header)) { - return [...this.#getHighlightsForHeader(document, header, links, toc)]; - } - - return [...this.#getHighlightsForLinkAtPosition(document, position, links, toc)]; - } - - *#getHighlightsForHeader(document: Document, header: TocHeaderEntry, links: readonly MdLink[], toc: TableOfContents): Iterable { - yield { range: header.headerLocation.range, kind: lsp.DocumentHighlightKind.Write }; - - const docUri = document.uri.toString(); - for (const link of links) { - if (link.href.kind === HrefKind.Internal - && toc.lookup(link.href.fragment) === header - && link.source.fragmentRange - && link.href.path.toString() === docUri - ) { - yield { - range: modifyRange(link.source.fragmentRange, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 })), - kind: lsp.DocumentHighlightKind.Read, - }; - } - } - } - - #getHighlightsForLinkAtPosition(document: Document, position: lsp.Position, links: readonly MdLink[], toc: TableOfContents): Iterable { - const link = links.find(link => rangeContains(link.source.hrefRange, position) || (link.kind === MdLinkKind.Definition && rangeContains(link.ref.range, position))); - if (!link) { - return []; - } - - if (link.kind === MdLinkKind.Definition && rangeContains(link.ref.range, position)) { - // We are on the reference text inside the link definition - return this.#getHighlightsForReference(link.ref.text, links); - } - - switch (link.href.kind) { - case HrefKind.Reference: { - return this.#getHighlightsForReference(link.href.ref, links); - } - case HrefKind.Internal: { - if (link.source.fragmentRange && rangeContains(link.source.fragmentRange, position)) { - return this.#getHighlightsForLinkFragment(document, link.href, links, toc); - } - - return this.#getHighlightsForLinkPath(link.href.path, links); - } - case HrefKind.External: { - return this.#getHighlightsForExternalLink(link.href.uri, links); - } - } - } - - *#getHighlightsForLinkFragment(document: Document, href: InternalHref, links: readonly MdLink[], toc: TableOfContents): Iterable { - const targetDoc = tryAppendMarkdownFileExtension(this.#configuration, href.path); - if (!targetDoc) { - return; - } - - const fragment = href.fragment.toLowerCase(); - - if (targetDoc.toString() === document.uri) { - const header = toc.lookup(fragment); - if (isTocHeaderEntry(header)) { - yield { range: header.headerLocation.range, kind: lsp.DocumentHighlightKind.Write }; - } - } - - for (const link of links) { - if (link.href.kind === HrefKind.Internal && looksLikeLinkToResource(this.#configuration, link.href, targetDoc)) { - if (link.source.fragmentRange && link.href.fragment.toLowerCase() === fragment) { - yield { - range: modifyRange(link.source.fragmentRange, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 })), - kind: lsp.DocumentHighlightKind.Read, - }; - } - } - } - } - - *#getHighlightsForLinkPath(path: URI, links: readonly MdLink[]): Iterable { - const targetDoc = tryAppendMarkdownFileExtension(this.#configuration, path) ?? path; - for (const link of links) { - if (link.href.kind === HrefKind.Internal && looksLikeLinkToResource(this.#configuration, link.href, targetDoc)) { - yield { - range: getFilePathRange(link), - kind: lsp.DocumentHighlightKind.Read, - }; - } - } - } - - *#getHighlightsForExternalLink(uri: URI, links: readonly MdLink[]): Iterable { - for (const link of links) { - if (link.href.kind === HrefKind.External && link.href.uri.toString() === uri.toString()) { - yield { - range: getFilePathRange(link), - kind: lsp.DocumentHighlightKind.Read, - }; - } - } - } - - *#getHighlightsForReference(ref: string, links: readonly MdLink[]): Iterable { - for (const link of links) { - if (link.kind === MdLinkKind.Definition && link.ref.text === ref) { - yield { - range: link.ref.range, - kind: lsp.DocumentHighlightKind.Write, - }; - } else if (link.href.kind === HrefKind.Reference && link.href.ref === ref) { - yield { - range: link.source.hrefRange, - kind: lsp.DocumentHighlightKind.Read, - }; - } - } - } + readonly #configuration: LsConfiguration; + readonly #tocProvider: MdTableOfContentsProvider; + readonly #linkProvider: MdLinkProvider; + + constructor( + configuration: LsConfiguration, + tocProvider: MdTableOfContentsProvider, + linkProvider: MdLinkProvider, + ) { + this.#configuration = configuration; + this.#tocProvider = tocProvider; + this.#linkProvider = linkProvider; + } + + public async getDocumentHighlights(document: Document, position: lsp.Position, token: CancellationToken): Promise { + + if (token.isCancellationRequested) { + return []; + } + + const toc = await this.#tocProvider.getForDocument(document); + + if (token.isCancellationRequested) { + return []; + } + + const { links } = await this.#linkProvider.getLinks(document); + if (token.isCancellationRequested) { + return []; + } + + const header = toc.entries.find(entry => entry.line === position.line); + if (isTocHeaderEntry(header)) { + return [...this.#getHighlightsForHeader(document, header, links, toc)]; + } + + return [...this.#getHighlightsForLinkAtPosition(document, position, links, toc)]; + } + + *#getHighlightsForHeader(document: Document, header: TocHeaderEntry, links: readonly MdLink[], toc: TableOfContents): Iterable { + yield { range: header.headerLocation.range, kind: lsp.DocumentHighlightKind.Write }; + + const docUri = document.uri.toString(); + for (const link of links) { + if (link.href.kind === HrefKind.Internal + && toc.lookup(link.href.fragment) === header + && link.source.fragmentRange + && link.href.path.toString() === docUri + ) { + yield { + range: modifyRange(link.source.fragmentRange, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 })), + kind: lsp.DocumentHighlightKind.Read, + }; + } + } + } + + #getHighlightsForLinkAtPosition(document: Document, position: lsp.Position, links: readonly MdLink[], toc: TableOfContents): Iterable { + const link = links.find(link => rangeContains(link.source.hrefRange, position) || (link.kind === MdLinkKind.Definition && rangeContains(link.ref.range, position))); + if (!link) { + return []; + } + + if (link.kind === MdLinkKind.Definition && rangeContains(link.ref.range, position)) { + // We are on the reference text inside the link definition + return this.#getHighlightsForReference(link.ref.text, links); + } + + switch (link.href.kind) { + case HrefKind.Reference: { + return this.#getHighlightsForReference(link.href.ref, links); + } + case HrefKind.Internal: { + if (link.source.fragmentRange && rangeContains(link.source.fragmentRange, position)) { + return this.#getHighlightsForLinkFragment(document, link.href, links, toc); + } + + return this.#getHighlightsForLinkPath(link.href.path, links); + } + case HrefKind.External: { + return this.#getHighlightsForExternalLink(link.href.uri, links); + } + } + } + + *#getHighlightsForLinkFragment(document: Document, href: InternalHref, links: readonly MdLink[], toc: TableOfContents): Iterable { + const targetDoc = tryAppendMarkdownFileExtension(this.#configuration, href.path); + if (!targetDoc) { + return; + } + + const fragment = href.fragment.toLowerCase(); + + if (targetDoc.toString() === document.uri) { + const header = toc.lookup(fragment); + if (isTocHeaderEntry(header)) { + yield { range: header.headerLocation.range, kind: lsp.DocumentHighlightKind.Write }; + } + } + + for (const link of links) { + if (link.href.kind === HrefKind.Internal && looksLikeLinkToResource(this.#configuration, link.href, targetDoc)) { + if (link.source.fragmentRange && link.href.fragment.toLowerCase() === fragment) { + yield { + range: modifyRange(link.source.fragmentRange, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 })), + kind: lsp.DocumentHighlightKind.Read, + }; + } + } + } + } + + *#getHighlightsForLinkPath(path: URI, links: readonly MdLink[]): Iterable { + const targetDoc = tryAppendMarkdownFileExtension(this.#configuration, path) ?? path; + for (const link of links) { + if (link.href.kind === HrefKind.Internal && looksLikeLinkToResource(this.#configuration, link.href, targetDoc)) { + yield { + range: getFilePathRange(link), + kind: lsp.DocumentHighlightKind.Read, + }; + } + } + } + + *#getHighlightsForExternalLink(uri: URI, links: readonly MdLink[]): Iterable { + for (const link of links) { + if (link.href.kind === HrefKind.External && link.href.uri.toString() === uri.toString()) { + yield { + range: getFilePathRange(link), + kind: lsp.DocumentHighlightKind.Read, + }; + } + } + } + + *#getHighlightsForReference(ref: string, links: readonly MdLink[]): Iterable { + for (const link of links) { + if (link.kind === MdLinkKind.Definition && link.ref.text === ref) { + yield { + range: link.ref.range, + kind: lsp.DocumentHighlightKind.Write, + }; + } else if (link.href.kind === HrefKind.Reference && link.href.ref === ref) { + yield { + range: link.source.hrefRange, + kind: lsp.DocumentHighlightKind.Read, + }; + } + } + } } function getFilePathRange(link: MdLink): lsp.Range { - if (link.source.fragmentRange) { - return modifyRange(link.source.hrefRange, undefined, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 })); - } - return link.source.hrefRange; -} \ No newline at end of file + if (link.source.fragmentRange) { + return modifyRange(link.source.hrefRange, undefined, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 })); + } + return link.source.hrefRange; +} diff --git a/apps/lsp/src/service/providers/document-links.ts b/apps/lsp/src/service/providers/document-links.ts index 4e2d8764..54f8d14e 100644 --- a/apps/lsp/src/service/providers/document-links.ts +++ b/apps/lsp/src/service/providers/document-links.ts @@ -31,233 +31,233 @@ import { IWorkspace, getWorkspaceFolder, tryAppendMarkdownFileExtension } from ' import { MdDocumentInfoCache, MdWorkspaceInfoCache } from '../workspace-cache'; export enum HrefKind { - External, - Internal, - Reference, + External, + Internal, + Reference, } export interface ExternalHref { - readonly kind: HrefKind.External; - readonly uri: URI; + readonly kind: HrefKind.External; + readonly uri: URI; } export interface InternalHref { - readonly kind: HrefKind.Internal; - readonly path: URI; - readonly fragment: string; + readonly kind: HrefKind.Internal; + readonly path: URI; + readonly fragment: string; } export interface ReferenceHref { - readonly kind: HrefKind.Reference; - readonly ref: string; + readonly kind: HrefKind.Reference; + readonly ref: string; } export type LinkHref = ExternalHref | InternalHref | ReferenceHref; export function resolveInternalDocumentLink( - sourceDocUri: URI, - linkText: string, - workspace: IWorkspace, + sourceDocUri: URI, + linkText: string, + workspace: IWorkspace, ): { resource: URI; linkFragment: string } | undefined { - // Assume it must be an relative or absolute file path - // Use a fake scheme to avoid parse warnings - const tempUri = URI.parse(`vscode-resource:${linkText}`); - - const docUri = workspace.getContainingDocument?.(sourceDocUri)?.uri ?? sourceDocUri; - - let resourceUri: URI | undefined; - if (!tempUri.path) { - // Looks like a fragment only link - if (typeof tempUri.fragment !== 'string') { - return undefined; - } - - resourceUri = sourceDocUri; - } else if (tempUri.path[0] === '/') { - const root = getWorkspaceFolder(workspace, docUri); - if (root) { - resourceUri = Utils.joinPath(root, tempUri.path); - } - } else { - if (docUri.scheme === 'untitled') { - const root = getWorkspaceFolder(workspace, docUri); - if (root) { - resourceUri = Utils.joinPath(root, tempUri.path); - } - } else { - const base = Utils.dirname(docUri); - resourceUri = Utils.joinPath(base, tempUri.path); - } - } - - if (!resourceUri) { - return undefined; - } - - return { - resource: resourceUri, - linkFragment: tempUri.fragment, - }; + // Assume it must be an relative or absolute file path + // Use a fake scheme to avoid parse warnings + const tempUri = URI.parse(`vscode-resource:${linkText}`); + + const docUri = workspace.getContainingDocument?.(sourceDocUri)?.uri ?? sourceDocUri; + + let resourceUri: URI | undefined; + if (!tempUri.path) { + // Looks like a fragment only link + if (typeof tempUri.fragment !== 'string') { + return undefined; + } + + resourceUri = sourceDocUri; + } else if (tempUri.path[0] === '/') { + const root = getWorkspaceFolder(workspace, docUri); + if (root) { + resourceUri = Utils.joinPath(root, tempUri.path); + } + } else { + if (docUri.scheme === 'untitled') { + const root = getWorkspaceFolder(workspace, docUri); + if (root) { + resourceUri = Utils.joinPath(root, tempUri.path); + } + } else { + const base = Utils.dirname(docUri); + resourceUri = Utils.joinPath(base, tempUri.path); + } + } + + if (!resourceUri) { + return undefined; + } + + return { + resource: resourceUri, + linkFragment: tempUri.fragment, + }; } export interface MdLinkSource { - /** - * The full range of the link. - */ - readonly range: lsp.Range; - - /** - * The file where the link is defined. - */ - readonly resource: URI; - - /** - * The range of the entire link target. - * - * This includes the opening `(`/`[` and closing `)`/`]`. - * - * For `[boris](/cat.md#siberian "title")` this would be the range of `(/cat.md#siberian "title")` - */ - readonly targetRange: lsp.Range; - - /** - * The original text of the link destination in code. - * - * For `[boris](/cat.md#siberian "title")` this would be `/cat.md#siberian` - * - */ - readonly hrefText: string; - - /** - * The original text of just the link's path in code. - * - * For `[boris](/cat.md#siberian "title")` this would be `/cat.md` - */ - readonly pathText: string; - - /** - * The range of the path in this link. - * - * Does not include whitespace or the link title. - * - * For `[boris](/cat.md#siberian "title")` this would be the range of `/cat.md#siberian` - */ - readonly hrefRange: lsp.Range; - - /** - * The range of the fragment within the path. - * - * For `[boris](/cat.md#siberian "title")` this would be the range of `#siberian` - */ - readonly fragmentRange: lsp.Range | undefined; + /** + * The full range of the link. + */ + readonly range: lsp.Range; + + /** + * The file where the link is defined. + */ + readonly resource: URI; + + /** + * The range of the entire link target. + * + * This includes the opening `(`/`[` and closing `)`/`]`. + * + * For `[boris](/cat.md#siberian "title")` this would be the range of `(/cat.md#siberian "title")` + */ + readonly targetRange: lsp.Range; + + /** + * The original text of the link destination in code. + * + * For `[boris](/cat.md#siberian "title")` this would be `/cat.md#siberian` + * + */ + readonly hrefText: string; + + /** + * The original text of just the link's path in code. + * + * For `[boris](/cat.md#siberian "title")` this would be `/cat.md` + */ + readonly pathText: string; + + /** + * The range of the path in this link. + * + * Does not include whitespace or the link title. + * + * For `[boris](/cat.md#siberian "title")` this would be the range of `/cat.md#siberian` + */ + readonly hrefRange: lsp.Range; + + /** + * The range of the fragment within the path. + * + * For `[boris](/cat.md#siberian "title")` this would be the range of `#siberian` + */ + readonly fragmentRange: lsp.Range | undefined; } export enum MdLinkKind { - Link = 1, - Definition = 2, + Link = 1, + Definition = 2, } export interface MdInlineLink { - readonly kind: MdLinkKind.Link; - readonly source: MdLinkSource; - readonly href: HrefType; + readonly kind: MdLinkKind.Link; + readonly source: MdLinkSource; + readonly href: HrefType; } export interface MdLinkDefinition { - readonly kind: MdLinkKind.Definition; - readonly source: MdLinkSource; - readonly ref: { - readonly range: lsp.Range; - readonly text: string; - }; - readonly href: ExternalHref | InternalHref; + readonly kind: MdLinkKind.Definition; + readonly source: MdLinkSource; + readonly ref: { + readonly range: lsp.Range; + readonly text: string; + }; + readonly href: ExternalHref | InternalHref; } export type MdLink = MdInlineLink | MdLinkDefinition; function createHref( - sourceDocUri: URI, - link: string, - workspace: IWorkspace, + sourceDocUri: URI, + link: string, + workspace: IWorkspace, ): ExternalHref | InternalHref | undefined { - if (/^[a-z-][a-z-]+:/i.test(link)) { - // Looks like a uri - return { kind: HrefKind.External, uri: URI.parse(tryDecodeUri(link)) }; - } - - const resolved = resolveInternalDocumentLink(sourceDocUri, link, workspace); - if (!resolved) { - return undefined; - } - - return { - kind: HrefKind.Internal, - path: resolved.resource, - fragment: resolved.linkFragment, - }; + if (/^[a-z-][a-z-]+:/i.test(link)) { + // Looks like a uri + return { kind: HrefKind.External, uri: URI.parse(tryDecodeUri(link)) }; + } + + const resolved = resolveInternalDocumentLink(sourceDocUri, link, workspace); + if (!resolved) { + return undefined; + } + + return { + kind: HrefKind.Internal, + path: resolved.resource, + fragment: resolved.linkFragment, + }; } function createMdLink( - document: Document, - targetText: string, - preHrefText: string, - rawLink: string, - matchIndex: number, - fullMatch: string, - workspace: IWorkspace, + document: Document, + targetText: string, + preHrefText: string, + rawLink: string, + matchIndex: number, + fullMatch: string, + workspace: IWorkspace, ): MdLink | undefined { - const isAngleBracketLink = rawLink.startsWith('<'); - const link = stripAngleBrackets(rawLink); - - let linkTarget: ExternalHref | InternalHref | undefined; - try { - linkTarget = createHref(getDocUri(document), link, workspace); - } catch { - return undefined; - } - if (!linkTarget) { - return undefined; - } - - const pre = targetText + preHrefText; - const linkStart = document.positionAt(matchIndex); - const linkEnd = translatePosition(linkStart, { characterDelta: fullMatch.length }); - - const targetStart = translatePosition(linkStart, { characterDelta: targetText.length }); - const targetRange: lsp.Range = { start: targetStart, end: linkEnd }; - - const hrefStart = translatePosition(linkStart, { characterDelta: pre.length + (isAngleBracketLink ? 1 : 0) }); - const hrefEnd = translatePosition(hrefStart, { characterDelta: link.length }); - const hrefRange: lsp.Range = { start: hrefStart, end: hrefEnd }; - - return { - kind: MdLinkKind.Link, - href: linkTarget, - source: { - hrefText: link, - resource: getDocUri(document), - range: { start: linkStart, end: linkEnd }, - targetRange, - hrefRange, - ...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd), - } - }; + const isAngleBracketLink = rawLink.startsWith('<'); + const link = stripAngleBrackets(rawLink); + + let linkTarget: ExternalHref | InternalHref | undefined; + try { + linkTarget = createHref(getDocUri(document), link, workspace); + } catch { + return undefined; + } + if (!linkTarget) { + return undefined; + } + + const pre = targetText + preHrefText; + const linkStart = document.positionAt(matchIndex); + const linkEnd = translatePosition(linkStart, { characterDelta: fullMatch.length }); + + const targetStart = translatePosition(linkStart, { characterDelta: targetText.length }); + const targetRange: lsp.Range = { start: targetStart, end: linkEnd }; + + const hrefStart = translatePosition(linkStart, { characterDelta: pre.length + (isAngleBracketLink ? 1 : 0) }); + const hrefEnd = translatePosition(hrefStart, { characterDelta: link.length }); + const hrefRange: lsp.Range = { start: hrefStart, end: hrefEnd }; + + return { + kind: MdLinkKind.Link, + href: linkTarget, + source: { + hrefText: link, + resource: getDocUri(document), + range: { start: linkStart, end: linkEnd }, + targetRange, + hrefRange, + ...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd), + } + }; } function getFragmentRange(text: string, start: lsp.Position, end: lsp.Position): lsp.Range | undefined { - const index = text.indexOf('#'); - if (index < 0) { - return undefined; - } - return { start: translatePosition(start, { characterDelta: index + 1 }), end }; + const index = text.indexOf('#'); + if (index < 0) { + return undefined; + } + return { start: translatePosition(start, { characterDelta: index + 1 }), end }; } function getLinkSourceFragmentInfo(document: Document, link: string, linkStart: lsp.Position, linkEnd: lsp.Position): { fragmentRange: lsp.Range | undefined; pathText: string } { - const fragmentRange = getFragmentRange(link, linkStart, linkEnd); - return { - pathText: document.getText({ start: linkStart, end: fragmentRange ? translatePosition(fragmentRange.start, { characterDelta: -1 }) : linkEnd }), - fragmentRange, - }; + const fragmentRange = getFragmentRange(link, linkStart, linkEnd); + return { + pathText: document.getText({ start: linkStart, end: fragmentRange ? translatePosition(fragmentRange.start, { characterDelta: -1 }) : linkEnd }), + fragmentRange, + }; } const angleBracketLinkRe = /^<(.*)>$/; @@ -268,24 +268,24 @@ const angleBracketLinkRe = /^<(.*)>$/; * will be transformed to http://example.com */ function stripAngleBrackets(link: string) { - return link.replace(angleBracketLinkRe, '$1'); + return link.replace(angleBracketLinkRe, '$1'); } /** * Matches `[text](link)` or `[text]()` */ const linkPattern = new RegExp( - // text - r`(!?\[` + // open prefix match --> + // text + r`(!?\[` + // open prefix match --> /**/r`(?:` + /*****/r`[^\[\]\\]|` + // Non-bracket chars, or... /*****/r`\\.|` + // Escaped char, or... /*****/r`\[[^\[\]]*\]` + // Matched bracket pair /**/r`)*` + - r`\])` + // <-- close prefix match + r`\])` + // <-- close prefix match - // Destination - r`(\(\s*)` + // Pre href + // Destination + r`(\(\s*)` + // Pre href /**/r`(` + /*****/r`[^\s\(\)\<](?:[^\s\(\)]|\([^\s\(\)]*?\))*|` + // Link without whitespace, or... /*****/r`<[^<>]+>` + // In angle brackets @@ -293,15 +293,15 @@ const linkPattern = new RegExp( // Title /**/r`\s*(?:"[^"]*"|'[^']*'|\([^\(\)]*\))?\s*` + - r`\)`, - 'g'); + r`\)`, + 'g'); /** * Matches `[text][ref]` or `[shorthand]` or `[shorthand][]` */ const referenceLinkPattern = new RegExp( - r`(^|[^\]\\])` + // Must not start with another bracket (workaround for lack of support for negative look behinds) - r`(?:` + + r`(^|[^\]\\])` + // Must not start with another bracket (workaround for lack of support for negative look behinds) + r`(?:` + /**/r`(?:` + /****/r`(` + // Start link prefix /******/r`!?` + // Optional image ref @@ -316,8 +316,8 @@ const referenceLinkPattern = new RegExp( /******/r`[^\]]*?)\]` + //link def /******/r`|` + /******/r`\[\s*?([^\\\]]*?)\s*\])(?![\(])` + - r`)`, - 'gm'); + r`)`, + 'gm'); /** * Matches `` @@ -332,424 +332,424 @@ const definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)([^<]\S*|<[^> const inlineCodePattern = /(^|[^`])(`+)((?:.+?|.*?(?:(?:\r?\n).+?)*?)(?:\r?\n)?\2)(?:$|[^`])/gm; class NoLinkRanges { - public static compute(tokens: readonly Token[], document: Document): NoLinkRanges { - const multiline = tokens - .filter(t => (t.type === 'CodeBlock' || t.type === 'RawBlock' || isDisplayMath(t))) - .map(t => ({ type: t.type, range: [t.range.start.line, t.range.end.line] as [number,number]})); - - const inlineRanges = new Map(); - const text = document.getText(); - for (const match of text.matchAll(inlineCodePattern)) { - const startOffset = (match.index ?? 0) + match[1].length; - const startPosition = document.positionAt(startOffset); - - const range: lsp.Range = { start: startPosition, end: document.positionAt(startOffset + match[3].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( - /** - * Block element ranges, such as code blocks. Represented by [line_start, line_end). - */ - public readonly multiline: ReadonlyArray<{ type: TokenType, range: [number, number] }>, - - /** - * Inline code spans where links should not be detected - */ - public readonly inline: ReadonlyMap - ) { } - - contains(position: lsp.Position, excludeType = ''): boolean { - return this.multiline.some(({ type, range }) => type !== excludeType && position.line >= range[0] && position.line < range[1]) || - !!this.inline.get(position.line)?.some(inlineRange => rangeContains(inlineRange, position)); - } - - concatInline(inlineRanges: Iterable): 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); - } + public static compute(tokens: readonly Token[], document: Document): NoLinkRanges { + const multiline = tokens + .filter(t => (t.type === 'CodeBlock' || t.type === 'RawBlock' || isDisplayMath(t))) + .map(t => ({ type: t.type, range: [t.range.start.line, t.range.end.line] as [number, number] })); + + const inlineRanges = new Map(); + const text = document.getText(); + for (const match of text.matchAll(inlineCodePattern)) { + const startOffset = (match.index ?? 0) + match[1].length; + const startPosition = document.positionAt(startOffset); + + const range: lsp.Range = { start: startPosition, end: document.positionAt(startOffset + match[3].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( + /** + * Block element ranges, such as code blocks. Represented by [line_start, line_end). + */ + public readonly multiline: ReadonlyArray<{ type: TokenType, range: [number, number] }>, + + /** + * Inline code spans where links should not be detected + */ + public readonly inline: ReadonlyMap + ) { } + + contains(position: lsp.Position, excludeType = ''): boolean { + return this.multiline.some(({ type, range }) => type !== excludeType && position.line >= range[0] && position.line < range[1]) || + !!this.inline.get(position.line)?.some(inlineRange => rangeContains(inlineRange, position)); + } + + concatInline(inlineRanges: Iterable): 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); + } } /** * The place a document link links to. */ export type ResolvedDocumentLinkTarget = - | { readonly kind: 'file'; readonly uri: URI; position?: lsp.Position; fragment?: string } - | { readonly kind: 'folder'; readonly uri: URI } - | { readonly kind: 'external'; readonly uri: URI }; + | { readonly kind: 'file'; readonly uri: URI; position?: lsp.Position; fragment?: string } + | { readonly kind: 'folder'; readonly uri: URI } + | { readonly kind: 'external'; readonly uri: URI }; /** * Stateless object that extracts link information from markdown files. */ export class MdLinkComputer { - readonly #parser: Parser; - readonly #workspace: IWorkspace; - - constructor( - parser: Parser, - workspace: IWorkspace, - ) { - this.#parser = parser; - this.#workspace = workspace; - } - - public async getAllLinks(document: Document, token: CancellationToken): Promise { - const tokens = this.#parser(document); - if (token.isCancellationRequested) { - return []; - } - - const noLinkRanges = NoLinkRanges.compute(tokens, document); - - const inlineLinks = Array.from(this.#getInlineLinks(document, noLinkRanges)); - return [ - ...inlineLinks, - ...this.#getReferenceLinks(document, noLinkRanges.concatInline(inlineLinks.map(x => x.source.range))), - ...this.#getLinkDefinitions(document, noLinkRanges), - ...this.#getAutoLinks(document, noLinkRanges), - ...this.#getHtmlLinks(document, noLinkRanges), - ]; - } - - *#getInlineLinks(document: Document, noLinkRanges: NoLinkRanges): Iterable { - const text = document.getText(); - for (const match of text.matchAll(linkPattern)) { - const linkTextIncludingBrackets = match[1]; - const matchLinkData = createMdLink(document, linkTextIncludingBrackets, match[2], match[3], match.index ?? 0, match[0], this.#workspace); - if (matchLinkData && !noLinkRanges.contains(matchLinkData.source.hrefRange.start)) { - yield matchLinkData; - - // Also check for images in link text - if (/![[(]/.test(linkTextIncludingBrackets)) { - const linkText = linkTextIncludingBrackets.slice(1, -1); - const startOffset = (match.index ?? 0) + 1; - for (const innerMatch of linkText.matchAll(linkPattern)) { - const innerData = createMdLink(document, innerMatch[1], innerMatch[2], innerMatch[3], startOffset + (innerMatch.index ?? 0), innerMatch[0], this.#workspace); - if (innerData) { - yield innerData; - } - } - - yield* this.#getReferenceLinksInText(document, linkText, startOffset, noLinkRanges); - } - } - } - } - - *#getAutoLinks(document: Document, noLinkRanges: NoLinkRanges): Iterable { - const text = document.getText(); - const docUri = getDocUri(document); - 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 = createHref(docUri, link, this.#workspace); - if (!linkTarget) { - continue; - } - - const linkEnd = translatePosition(linkStart, { characterDelta: match[0].length }); - const hrefStart = translatePosition(linkStart, { characterDelta: 1 }); - const hrefEnd = translatePosition(hrefStart, { characterDelta: link.length }); - const hrefRange = { start: hrefStart, end: hrefEnd }; - yield { - kind: MdLinkKind.Link, - href: linkTarget, - source: { - hrefText: link, - resource: docUri, - targetRange: hrefRange, - hrefRange: hrefRange, - range: { start: linkStart, end: linkEnd }, - ...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd), - } - }; - } - } - - #getReferenceLinks(document: Document, noLinkRanges: NoLinkRanges): Iterable { - const text = document.getText(); - return this.#getReferenceLinksInText(document, text, 0, noLinkRanges); - } - - *#getReferenceLinksInText(document: Document, text: string, startingOffset: number, noLinkRanges: NoLinkRanges): Iterable { - for (const match of text.matchAll(referenceLinkPattern)) { - const linkStartOffset = startingOffset + (match.index ?? 0) + match[1].length; - const linkStart = document.positionAt(linkStartOffset); - if (noLinkRanges.contains(linkStart)) { - continue; - } - - let hrefStart: lsp.Position; - let hrefEnd: lsp.Position; - let reference = match[4]; - if (reference === '') { // [ref][], - reference = match[3]; - if (!reference) { - continue; - } - const offset = linkStartOffset + 1; - hrefStart = document.positionAt(offset); - hrefEnd = document.positionAt(offset + reference.length); - } else if (reference) { // [text][ref] - const text = match[3]; - if (!text) { - // Handle the case ![][cat] - if (!match[0].startsWith('!')) { - // Empty links are not valid - continue; - } - } - if (!match[0].startsWith('!')) { - // Also get links in text - yield* this.#getReferenceLinksInText(document, match[3], linkStartOffset + 1, noLinkRanges); - } - - 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 link definition - if (linkStart.character === 0 && line[match[0].length - match[1].length] === ':') { - continue; - } - - // 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 = translatePosition(linkStart, { characterDelta: match[0].length - match[1].length }); - const hrefRange = { start: hrefStart, end: hrefEnd }; - yield { - kind: MdLinkKind.Link, - source: { - hrefText: reference, - pathText: reference, - resource: getDocUri(document), - range: { start: linkStart, end: linkEnd }, - targetRange: hrefRange, - hrefRange: hrefRange, - fragmentRange: undefined, - }, - href: { - kind: HrefKind.Reference, - ref: reference, - } - }; - } - } - - *#getLinkDefinitions(document: Document, noLinkRanges: NoLinkRanges): Iterable { - const text = document.getText(); - const docUri = getDocUri(document); - 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 isAngleBracketLink = angleBracketLinkRe.test(rawLinkText); - const linkText = stripAngleBrackets(rawLinkText); - - const target = createHref(docUri, linkText, this.#workspace); - if (!target) { - continue; - } - - const hrefStart = translatePosition(linkStart, { characterDelta: pre.length + (isAngleBracketLink ? 1 : 0) }); - const hrefEnd = translatePosition(hrefStart, { characterDelta: linkText.length }); - const hrefRange = { start: hrefStart, end: hrefEnd }; - - const refStart = translatePosition(linkStart, { characterDelta: 1 }); - const refRange: lsp.Range = { start: refStart, end: translatePosition(refStart, { characterDelta: reference.length }) }; - const line = getLine(document, linkStart.line); - const linkEnd = translatePosition(linkStart, { characterDelta: line.length }); - yield { - kind: MdLinkKind.Definition, - source: { - hrefText: linkText, - resource: docUri, - range: { start: linkStart, end: linkEnd }, - targetRange: hrefRange, - hrefRange, - ...getLinkSourceFragmentInfo(document, rawLinkText, hrefStart, hrefEnd), - }, - ref: { text: reference, range: refRange }, - href: target, - }; - } - } - - #getHtmlLinks(document: Document, noLinkRanges: NoLinkRanges): Iterable { - const text = document.getText(); - if (!/<\w/.test(text)) { // Only parse if there may be html - return []; - } - - try { - const tree = parse(text); - return this.#getHtmlLinksFromNode(document, tree, noLinkRanges); - } catch { - return []; - } - } - - static #toAttrEntry(attr: string) { - return { attr, regexp: new RegExp(`(${attr}=["'])([^'"]*)["']`, 'i') }; - } - - static readonly #linkAttrsByTag = new Map([ - ['IMG', ['src'].map(this.#toAttrEntry)], - ['VIDEO', ['src', 'placeholder'].map(this.#toAttrEntry)], - ['SOURCE', ['src'].map(this.#toAttrEntry)], - ['A', ['href'].map(this.#toAttrEntry)], - ]); - - *#getHtmlLinksFromNode(document: Document, node: HTMLElement, noLinkRanges: NoLinkRanges): Iterable { - const attrs = MdLinkComputer.#linkAttrsByTag.get(node.tagName); - if (attrs) { - for (const attr of attrs) { - const link = node.attributes[attr.attr]; - if (!link) { - continue; - } - - const attrMatch = node.outerHTML.match(attr.regexp); - if (!attrMatch) { - continue; - } - - const docUri = getDocUri(document); - const linkTarget = createHref(docUri, link, this.#workspace); - if (!linkTarget) { - continue; - } - - const linkStart = document.positionAt(node.range[0] + attrMatch.index! + attrMatch[1].length); - if (noLinkRanges.contains(linkStart, 'html_block')) { - continue; - } - - const linkEnd = translatePosition(linkStart, { characterDelta: attrMatch[2].length }); - const hrefRange = { start: linkStart, end: linkEnd }; - yield { - kind: MdLinkKind.Link, - href: linkTarget, - source: { - hrefText: link, - resource: docUri, - targetRange: hrefRange, - hrefRange: hrefRange, - range: { start: linkStart, end: linkEnd }, - ...getLinkSourceFragmentInfo(document, link, linkStart, linkEnd), - } - }; - } - } - - for (const child of node.childNodes) { - if (child instanceof HTMLElement) { - yield* this.#getHtmlLinksFromNode(document, child, noLinkRanges); - } - } - } + readonly #parser: Parser; + readonly #workspace: IWorkspace; + + constructor( + parser: Parser, + workspace: IWorkspace, + ) { + this.#parser = parser; + this.#workspace = workspace; + } + + public async getAllLinks(document: Document, token: CancellationToken): Promise { + const tokens = this.#parser(document); + if (token.isCancellationRequested) { + return []; + } + + const noLinkRanges = NoLinkRanges.compute(tokens, document); + + const inlineLinks = Array.from(this.#getInlineLinks(document, noLinkRanges)); + return [ + ...inlineLinks, + ...this.#getReferenceLinks(document, noLinkRanges.concatInline(inlineLinks.map(x => x.source.range))), + ...this.#getLinkDefinitions(document, noLinkRanges), + ...this.#getAutoLinks(document, noLinkRanges), + ...this.#getHtmlLinks(document, noLinkRanges), + ]; + } + + *#getInlineLinks(document: Document, noLinkRanges: NoLinkRanges): Iterable { + const text = document.getText(); + for (const match of text.matchAll(linkPattern)) { + const linkTextIncludingBrackets = match[1]; + const matchLinkData = createMdLink(document, linkTextIncludingBrackets, match[2], match[3], match.index ?? 0, match[0], this.#workspace); + if (matchLinkData && !noLinkRanges.contains(matchLinkData.source.hrefRange.start)) { + yield matchLinkData; + + // Also check for images in link text + if (/![[(]/.test(linkTextIncludingBrackets)) { + const linkText = linkTextIncludingBrackets.slice(1, -1); + const startOffset = (match.index ?? 0) + 1; + for (const innerMatch of linkText.matchAll(linkPattern)) { + const innerData = createMdLink(document, innerMatch[1], innerMatch[2], innerMatch[3], startOffset + (innerMatch.index ?? 0), innerMatch[0], this.#workspace); + if (innerData) { + yield innerData; + } + } + + yield* this.#getReferenceLinksInText(document, linkText, startOffset, noLinkRanges); + } + } + } + } + + *#getAutoLinks(document: Document, noLinkRanges: NoLinkRanges): Iterable { + const text = document.getText(); + const docUri = getDocUri(document); + 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 = createHref(docUri, link, this.#workspace); + if (!linkTarget) { + continue; + } + + const linkEnd = translatePosition(linkStart, { characterDelta: match[0].length }); + const hrefStart = translatePosition(linkStart, { characterDelta: 1 }); + const hrefEnd = translatePosition(hrefStart, { characterDelta: link.length }); + const hrefRange = { start: hrefStart, end: hrefEnd }; + yield { + kind: MdLinkKind.Link, + href: linkTarget, + source: { + hrefText: link, + resource: docUri, + targetRange: hrefRange, + hrefRange: hrefRange, + range: { start: linkStart, end: linkEnd }, + ...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd), + } + }; + } + } + + #getReferenceLinks(document: Document, noLinkRanges: NoLinkRanges): Iterable { + const text = document.getText(); + return this.#getReferenceLinksInText(document, text, 0, noLinkRanges); + } + + *#getReferenceLinksInText(document: Document, text: string, startingOffset: number, noLinkRanges: NoLinkRanges): Iterable { + for (const match of text.matchAll(referenceLinkPattern)) { + const linkStartOffset = startingOffset + (match.index ?? 0) + match[1].length; + const linkStart = document.positionAt(linkStartOffset); + if (noLinkRanges.contains(linkStart)) { + continue; + } + + let hrefStart: lsp.Position; + let hrefEnd: lsp.Position; + let reference = match[4]; + if (reference === '') { // [ref][], + reference = match[3]; + if (!reference) { + continue; + } + const offset = linkStartOffset + 1; + hrefStart = document.positionAt(offset); + hrefEnd = document.positionAt(offset + reference.length); + } else if (reference) { // [text][ref] + const text = match[3]; + if (!text) { + // Handle the case ![][cat] + if (!match[0].startsWith('!')) { + // Empty links are not valid + continue; + } + } + if (!match[0].startsWith('!')) { + // Also get links in text + yield* this.#getReferenceLinksInText(document, match[3], linkStartOffset + 1, noLinkRanges); + } + + 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 link definition + if (linkStart.character === 0 && line[match[0].length - match[1].length] === ':') { + continue; + } + + // 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 = translatePosition(linkStart, { characterDelta: match[0].length - match[1].length }); + const hrefRange = { start: hrefStart, end: hrefEnd }; + yield { + kind: MdLinkKind.Link, + source: { + hrefText: reference, + pathText: reference, + resource: getDocUri(document), + range: { start: linkStart, end: linkEnd }, + targetRange: hrefRange, + hrefRange: hrefRange, + fragmentRange: undefined, + }, + href: { + kind: HrefKind.Reference, + ref: reference, + } + }; + } + } + + *#getLinkDefinitions(document: Document, noLinkRanges: NoLinkRanges): Iterable { + const text = document.getText(); + const docUri = getDocUri(document); + 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 isAngleBracketLink = angleBracketLinkRe.test(rawLinkText); + const linkText = stripAngleBrackets(rawLinkText); + + const target = createHref(docUri, linkText, this.#workspace); + if (!target) { + continue; + } + + const hrefStart = translatePosition(linkStart, { characterDelta: pre.length + (isAngleBracketLink ? 1 : 0) }); + const hrefEnd = translatePosition(hrefStart, { characterDelta: linkText.length }); + const hrefRange = { start: hrefStart, end: hrefEnd }; + + const refStart = translatePosition(linkStart, { characterDelta: 1 }); + const refRange: lsp.Range = { start: refStart, end: translatePosition(refStart, { characterDelta: reference.length }) }; + const line = getLine(document, linkStart.line); + const linkEnd = translatePosition(linkStart, { characterDelta: line.length }); + yield { + kind: MdLinkKind.Definition, + source: { + hrefText: linkText, + resource: docUri, + range: { start: linkStart, end: linkEnd }, + targetRange: hrefRange, + hrefRange, + ...getLinkSourceFragmentInfo(document, rawLinkText, hrefStart, hrefEnd), + }, + ref: { text: reference, range: refRange }, + href: target, + }; + } + } + + #getHtmlLinks(document: Document, noLinkRanges: NoLinkRanges): Iterable { + const text = document.getText(); + if (!/<\w/.test(text)) { // Only parse if there may be html + return []; + } + + try { + const tree = parse(text); + return this.#getHtmlLinksFromNode(document, tree, noLinkRanges); + } catch { + return []; + } + } + + static #toAttrEntry(attr: string) { + return { attr, regexp: new RegExp(`(${attr}=["'])([^'"]*)["']`, 'i') }; + } + + static readonly #linkAttrsByTag = new Map([ + ['IMG', ['src'].map(this.#toAttrEntry)], + ['VIDEO', ['src', 'placeholder'].map(this.#toAttrEntry)], + ['SOURCE', ['src'].map(this.#toAttrEntry)], + ['A', ['href'].map(this.#toAttrEntry)], + ]); + + *#getHtmlLinksFromNode(document: Document, node: HTMLElement, noLinkRanges: NoLinkRanges): Iterable { + const attrs = MdLinkComputer.#linkAttrsByTag.get(node.tagName); + if (attrs) { + for (const attr of attrs) { + const link = node.attributes[attr.attr]; + if (!link) { + continue; + } + + const attrMatch = node.outerHTML.match(attr.regexp); + if (!attrMatch) { + continue; + } + + const docUri = getDocUri(document); + const linkTarget = createHref(docUri, link, this.#workspace); + if (!linkTarget) { + continue; + } + + const linkStart = document.positionAt(node.range[0] + attrMatch.index! + attrMatch[1].length); + if (noLinkRanges.contains(linkStart, 'html_block')) { + continue; + } + + const linkEnd = translatePosition(linkStart, { characterDelta: attrMatch[2].length }); + const hrefRange = { start: linkStart, end: linkEnd }; + yield { + kind: MdLinkKind.Link, + href: linkTarget, + source: { + hrefText: link, + resource: docUri, + targetRange: hrefRange, + hrefRange: hrefRange, + range: { start: linkStart, end: linkEnd }, + ...getLinkSourceFragmentInfo(document, link, linkStart, linkEnd), + } + }; + } + } + + for (const child of node.childNodes) { + if (child instanceof HTMLElement) { + yield* this.#getHtmlLinksFromNode(document, child, noLinkRanges); + } + } + } } export interface MdDocumentLinksInfo { - readonly links: readonly MdLink[]; - readonly definitions: LinkDefinitionSet; + readonly links: readonly MdLink[]; + readonly definitions: LinkDefinitionSet; } export class ReferenceLinkMap { - readonly #map = new Map(); - - public set(ref: string, link: T) { - this.#map.set(this.#normalizeRefName(ref), link); - } - - public lookup(ref: string): T | undefined { - return this.#map.get(this.#normalizeRefName(ref)); - } - - public has(ref: string): boolean { - return this.#map.has(this.#normalizeRefName(ref)); - } - - public [Symbol.iterator](): Iterator { - return this.#map.values(); - } - - /** - * Normalizes a link reference. Link references are case-insensitive, so this lowercases the reference too so you can - * correctly compare two normalized references. - */ - #normalizeRefName(ref: string): string { - return ref.normalize().trim().toLowerCase(); - } + readonly #map = new Map(); + + public set(ref: string, link: T) { + this.#map.set(this.#normalizeRefName(ref), link); + } + + public lookup(ref: string): T | undefined { + return this.#map.get(this.#normalizeRefName(ref)); + } + + public has(ref: string): boolean { + return this.#map.has(this.#normalizeRefName(ref)); + } + + public [Symbol.iterator](): Iterator { + return this.#map.values(); + } + + /** + * Normalizes a link reference. Link references are case-insensitive, so this lowercases the reference too so you can + * correctly compare two normalized references. + */ + #normalizeRefName(ref: string): string { + return ref.normalize().trim().toLowerCase(); + } } export class LinkDefinitionSet implements Iterable { - readonly #map = new ReferenceLinkMap(); - - constructor(links: Iterable) { - for (const link of links) { - if (link.kind === MdLinkKind.Definition) { - if (!this.#map.has(link.ref.text)) { - this.#map.set(link.ref.text, link); - } - } - } - } - - public [Symbol.iterator](): Iterator { - return this.#map[Symbol.iterator](); - } - - public lookup(ref: string): MdLinkDefinition | undefined { - return this.#map.lookup(ref); - } + readonly #map = new ReferenceLinkMap(); + + constructor(links: Iterable) { + for (const link of links) { + if (link.kind === MdLinkKind.Definition) { + if (!this.#map.has(link.ref.text)) { + this.#map.set(link.ref.text, link); + } + } + } + } + + public [Symbol.iterator](): Iterator { + return this.#map[Symbol.iterator](); + } + + public lookup(ref: string): MdLinkDefinition | undefined { + return this.#map.lookup(ref); + } } /** @@ -757,261 +757,261 @@ export class LinkDefinitionSet implements Iterable { */ export class MdLinkProvider extends Disposable { - readonly #linkCache: MdDocumentInfoCache; - - readonly #linkComputer: MdLinkComputer; - readonly #config: LsConfiguration; - readonly #workspace: IWorkspace; - readonly #tocProvider: MdTableOfContentsProvider; - - constructor( - config: LsConfiguration, - parser: Parser, - workspace: IWorkspace, - tocProvider: MdTableOfContentsProvider, - logger: ILogger, - ) { - super(); - - this.#config = config; - this.#workspace = workspace; - this.#tocProvider = tocProvider; - - this.#linkComputer = new MdLinkComputer(parser, this.#workspace); - this.#linkCache = this._register(new MdDocumentInfoCache(this.#workspace, async (doc, token) => { - logger.log(LogLevel.Debug, 'LinkProvider.compute', { document: doc.uri, version: doc.version }); - - const links = await this.#linkComputer.getAllLinks(doc, token); - return { - links, - definitions: new LinkDefinitionSet(links), - }; - })); - } - - public getLinks(document: Document): Promise { - return this.#linkCache.getForDocument(document); - } - - public async provideDocumentLinks(document: Document, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return []; - } - const { links, definitions } = await this.getLinks(document); - if (token.isCancellationRequested) { - return []; - } - - return coalesce(links.map(data => this.#toValidDocumentLink(data, definitions))); - } - - public async resolveDocumentLink(link: lsp.DocumentLink, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return undefined; - } - - const href = this.#reviveLinkHrefData(link); - if (!href) { - return undefined; - } - - const target = await this.#resolveInternalLinkTarget(href.path, href.fragment, token); - switch (target.kind) { - case 'folder': - link.target = this.#createCommandUri('revealInExplorer', href.path); - break; - case 'external': - link.target = target.uri.toString(true); - break; - case 'file': - if (target.position) { - link.target = this.#createOpenAtPosCommand(target.uri, target.position); - } else { - link.target = target.uri.toString(true); - } - break; - } - - return link; - } - - public async resolveLinkTarget(linkText: string, sourceDoc: URI, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return undefined; - } - - const href = createHref(sourceDoc, linkText, this.#workspace); - if (href?.kind !== HrefKind.Internal) { - return undefined; - } - - const resolved = resolveInternalDocumentLink(sourceDoc, linkText, this.#workspace); - if (!resolved) { - return undefined; - } - - return this.#resolveInternalLinkTarget(resolved.resource, resolved.linkFragment, token); - } - - async #resolveInternalLinkTarget(linkPath: URI, linkFragment: string, token: CancellationToken): Promise { - let target = linkPath; - - // If there's a containing document, don't bother with trying to resolve the - // link to a workspace file as one will not exist - const containingContext = this.#workspace.getContainingDocument?.(target); - if (!containingContext) { - const stat = await this.#workspace.stat(target); - if (stat?.isDirectory) { - return { kind: 'folder', uri: target }; - } - - if (token.isCancellationRequested) { - return { kind: 'folder', uri: target }; - } - - if (!stat) { - // We don't think the file exists. If it doesn't already have an extension, try tacking on a `.md` and using that instead - let found = false; - const dotMdResource = tryAppendMarkdownFileExtension(this.#config, target); - if (dotMdResource) { - if (await this.#workspace.stat(dotMdResource)) { - target = dotMdResource; - found = true; - } - } - - if (!found) { - return { kind: 'file', uri: target }; - } - } - } - - if (!linkFragment) { - return { kind: 'file', uri: target }; - } - - // Try navigating with fragment that sets line number - const locationLinkPosition = parseLocationInfoFromFragment(linkFragment); - if (locationLinkPosition) { - return { kind: 'file', uri: target, position: locationLinkPosition }; - } - - // Try navigating to header in file - const doc = await this.#workspace.openMarkdownDocument(target); - if (token.isCancellationRequested) { - return { kind: 'file', uri: target }; - } - - if (doc) { - const toc = await this.#tocProvider.getForContainingDoc(doc, token); - const entry = toc.lookup(linkFragment); - if (isTocHeaderEntry(entry)) { - return { kind: 'file', uri: URI.parse(entry.headerLocation.uri), position: entry.headerLocation.range.start, fragment: linkFragment }; - } - } - - return { kind: 'file', uri: target }; - } - - #reviveLinkHrefData(link: lsp.DocumentLink): { path: URI, fragment: string } | undefined { - if (!link.data) { - return undefined; - } - - const mdLink = link.data as MdLink; - if (mdLink.href.kind !== HrefKind.Internal) { - return undefined; - } - - return { path: URI.from(mdLink.href.path), fragment: mdLink.href.fragment }; - } - - #toValidDocumentLink(link: MdLink, definitionSet: LinkDefinitionSet): lsp.DocumentLink | undefined { - switch (link.href.kind) { - case HrefKind.External: { - return { - range: link.source.hrefRange, - target: link.href.uri.toString(true), - }; - } - case HrefKind.Internal: { - return { - range: link.source.hrefRange, - target: undefined, // Needs to be resolved later - tooltip: l10n.t('Follow link'), - data: link, - }; - } - case HrefKind.Reference: { - // We only render reference links in the editor if they are actually defined. - // This matches how reference links are rendered by markdown-it. - const def = definitionSet.lookup(link.href.ref); - if (!def) { - return undefined; - } - - const target = this.#createOpenAtPosCommand(link.source.resource, def.source.hrefRange.start); - return { - range: link.source.hrefRange, - tooltip: l10n.t('Go to link definition'), - target: target, - data: link - }; - } - } - } - - #createCommandUri(command: string, ...args: unknown[]): string { - return `command:${command}?${encodeURIComponent(JSON.stringify(args))}`; - } - - #createOpenAtPosCommand(resource: URI, pos: lsp.Position): string { - // If the resource itself already has a fragment, we need to handle opening specially - // instead of using `file://path.md#L123` style uris - if (resource.fragment) { - // Match the args of `vscode.open` - return this.#createCommandUri('quartoLanguageservice.open', resource, { - selection: makeRange(pos, pos), - }); - } - - return resource.with({ - fragment: `L${pos.line + 1},${pos.character + 1}` - }).toString(true); - } + readonly #linkCache: MdDocumentInfoCache; + + readonly #linkComputer: MdLinkComputer; + readonly #config: LsConfiguration; + readonly #workspace: IWorkspace; + readonly #tocProvider: MdTableOfContentsProvider; + + constructor( + config: LsConfiguration, + parser: Parser, + workspace: IWorkspace, + tocProvider: MdTableOfContentsProvider, + logger: ILogger, + ) { + super(); + + this.#config = config; + this.#workspace = workspace; + this.#tocProvider = tocProvider; + + this.#linkComputer = new MdLinkComputer(parser, this.#workspace); + this.#linkCache = this._register(new MdDocumentInfoCache(this.#workspace, async (doc, token) => { + logger.log(LogLevel.Debug, 'LinkProvider.compute', { document: doc.uri, version: doc.version }); + + const links = await this.#linkComputer.getAllLinks(doc, token); + return { + links, + definitions: new LinkDefinitionSet(links), + }; + })); + } + + public getLinks(document: Document): Promise { + return this.#linkCache.getForDocument(document); + } + + public async provideDocumentLinks(document: Document, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return []; + } + const { links, definitions } = await this.getLinks(document); + if (token.isCancellationRequested) { + return []; + } + + return coalesce(links.map(data => this.#toValidDocumentLink(data, definitions))); + } + + public async resolveDocumentLink(link: lsp.DocumentLink, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + + const href = this.#reviveLinkHrefData(link); + if (!href) { + return undefined; + } + + const target = await this.#resolveInternalLinkTarget(href.path, href.fragment, token); + switch (target.kind) { + case 'folder': + link.target = this.#createCommandUri('revealInExplorer', href.path); + break; + case 'external': + link.target = target.uri.toString(true); + break; + case 'file': + if (target.position) { + link.target = this.#createOpenAtPosCommand(target.uri, target.position); + } else { + link.target = target.uri.toString(true); + } + break; + } + + return link; + } + + public async resolveLinkTarget(linkText: string, sourceDoc: URI, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return undefined; + } + + const href = createHref(sourceDoc, linkText, this.#workspace); + if (href?.kind !== HrefKind.Internal) { + return undefined; + } + + const resolved = resolveInternalDocumentLink(sourceDoc, linkText, this.#workspace); + if (!resolved) { + return undefined; + } + + return this.#resolveInternalLinkTarget(resolved.resource, resolved.linkFragment, token); + } + + async #resolveInternalLinkTarget(linkPath: URI, linkFragment: string, token: CancellationToken): Promise { + let target = linkPath; + + // If there's a containing document, don't bother with trying to resolve the + // link to a workspace file as one will not exist + const containingContext = this.#workspace.getContainingDocument?.(target); + if (!containingContext) { + const stat = await this.#workspace.stat(target); + if (stat?.isDirectory) { + return { kind: 'folder', uri: target }; + } + + if (token.isCancellationRequested) { + return { kind: 'folder', uri: target }; + } + + if (!stat) { + // We don't think the file exists. If it doesn't already have an extension, try tacking on a `.md` and using that instead + let found = false; + const dotMdResource = tryAppendMarkdownFileExtension(this.#config, target); + if (dotMdResource) { + if (await this.#workspace.stat(dotMdResource)) { + target = dotMdResource; + found = true; + } + } + + if (!found) { + return { kind: 'file', uri: target }; + } + } + } + + if (!linkFragment) { + return { kind: 'file', uri: target }; + } + + // Try navigating with fragment that sets line number + const locationLinkPosition = parseLocationInfoFromFragment(linkFragment); + if (locationLinkPosition) { + return { kind: 'file', uri: target, position: locationLinkPosition }; + } + + // Try navigating to header in file + const doc = await this.#workspace.openMarkdownDocument(target); + if (token.isCancellationRequested) { + return { kind: 'file', uri: target }; + } + + if (doc) { + const toc = await this.#tocProvider.getForContainingDoc(doc, token); + const entry = toc.lookup(linkFragment); + if (isTocHeaderEntry(entry)) { + return { kind: 'file', uri: URI.parse(entry.headerLocation.uri), position: entry.headerLocation.range.start, fragment: linkFragment }; + } + } + + return { kind: 'file', uri: target }; + } + + #reviveLinkHrefData(link: lsp.DocumentLink): { path: URI, fragment: string } | undefined { + if (!link.data) { + return undefined; + } + + const mdLink = link.data as MdLink; + if (mdLink.href.kind !== HrefKind.Internal) { + return undefined; + } + + return { path: URI.from(mdLink.href.path), fragment: mdLink.href.fragment }; + } + + #toValidDocumentLink(link: MdLink, definitionSet: LinkDefinitionSet): lsp.DocumentLink | undefined { + switch (link.href.kind) { + case HrefKind.External: { + return { + range: link.source.hrefRange, + target: link.href.uri.toString(true), + }; + } + case HrefKind.Internal: { + return { + range: link.source.hrefRange, + target: undefined, // Needs to be resolved later + tooltip: l10n.t('Follow link'), + data: link, + }; + } + case HrefKind.Reference: { + // We only render reference links in the editor if they are actually defined. + // This matches how reference links are rendered by markdown-it. + const def = definitionSet.lookup(link.href.ref); + if (!def) { + return undefined; + } + + const target = this.#createOpenAtPosCommand(link.source.resource, def.source.hrefRange.start); + return { + range: link.source.hrefRange, + tooltip: l10n.t('Go to link definition'), + target: target, + data: link + }; + } + } + } + + #createCommandUri(command: string, ...args: unknown[]): string { + return `command:${command}?${encodeURIComponent(JSON.stringify(args))}`; + } + + #createOpenAtPosCommand(resource: URI, pos: lsp.Position): string { + // If the resource itself already has a fragment, we need to handle opening specially + // instead of using `file://path.md#L123` style uris + if (resource.fragment) { + // Match the args of `vscode.open` + return this.#createCommandUri('quartoLanguageservice.open', resource, { + selection: makeRange(pos, pos), + }); + } + + return resource.with({ + fragment: `L${pos.line + 1},${pos.character + 1}` + }).toString(true); + } } /** * Extract position info from link fragments that look like `#L5,3` */ export function parseLocationInfoFromFragment(fragment: string): lsp.Position | undefined { - const match = fragment.match(/^L(\d+)(?:,(\d+))?$/i); - if (!match) { - return undefined; - } - - const line = +match[1] - 1; - if (isNaN(line)) { - return undefined; - } - - const column = +match[2] - 1; - return { line, character: isNaN(column) ? 0 : column }; + const match = fragment.match(/^L(\d+)(?:,(\d+))?$/i); + if (!match) { + return undefined; + } + + const line = +match[1] - 1; + if (isNaN(line)) { + return undefined; + } + + const column = +match[2] - 1; + return { line, character: isNaN(column) ? 0 : column }; } export function createWorkspaceLinkCache( - parser: Parser, - workspace: IWorkspace, + parser: Parser, + workspace: IWorkspace, ) { - const linkComputer = new MdLinkComputer(parser, workspace); - return new MdWorkspaceInfoCache(workspace, (doc, token) => linkComputer.getAllLinks(doc, token)); + const linkComputer = new MdLinkComputer(parser, workspace); + return new MdWorkspaceInfoCache(workspace, (doc, token) => linkComputer.getAllLinks(doc, token)); } export function looksLikeLinkToResource(configuration: LsConfiguration, href: InternalHref, targetResource: URI): boolean { - if (href.path.fsPath === targetResource.fsPath) { - return true; - } + if (href.path.fsPath === targetResource.fsPath) { + return true; + } - return configuration.markdownFileExtensions.some(ext => - href.path.with({ path: href.path.path + '.' + ext }).fsPath === targetResource.fsPath); -} \ No newline at end of file + return configuration.markdownFileExtensions.some(ext => + href.path.with({ path: href.path.path + '.' + ext }).fsPath === targetResource.fsPath); +} diff --git a/apps/lsp/src/service/providers/document-symbols.ts b/apps/lsp/src/service/providers/document-symbols.ts index 28a5705c..ddd6e047 100644 --- a/apps/lsp/src/service/providers/document-symbols.ts +++ b/apps/lsp/src/service/providers/document-symbols.ts @@ -21,119 +21,119 @@ import { MdTableOfContentsProvider, TableOfContents, TocEntry, TocEntryType } fr import { MdLinkDefinition, MdLinkKind, MdLinkProvider } from './document-links'; interface MarkdownSymbol { - readonly level: number; - readonly parent: MarkdownSymbol | undefined; - readonly children: lsp.DocumentSymbol[]; - readonly range: lsp.Range; + readonly level: number; + readonly parent: MarkdownSymbol | undefined; + readonly children: lsp.DocumentSymbol[]; + readonly range: lsp.Range; } export interface ProvideDocumentSymbolOptions { - readonly includeLinkDefinitions?: boolean; + readonly includeLinkDefinitions?: boolean; } export class MdDocumentSymbolProvider { - readonly #tocProvider: MdTableOfContentsProvider; - readonly #linkProvider: MdLinkProvider; - readonly #logger: ILogger; - - constructor( - tocProvider: MdTableOfContentsProvider, - linkProvider: MdLinkProvider, - logger: ILogger, - ) { - this.#tocProvider = tocProvider; - this.#linkProvider = linkProvider; - this.#logger = logger; - } - - public async provideDocumentSymbols(document: Document, options: ProvideDocumentSymbolOptions, token: CancellationToken): Promise { - this.#logger.log(LogLevel.Debug, 'DocumentSymbolProvider.provideDocumentSymbols', { document: document.uri, version: document.version }); - - if (token.isCancellationRequested) { - return []; - } - - const linkSymbols = await (options.includeLinkDefinitions ? this.#provideLinkDefinitionSymbols(document, token) : []); - if (token.isCancellationRequested) { - return []; - } - - const toc = await this.#tocProvider.getForDocument(document); - if (token.isCancellationRequested) { - return []; - } - - return this.#toSymbolTree(document, linkSymbols, toc); - } - - #toSymbolTree(document: Document, linkSymbols: readonly lsp.DocumentSymbol[], toc: TableOfContents): lsp.DocumentSymbol[] { - const root: MarkdownSymbol = { - level: -Infinity, - children: [], - parent: undefined, - range: makeRange(0, 0, document.lineCount + 1, 0), - }; - const additionalSymbols = [...linkSymbols]; - this.#buildTocSymbolTree(root, toc.entries.filter(entry => entry.type !== TocEntryType.Title), additionalSymbols); - // Put remaining link definitions into top level document instead of last header - root.children.push(...additionalSymbols); - return root.children; - } - - async #provideLinkDefinitionSymbols(document: Document, token: CancellationToken): Promise { - const { links } = await this.#linkProvider.getLinks(document); - if (token.isCancellationRequested) { - return []; - } - - return links - .filter(link => link.kind === MdLinkKind.Definition) - .map((link): lsp.DocumentSymbol => this.#definitionToDocumentSymbol(link as MdLinkDefinition)); - } - - #definitionToDocumentSymbol(def: MdLinkDefinition): lsp.DocumentSymbol { - return { - kind: lsp.SymbolKind.Constant, - name: `[${def.ref.text}]`, - selectionRange: def.ref.range, - range: def.source.range, - }; - } - - #buildTocSymbolTree(parent: MarkdownSymbol, entries: readonly TocEntry[], additionalSymbols: lsp.DocumentSymbol[]): void { - if (entries.length) { - while (additionalSymbols.length && isBefore(additionalSymbols[0].range.end, entries[0].sectionLocation.range.start)) { - parent.children.push(additionalSymbols.shift()!); - } - } - - if (!entries.length) { - return; - } - - const entry = entries[0]; - const symbol = this.#tocToDocumentSymbol(entry); - symbol.children = []; - - while (entry.level <= parent.level) { - parent = parent.parent!; - } - parent.children.push(symbol); - - this.#buildTocSymbolTree({ level: entry.level, children: symbol.children, parent, range: entry.sectionLocation.range }, entries.slice(1), additionalSymbols); - } - - #tocToDocumentSymbol(entry: TocEntry): lsp.DocumentSymbol { - return { - name: this.#getTocSymbolName(entry), - kind: this.#getTocSymbolKind(entry), - range: entry.sectionLocation.range, - selectionRange: entry.sectionLocation.range - }; - } - - #getTocSymbolKind(entry: TocEntry): lsp.SymbolKind { + readonly #tocProvider: MdTableOfContentsProvider; + readonly #linkProvider: MdLinkProvider; + readonly #logger: ILogger; + + constructor( + tocProvider: MdTableOfContentsProvider, + linkProvider: MdLinkProvider, + logger: ILogger, + ) { + this.#tocProvider = tocProvider; + this.#linkProvider = linkProvider; + this.#logger = logger; + } + + public async provideDocumentSymbols(document: Document, options: ProvideDocumentSymbolOptions, token: CancellationToken): Promise { + this.#logger.log(LogLevel.Debug, 'DocumentSymbolProvider.provideDocumentSymbols', { document: document.uri, version: document.version }); + + if (token.isCancellationRequested) { + return []; + } + + const linkSymbols = await (options.includeLinkDefinitions ? this.#provideLinkDefinitionSymbols(document, token) : []); + if (token.isCancellationRequested) { + return []; + } + + const toc = await this.#tocProvider.getForDocument(document); + if (token.isCancellationRequested) { + return []; + } + + return this.#toSymbolTree(document, linkSymbols, toc); + } + + #toSymbolTree(document: Document, linkSymbols: readonly lsp.DocumentSymbol[], toc: TableOfContents): lsp.DocumentSymbol[] { + const root: MarkdownSymbol = { + level: -Infinity, + children: [], + parent: undefined, + range: makeRange(0, 0, document.lineCount + 1, 0), + }; + const additionalSymbols = [...linkSymbols]; + this.#buildTocSymbolTree(root, toc.entries.filter(entry => entry.type !== TocEntryType.Title), additionalSymbols); + // Put remaining link definitions into top level document instead of last header + root.children.push(...additionalSymbols); + return root.children; + } + + async #provideLinkDefinitionSymbols(document: Document, token: CancellationToken): Promise { + const { links } = await this.#linkProvider.getLinks(document); + if (token.isCancellationRequested) { + return []; + } + + return links + .filter(link => link.kind === MdLinkKind.Definition) + .map((link): lsp.DocumentSymbol => this.#definitionToDocumentSymbol(link as MdLinkDefinition)); + } + + #definitionToDocumentSymbol(def: MdLinkDefinition): lsp.DocumentSymbol { + return { + kind: lsp.SymbolKind.Constant, + name: `[${def.ref.text}]`, + selectionRange: def.ref.range, + range: def.source.range, + }; + } + + #buildTocSymbolTree(parent: MarkdownSymbol, entries: readonly TocEntry[], additionalSymbols: lsp.DocumentSymbol[]): void { + if (entries.length) { + while (additionalSymbols.length && isBefore(additionalSymbols[0].range.end, entries[0].sectionLocation.range.start)) { + parent.children.push(additionalSymbols.shift()!); + } + } + + if (!entries.length) { + return; + } + + const entry = entries[0]; + const symbol = this.#tocToDocumentSymbol(entry); + symbol.children = []; + + while (entry.level <= parent.level) { + parent = parent.parent!; + } + parent.children.push(symbol); + + this.#buildTocSymbolTree({ level: entry.level, children: symbol.children, parent, range: entry.sectionLocation.range }, entries.slice(1), additionalSymbols); + } + + #tocToDocumentSymbol(entry: TocEntry): lsp.DocumentSymbol { + return { + name: this.#getTocSymbolName(entry), + kind: this.#getTocSymbolKind(entry), + range: entry.sectionLocation.range, + selectionRange: entry.sectionLocation.range + }; + } + + #getTocSymbolKind(entry: TocEntry): lsp.SymbolKind { switch (entry.type) { case TocEntryType.Title: { return lsp.SymbolKind.File; @@ -147,7 +147,7 @@ export class MdDocumentSymbolProvider { } } - #getTocSymbolName(entry: TocEntry): string { - return entry.text || " "; - } + #getTocSymbolName(entry: TocEntry): string { + return entry.text || " "; + } } diff --git a/apps/lsp/src/service/providers/folding.ts b/apps/lsp/src/service/providers/folding.ts index 74fcdcb9..d6a9e6c2 100644 --- a/apps/lsp/src/service/providers/folding.ts +++ b/apps/lsp/src/service/providers/folding.ts @@ -24,165 +24,165 @@ import { isEmptyOrWhitespace } from '../util/string'; const rangeLimit = 5000; interface RegionMarker { - readonly line: number; - readonly isStart: boolean; + readonly line: number; + readonly isStart: boolean; } export class MdFoldingProvider { - readonly #parser: Parser; - readonly #tocProvider: MdTableOfContentsProvider; - readonly #logger: ILogger; - - constructor( - parser: Parser, - tocProvider: MdTableOfContentsProvider, - logger: ILogger, - ) { - this.#parser = parser; - this.#tocProvider = tocProvider; - this.#logger = logger; - } - - public async provideFoldingRanges(document: Document, token: CancellationToken): Promise { - this.#logger.log(LogLevel.Debug, 'MdFoldingProvider.provideFoldingRanges', { document: document.uri, version: document.version }); - - if (token.isCancellationRequested) { - return []; - } - - const foldables = await Promise.all([ - this.#getRegions(document, token), - this.#getBlockFoldingRanges(document, token), - this.#getHeaderFoldingRanges(document, token), - - ]); - const result = foldables.flat(); - return result.length > rangeLimit ? result.slice(0, rangeLimit) : result; - } - - async #getRegions(document: Document, token: CancellationToken): Promise { - const tokens = this.#parser(document); - if (token.isCancellationRequested) { - return []; - } - - return Array.from(this.#getRegionsFromTokens(tokens)); - } - - *#getRegionsFromTokens(tokens: readonly Token[]): Iterable { - const nestingStack: RegionMarker[] = []; - for (const token of tokens) { - const marker = asRegionMarker(token); - if (marker) { - if (marker.isStart) { - nestingStack.push(marker); - } else if (nestingStack.length && nestingStack[nestingStack.length - 1].isStart) { - yield { startLine: nestingStack.pop()!.line, endLine: marker.line, kind: lsp.FoldingRangeKind.Region }; - } else { - // noop: invalid nesting (i.e. [end, start] or [start, end, end]) - } - } - } - } - - async #getHeaderFoldingRanges(document: Document, token: CancellationToken): Promise { - const toc = await this.#tocProvider.getForDocument(document); - if (token.isCancellationRequested) { - return []; - } - - return toc.entries.filter(entry => isTocHeaderEntry(entry)).map((entry): lsp.FoldingRange => { - let endLine = entry.sectionLocation.range.end.line; - if (isEmptyOrWhitespace(getLine(document, endLine)) && endLine >= entry.line + 1) { - endLine = endLine - 1; - } - return { startLine: entry.line, endLine }; - }); - } - - async #getBlockFoldingRanges(document: Document, token: CancellationToken): Promise { - const tokens = this.#parser(document); - if (token.isCancellationRequested) { - return []; - } - return Array.from(this.#getBlockFoldingRangesFromTokens(document, tokens)); - } - - *#getBlockFoldingRangesFromTokens(document: Document, tokens: readonly Token[]): Iterable { - for (const token of tokens) { - if (isFoldableToken(token)) { - const startLine = token.range.start.line; - let endLine = token.range.end.line; - if (isEmptyOrWhitespace(getLine(document, endLine)) && endLine >= startLine + 1) { - endLine = endLine - 1; - } - - if (endLine > startLine) { - yield { startLine, endLine, kind: this.#getFoldingRangeKind(token) }; - } - } - } - } - - #getFoldingRangeKind(listItem: Token): lsp.FoldingRangeKind | undefined { - const html = asHtmlBlock(listItem); - return html && html.startsWith('!--') ? lsp.FoldingRangeKind.Comment : undefined; - } + readonly #parser: Parser; + readonly #tocProvider: MdTableOfContentsProvider; + readonly #logger: ILogger; + + constructor( + parser: Parser, + tocProvider: MdTableOfContentsProvider, + logger: ILogger, + ) { + this.#parser = parser; + this.#tocProvider = tocProvider; + this.#logger = logger; + } + + public async provideFoldingRanges(document: Document, token: CancellationToken): Promise { + this.#logger.log(LogLevel.Debug, 'MdFoldingProvider.provideFoldingRanges', { document: document.uri, version: document.version }); + + if (token.isCancellationRequested) { + return []; + } + + const foldables = await Promise.all([ + this.#getRegions(document, token), + this.#getBlockFoldingRanges(document, token), + this.#getHeaderFoldingRanges(document, token), + + ]); + const result = foldables.flat(); + return result.length > rangeLimit ? result.slice(0, rangeLimit) : result; + } + + async #getRegions(document: Document, token: CancellationToken): Promise { + const tokens = this.#parser(document); + if (token.isCancellationRequested) { + return []; + } + + return Array.from(this.#getRegionsFromTokens(tokens)); + } + + *#getRegionsFromTokens(tokens: readonly Token[]): Iterable { + const nestingStack: RegionMarker[] = []; + for (const token of tokens) { + const marker = asRegionMarker(token); + if (marker) { + if (marker.isStart) { + nestingStack.push(marker); + } else if (nestingStack.length && nestingStack[nestingStack.length - 1].isStart) { + yield { startLine: nestingStack.pop()!.line, endLine: marker.line, kind: lsp.FoldingRangeKind.Region }; + } else { + // noop: invalid nesting (i.e. [end, start] or [start, end, end]) + } + } + } + } + + async #getHeaderFoldingRanges(document: Document, token: CancellationToken): Promise { + const toc = await this.#tocProvider.getForDocument(document); + if (token.isCancellationRequested) { + return []; + } + + return toc.entries.filter(entry => isTocHeaderEntry(entry)).map((entry): lsp.FoldingRange => { + let endLine = entry.sectionLocation.range.end.line; + if (isEmptyOrWhitespace(getLine(document, endLine)) && endLine >= entry.line + 1) { + endLine = endLine - 1; + } + return { startLine: entry.line, endLine }; + }); + } + + async #getBlockFoldingRanges(document: Document, token: CancellationToken): Promise { + const tokens = this.#parser(document); + if (token.isCancellationRequested) { + return []; + } + return Array.from(this.#getBlockFoldingRangesFromTokens(document, tokens)); + } + + *#getBlockFoldingRangesFromTokens(document: Document, tokens: readonly Token[]): Iterable { + for (const token of tokens) { + if (isFoldableToken(token)) { + const startLine = token.range.start.line; + let endLine = token.range.end.line; + if (isEmptyOrWhitespace(getLine(document, endLine)) && endLine >= startLine + 1) { + endLine = endLine - 1; + } + + if (endLine > startLine) { + yield { startLine, endLine, kind: this.#getFoldingRangeKind(token) }; + } + } + } + } + + #getFoldingRangeKind(listItem: Token): lsp.FoldingRangeKind | undefined { + const html = asHtmlBlock(listItem); + return html && html.startsWith('!--') ? lsp.FoldingRangeKind.Comment : undefined; + } } function isStartRegion(t: string) { return /^\s*/.test(t); } function isEndRegion(t: string) { return /^\s*/.test(t); } function asRegionMarker(token: Token): RegionMarker | undefined { - const html = asHtmlBlock(token); - if (html === undefined) { - return undefined; - } - - if (isStartRegion(html)) { - return { line: token.range.start.line, isStart: true }; - } - - if (isEndRegion(html)) { - return { line: token.range.start.line, isStart: false }; - } - - return undefined; + const html = asHtmlBlock(token); + if (html === undefined) { + return undefined; + } + + if (isStartRegion(html)) { + return { line: token.range.start.line, isStart: true }; + } + + if (isEndRegion(html)) { + return { line: token.range.start.line, isStart: false }; + } + + return undefined; } -function asHtmlBlock(token: Token) : string | undefined { - if (!(isRawBlock(token))) { - return undefined; - } - if (token.data.format !== "html") { - return undefined; - } - return token.data.text; +function asHtmlBlock(token: Token): string | undefined { + if (!(isRawBlock(token))) { + return undefined; + } + if (token.data.format !== "html") { + return undefined; + } + return token.data.text; } function isFoldableToken(token: Token) { - switch (token.type) { - case 'FrontMatter': - case 'CodeBlock': - case 'Div': - case 'BlockQuote': - case 'Table': - case 'OrderedList': - case 'BulletList': - return token.range.end.line > token.range.start.line; - - case 'Math': - return isDisplayMath(token) && token.range.end.line > token.range.start.line; - - case 'RawBlock': - if (asRegionMarker(token)) { - return false; - } - return token.range.end.line > token.range.start.line + 1; - - default: - return false; - } + switch (token.type) { + case 'FrontMatter': + case 'CodeBlock': + case 'Div': + case 'BlockQuote': + case 'Table': + case 'OrderedList': + case 'BulletList': + return token.range.end.line > token.range.start.line; + + case 'Math': + return isDisplayMath(token) && token.range.end.line > token.range.start.line; + + case 'RawBlock': + if (asRegionMarker(token)) { + return false; + } + return token.range.end.line > token.range.start.line + 1; + + default: + return false; + } } diff --git a/apps/lsp/src/service/providers/hover/hover-image.ts b/apps/lsp/src/service/providers/hover/hover-image.ts index f4cbdd93..8a9d279b 100644 --- a/apps/lsp/src/service/providers/hover/hover-image.ts +++ b/apps/lsp/src/service/providers/hover/hover-image.ts @@ -71,4 +71,3 @@ function pngToDataUrl(png: string): string { const b64Start = "data:image/png;base64,"; return b64Start + base64data; } - diff --git a/apps/lsp/src/service/providers/hover/hover-math.ts b/apps/lsp/src/service/providers/hover/hover-math.ts index 21a5d0cc..876e81a8 100644 --- a/apps/lsp/src/service/providers/hover/hover-math.ts +++ b/apps/lsp/src/service/providers/hover/hover-math.ts @@ -39,10 +39,10 @@ export function mathHover(parser: Parser, doc: Document, pos: Position, config?: function mathjaxTypesetToMarkdown( - tex: string, + tex: string, docText: string, config?: LsConfiguration | undefined -) : MarkupContent | null { +): MarkupContent | null { const options: MathjaxTypesetOptions = { format: "data-uri", theme: config?.colorTheme || "dark", @@ -62,4 +62,3 @@ function mathjaxTypesetToMarkdown( }; } } - diff --git a/apps/lsp/src/service/providers/hover/hover.ts b/apps/lsp/src/service/providers/hover/hover.ts index 8f6d2bea..c0001f4e 100644 --- a/apps/lsp/src/service/providers/hover/hover.ts +++ b/apps/lsp/src/service/providers/hover/hover.ts @@ -26,7 +26,7 @@ import { docEditorContext } from "../../quarto"; import { IWorkspace } from "../../workspace"; export class MdHoverProvider { - constructor(private readonly workspace_: IWorkspace, private readonly quarto_: Quarto, private readonly parser_: Parser) {} + constructor(private readonly workspace_: IWorkspace, private readonly quarto_: Quarto, private readonly parser_: Parser) { } public async provideHover( doc: Document, @@ -38,15 +38,15 @@ export class MdHoverProvider { return null; } return ( - (await refHover(this.quarto_, this.parser_, doc, pos)) || - mathHover(this.parser_, doc, pos, config) || - (await yamlHover(this.quarto_, docEditorContext(doc, pos, true))) + (await refHover(this.quarto_, this.parser_, doc, pos)) || + mathHover(this.parser_, doc, pos, config) || + (await yamlHover(this.quarto_, docEditorContext(doc, pos, true))) // there appears to be a size cap on markdown images (somewhere around 75k) // so we have switched this back to the client-side. note also that if we // bring this back we need to make it work for more than just png files // || (await (imageHover(this.workspace_)(doc, pos)) - + ); - } + } } diff --git a/apps/lsp/src/service/providers/references.ts b/apps/lsp/src/service/providers/references.ts index 9dcb2da4..d87341ba 100644 --- a/apps/lsp/src/service/providers/references.ts +++ b/apps/lsp/src/service/providers/references.ts @@ -18,7 +18,7 @@ import { CancellationToken } from 'vscode-languageserver'; import * as lsp from 'vscode-languageserver-types'; import { URI } from 'vscode-uri'; import { Disposable } from 'core'; -import { translatePosition, areRangesEqual, modifyRange, rangeContains, getDocUri, Document, Parser } from 'quarto-core'; +import { translatePosition, areRangesEqual, modifyRange, rangeContains, getDocUri, Document, Parser } from 'quarto-core'; import { LsConfiguration } from '../config'; import { ILogger, LogLevel } from '../logging'; import { MdTableOfContentsProvider, TocHeaderEntry, isTocHeaderEntry } from '../toc'; @@ -29,51 +29,51 @@ import { HrefKind, looksLikeLinkToResource, MdLink, MdLinkKind } from './documen import { pandocSlugifier } from '../slugify'; export enum MdReferenceKind { - Link = 1, - Header = 2, + Link = 1, + Header = 2, } /** * A link in a markdown file. */ export interface MdLinkReference { - readonly kind: MdReferenceKind.Link; - readonly isTriggerLocation: boolean; - readonly isDefinition: boolean; - readonly location: lsp.Location; + readonly kind: MdReferenceKind.Link; + readonly isTriggerLocation: boolean; + readonly isDefinition: boolean; + readonly location: lsp.Location; - readonly link: MdLink; + readonly link: MdLink; } /** * A header in a markdown file. */ export interface MdHeaderReference { - readonly kind: MdReferenceKind.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: lsp.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: lsp.Location; + readonly kind: MdReferenceKind.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: lsp.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: lsp.Location; } export type MdReference = MdLinkReference | MdHeaderReference; @@ -83,271 +83,271 @@ export type MdReference = MdLinkReference | MdHeaderReference; */ export class MdReferencesProvider extends Disposable { - readonly #configuration: LsConfiguration; - readonly #parser: Parser; - readonly #workspace: IWorkspace; - readonly #tocProvider: MdTableOfContentsProvider; - readonly #linkCache: MdWorkspaceInfoCache; - readonly #logger: ILogger; - - public constructor( - configuration: LsConfiguration, - parser: Parser, - workspace: IWorkspace, - tocProvider: MdTableOfContentsProvider, - linkCache: MdWorkspaceInfoCache, - logger: ILogger, - ) { - super(); - - this.#configuration = configuration; - this.#parser = parser; - this.#workspace = workspace; - this.#tocProvider = tocProvider; - this.#linkCache = linkCache; - this.#logger = logger; - } - - async provideReferences(document: Document, position: lsp.Position, context: lsp.ReferenceContext, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return []; - } - const allRefs = await this.getReferencesAtPosition(document, position, token); - return allRefs - .filter(ref => context.includeDeclaration || !ref.isDefinition) - .map(ref => ref.location); - } - - public async getReferencesAtPosition(document: Document, position: lsp.Position, token: CancellationToken): Promise { - this.#logger.log(LogLevel.Debug, 'ReferencesProvider.getReferencesAtPosition', { document: document.uri, version: document.version }); - - const toc = await this.#tocProvider.getForDocument(document); - if (token.isCancellationRequested) { - return []; - } - - const header = toc.entries.find(entry => entry.line === position.line); - if (isTocHeaderEntry(header)) { - return this.#getReferencesToHeader(document, header, token); - } else { - return this.#getReferencesToLinkAtPosition(document, position, token); - } - } - - public async getReferencesToFileInWorkspace(resource: URI, token: CancellationToken): Promise { - this.#logger.log(LogLevel.Debug, 'ReferencesProvider.getAllReferencesToFileInWorkspace', { resource }); - - if (token.isCancellationRequested) { - return []; - } - - const allLinksInWorkspace = await this.#getAllLinksInWorkspace(); - - if (token.isCancellationRequested) { - return []; - } - - return Array.from(this.#findLinksToFile(resource, allLinksInWorkspace, undefined)); - } - - async #getReferencesToHeader(document: Document, header: TocHeaderEntry, token: CancellationToken): Promise { - - - const links = await this.#getAllLinksInWorkspace(); - if (token.isCancellationRequested) { - return []; - } - - const references: MdReference[] = []; - - references.push({ - kind: MdReferenceKind.Header, - isTriggerLocation: true, - isDefinition: true, - location: header.headerLocation, - headerText: header.text, - headerTextLocation: header.headerTextLocation - }); - - for (const link of links) { - if (link.href.kind === HrefKind.Internal - && looksLikeLinkToResource(this.#configuration, link.href, getDocUri(document)) - && pandocSlugifier.fromHeading(link.href.fragment).value === header.slug.value - ) { - references.push({ - kind: MdReferenceKind.Link, - isTriggerLocation: false, - isDefinition: false, - link, - location: { uri: link.source.resource.toString(), range: link.source.hrefRange }, - }); - } - } - - return references; - } - - async #getReferencesToLinkAtPosition(document: Document, position: lsp.Position, token: CancellationToken): Promise { - const docLinks = (await this.#linkCache.getForDocs([document]))[0]; - if (token.isCancellationRequested) { - return []; - } - - for (const link of docLinks) { - if (link.kind === MdLinkKind.Definition) { - // We could be in either the ref name or the definition - if (rangeContains(link.ref.range, position)) { - return Array.from(this.#getReferencesToLinkReference(docLinks, link.ref.text, { resource: getDocUri(document), range: link.ref.range })); - } else if (rangeContains(link.source.hrefRange, position)) { - return this.#getReferencesToLink(docLinks, link, position, token); - } - } else { - if (rangeContains(link.source.hrefRange, position)) { - return this.#getReferencesToLink(docLinks, link, position, token); - } - } - } - - return []; - } - - async #getReferencesToLink(docLinks: Iterable, sourceLink: MdLink, triggerPosition: lsp.Position, token: CancellationToken): Promise { - if (sourceLink.href.kind === HrefKind.Reference) { - return Array.from(this.#getReferencesToLinkReference(docLinks, sourceLink.href.ref, { resource: sourceLink.source.resource, range: sourceLink.source.hrefRange })); - } - - // Otherwise find all occurrences of the link in the workspace - const allLinksInWorkspace = await this.#getAllLinksInWorkspace(); - if (token.isCancellationRequested) { - return []; - } - - if (sourceLink.href.kind === HrefKind.External) { - const references: MdReference[] = []; - - for (const link of allLinksInWorkspace) { - if (link.href.kind === HrefKind.External && link.href.uri.toString() === sourceLink.href.uri.toString()) { - const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && areRangesEqual(sourceLink.source.hrefRange, link.source.hrefRange); - references.push({ - kind: MdReferenceKind.Link, - isTriggerLocation, - isDefinition: false, - link, - location: { uri: link.source.resource.toString(), range: link.source.hrefRange }, - }); - } - } - return references; - } - - const resolvedResource = await statLinkToMarkdownFile(this.#configuration, this.#workspace, sourceLink.href.path); - if (token.isCancellationRequested) { - return []; - } - - const references: MdReference[] = []; - - if (resolvedResource && this.#isMarkdownPath(resolvedResource) && sourceLink.href.fragment && sourceLink.source.fragmentRange && rangeContains(sourceLink.source.fragmentRange, triggerPosition)) { - const toc = await this.#tocProvider.get(resolvedResource); - const entry = toc.lookup(sourceLink.href.fragment); - if (isTocHeaderEntry(entry)) { - references.push({ - kind: MdReferenceKind.Header, - isTriggerLocation: false, - isDefinition: true, - location: entry.headerLocation, - headerText: entry.text, - headerTextLocation: entry.headerTextLocation - }); - } - - for (const link of allLinksInWorkspace) { - if (link.href.kind !== HrefKind.Internal || !looksLikeLinkToResource(this.#configuration, link.href, resolvedResource)) { - continue; - } - - if (pandocSlugifier.fromHeading(link.href.fragment).equals(pandocSlugifier.fromHeading(sourceLink.href.fragment))) { - const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && areRangesEqual(sourceLink.source.hrefRange, link.source.hrefRange); - references.push({ - kind: MdReferenceKind.Link, - isTriggerLocation, - isDefinition: false, - link, - location: { uri: link.source.resource.toString(), range: 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; - } - - async #getAllLinksInWorkspace(): Promise { - return (await this.#linkCache.values()).flat(); - } - - #isMarkdownPath(resolvedHrefPath: URI) { - return this.#workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownUri(this.#configuration, resolvedHrefPath); - } - - *#findLinksToFile(resource: URI, links: readonly MdLink[], sourceLink: MdLink | undefined): Iterable { - for (const link of links) { - if (link.href.kind !== HrefKind.Internal || !looksLikeLinkToResource(this.#configuration, 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 && areRangesEqual(sourceLink.source.hrefRange, link.source.hrefRange); - const pathRange = this.#getPathRange(link); - yield { - kind: MdReferenceKind.Link, - isTriggerLocation, - isDefinition: false, - link, - location: { uri: link.source.resource.toString(), range: pathRange }, - }; - } - } - - *#getReferencesToLinkReference(allLinks: Iterable, refToFind: string, from: { resource: URI; range: lsp.Range }): Iterable { - for (const link of allLinks) { - let ref: string; - if (link.kind === MdLinkKind.Definition) { - ref = link.ref.text; - } else if (link.href.kind === HrefKind.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 === HrefKind.Reference && areRangesEqual(from.range, link.source.hrefRange)) || (link.kind === MdLinkKind.Definition && areRangesEqual(from.range, link.ref.range))); - - const pathRange = this.#getPathRange(link); - yield { - kind: MdReferenceKind.Link, - isTriggerLocation, - isDefinition: link.kind === MdLinkKind.Definition, - link, - location: { uri: from.resource.toString(), range: pathRange }, - }; - } - } - } - - /** - * Get just the range of the file path, dropping the fragment - */ - #getPathRange(link: MdLink): lsp.Range { - return link.source.fragmentRange - ? modifyRange(link.source.hrefRange, undefined, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 })) - : link.source.hrefRange; - } + readonly #configuration: LsConfiguration; + readonly #parser: Parser; + readonly #workspace: IWorkspace; + readonly #tocProvider: MdTableOfContentsProvider; + readonly #linkCache: MdWorkspaceInfoCache; + readonly #logger: ILogger; + + public constructor( + configuration: LsConfiguration, + parser: Parser, + workspace: IWorkspace, + tocProvider: MdTableOfContentsProvider, + linkCache: MdWorkspaceInfoCache, + logger: ILogger, + ) { + super(); + + this.#configuration = configuration; + this.#parser = parser; + this.#workspace = workspace; + this.#tocProvider = tocProvider; + this.#linkCache = linkCache; + this.#logger = logger; + } + + async provideReferences(document: Document, position: lsp.Position, context: lsp.ReferenceContext, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return []; + } + const allRefs = await this.getReferencesAtPosition(document, position, token); + return allRefs + .filter(ref => context.includeDeclaration || !ref.isDefinition) + .map(ref => ref.location); + } + + public async getReferencesAtPosition(document: Document, position: lsp.Position, token: CancellationToken): Promise { + this.#logger.log(LogLevel.Debug, 'ReferencesProvider.getReferencesAtPosition', { document: document.uri, version: document.version }); + + const toc = await this.#tocProvider.getForDocument(document); + if (token.isCancellationRequested) { + return []; + } + + const header = toc.entries.find(entry => entry.line === position.line); + if (isTocHeaderEntry(header)) { + return this.#getReferencesToHeader(document, header, token); + } else { + return this.#getReferencesToLinkAtPosition(document, position, token); + } + } + + public async getReferencesToFileInWorkspace(resource: URI, token: CancellationToken): Promise { + this.#logger.log(LogLevel.Debug, 'ReferencesProvider.getAllReferencesToFileInWorkspace', { resource }); + + if (token.isCancellationRequested) { + return []; + } + + const allLinksInWorkspace = await this.#getAllLinksInWorkspace(); + + if (token.isCancellationRequested) { + return []; + } + + return Array.from(this.#findLinksToFile(resource, allLinksInWorkspace, undefined)); + } + + async #getReferencesToHeader(document: Document, header: TocHeaderEntry, token: CancellationToken): Promise { + + + const links = await this.#getAllLinksInWorkspace(); + if (token.isCancellationRequested) { + return []; + } + + const references: MdReference[] = []; + + references.push({ + kind: MdReferenceKind.Header, + isTriggerLocation: true, + isDefinition: true, + location: header.headerLocation, + headerText: header.text, + headerTextLocation: header.headerTextLocation + }); + + for (const link of links) { + if (link.href.kind === HrefKind.Internal + && looksLikeLinkToResource(this.#configuration, link.href, getDocUri(document)) + && pandocSlugifier.fromHeading(link.href.fragment).value === header.slug.value + ) { + references.push({ + kind: MdReferenceKind.Link, + isTriggerLocation: false, + isDefinition: false, + link, + location: { uri: link.source.resource.toString(), range: link.source.hrefRange }, + }); + } + } + + return references; + } + + async #getReferencesToLinkAtPosition(document: Document, position: lsp.Position, token: CancellationToken): Promise { + const docLinks = (await this.#linkCache.getForDocs([document]))[0]; + if (token.isCancellationRequested) { + return []; + } + + for (const link of docLinks) { + if (link.kind === MdLinkKind.Definition) { + // We could be in either the ref name or the definition + if (rangeContains(link.ref.range, position)) { + return Array.from(this.#getReferencesToLinkReference(docLinks, link.ref.text, { resource: getDocUri(document), range: link.ref.range })); + } else if (rangeContains(link.source.hrefRange, position)) { + return this.#getReferencesToLink(docLinks, link, position, token); + } + } else { + if (rangeContains(link.source.hrefRange, position)) { + return this.#getReferencesToLink(docLinks, link, position, token); + } + } + } + + return []; + } + + async #getReferencesToLink(docLinks: Iterable, sourceLink: MdLink, triggerPosition: lsp.Position, token: CancellationToken): Promise { + if (sourceLink.href.kind === HrefKind.Reference) { + return Array.from(this.#getReferencesToLinkReference(docLinks, sourceLink.href.ref, { resource: sourceLink.source.resource, range: sourceLink.source.hrefRange })); + } + + // Otherwise find all occurrences of the link in the workspace + const allLinksInWorkspace = await this.#getAllLinksInWorkspace(); + if (token.isCancellationRequested) { + return []; + } + + if (sourceLink.href.kind === HrefKind.External) { + const references: MdReference[] = []; + + for (const link of allLinksInWorkspace) { + if (link.href.kind === HrefKind.External && link.href.uri.toString() === sourceLink.href.uri.toString()) { + const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && areRangesEqual(sourceLink.source.hrefRange, link.source.hrefRange); + references.push({ + kind: MdReferenceKind.Link, + isTriggerLocation, + isDefinition: false, + link, + location: { uri: link.source.resource.toString(), range: link.source.hrefRange }, + }); + } + } + return references; + } + + const resolvedResource = await statLinkToMarkdownFile(this.#configuration, this.#workspace, sourceLink.href.path); + if (token.isCancellationRequested) { + return []; + } + + const references: MdReference[] = []; + + if (resolvedResource && this.#isMarkdownPath(resolvedResource) && sourceLink.href.fragment && sourceLink.source.fragmentRange && rangeContains(sourceLink.source.fragmentRange, triggerPosition)) { + const toc = await this.#tocProvider.get(resolvedResource); + const entry = toc.lookup(sourceLink.href.fragment); + if (isTocHeaderEntry(entry)) { + references.push({ + kind: MdReferenceKind.Header, + isTriggerLocation: false, + isDefinition: true, + location: entry.headerLocation, + headerText: entry.text, + headerTextLocation: entry.headerTextLocation + }); + } + + for (const link of allLinksInWorkspace) { + if (link.href.kind !== HrefKind.Internal || !looksLikeLinkToResource(this.#configuration, link.href, resolvedResource)) { + continue; + } + + if (pandocSlugifier.fromHeading(link.href.fragment).equals(pandocSlugifier.fromHeading(sourceLink.href.fragment))) { + const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && areRangesEqual(sourceLink.source.hrefRange, link.source.hrefRange); + references.push({ + kind: MdReferenceKind.Link, + isTriggerLocation, + isDefinition: false, + link, + location: { uri: link.source.resource.toString(), range: 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; + } + + async #getAllLinksInWorkspace(): Promise { + return (await this.#linkCache.values()).flat(); + } + + #isMarkdownPath(resolvedHrefPath: URI) { + return this.#workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownUri(this.#configuration, resolvedHrefPath); + } + + *#findLinksToFile(resource: URI, links: readonly MdLink[], sourceLink: MdLink | undefined): Iterable { + for (const link of links) { + if (link.href.kind !== HrefKind.Internal || !looksLikeLinkToResource(this.#configuration, 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 && areRangesEqual(sourceLink.source.hrefRange, link.source.hrefRange); + const pathRange = this.#getPathRange(link); + yield { + kind: MdReferenceKind.Link, + isTriggerLocation, + isDefinition: false, + link, + location: { uri: link.source.resource.toString(), range: pathRange }, + }; + } + } + + *#getReferencesToLinkReference(allLinks: Iterable, refToFind: string, from: { resource: URI; range: lsp.Range }): Iterable { + for (const link of allLinks) { + let ref: string; + if (link.kind === MdLinkKind.Definition) { + ref = link.ref.text; + } else if (link.href.kind === HrefKind.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 === HrefKind.Reference && areRangesEqual(from.range, link.source.hrefRange)) || (link.kind === MdLinkKind.Definition && areRangesEqual(from.range, link.ref.range))); + + const pathRange = this.#getPathRange(link); + yield { + kind: MdReferenceKind.Link, + isTriggerLocation, + isDefinition: link.kind === MdLinkKind.Definition, + link, + location: { uri: from.resource.toString(), range: pathRange }, + }; + } + } + } + + /** + * Get just the range of the file path, dropping the fragment + */ + #getPathRange(link: MdLink): lsp.Range { + return link.source.fragmentRange + ? modifyRange(link.source.hrefRange, undefined, translatePosition(link.source.fragmentRange.start, { characterDelta: -1 })) + : link.source.hrefRange; + } } diff --git a/apps/lsp/src/service/providers/smart-select.ts b/apps/lsp/src/service/providers/smart-select.ts index 53c22391..ab3b374f 100644 --- a/apps/lsp/src/service/providers/smart-select.ts +++ b/apps/lsp/src/service/providers/smart-select.ts @@ -26,256 +26,256 @@ import { isEmptyOrWhitespace } from '../util/string'; export class MdSelectionRangeProvider { - readonly #parser: Parser; - readonly #tocProvider: MdTableOfContentsProvider; - readonly #logger: ILogger; - - constructor( - parser: Parser, - tocProvider: MdTableOfContentsProvider, - logger: ILogger, - ) { - this.#parser = parser; - this.#tocProvider = tocProvider; - this.#logger = logger; - } - - public async provideSelectionRanges(document: Document, positions: readonly Position[], token: CancellationToken): Promise { - this.#logger.log(LogLevel.Debug, 'MdSelectionRangeProvider.provideSelectionRanges', { document: document.uri, version: document.version }); - - if (token.isCancellationRequested) { - return undefined; - } - - return coalesce(await Promise.all(positions.map(position => this.#provideSelectionRange(document, position, token)))); - } - - async #provideSelectionRange(document: Document, position: Position, token: CancellationToken): Promise { - const headerRange = await this.#getHeaderSelectionRange(document, position, token); - if (token.isCancellationRequested) { - return; - } - - const blockRange = await this.#getBlockSelectionRange(document, position, headerRange, token); - if (token.isCancellationRequested) { - return; - } - - const inlineRange = createInlineRange(document, position, blockRange); - return inlineRange ?? blockRange ?? headerRange; - } - - async #getBlockSelectionRange(document: Document, position: Position, parent: lsp.SelectionRange | undefined, token: CancellationToken): Promise { - const tokens = this.#parser(document); - if (token.isCancellationRequested) { - return undefined; - } - - const blockTokens = getBlockTokensForPosition(tokens, position, parent); - if (blockTokens.length === 0) { - return undefined; - } - - let currentRange = parent ?? createBlockRange(blockTokens.shift()!, document, position.line, undefined); - for (let i = 0; i < blockTokens.length; i++) { - currentRange = createBlockRange(blockTokens[i], document, position.line, currentRange); - } - return currentRange; - } - - async #getHeaderSelectionRange(document: Document, position: Position, token: CancellationToken): Promise { - const toc = await this.#tocProvider.getForDocument(document); - if (token.isCancellationRequested) { - return undefined; - } - - const headerInfo = getHeadersForPosition(toc.entries, position); - const headers = headerInfo.headers; - - let currentRange: lsp.SelectionRange | undefined; - for (let i = 0; i < headers.length; i++) { - currentRange = createHeaderRange(headers[i], i === headers.length - 1, headerInfo.headerOnThisLine, currentRange, getFirstChildHeader(document, headers[i], toc.entries)); - } - return currentRange; - } + readonly #parser: Parser; + readonly #tocProvider: MdTableOfContentsProvider; + readonly #logger: ILogger; + + constructor( + parser: Parser, + tocProvider: MdTableOfContentsProvider, + logger: ILogger, + ) { + this.#parser = parser; + this.#tocProvider = tocProvider; + this.#logger = logger; + } + + public async provideSelectionRanges(document: Document, positions: readonly Position[], token: CancellationToken): Promise { + this.#logger.log(LogLevel.Debug, 'MdSelectionRangeProvider.provideSelectionRanges', { document: document.uri, version: document.version }); + + if (token.isCancellationRequested) { + return undefined; + } + + return coalesce(await Promise.all(positions.map(position => this.#provideSelectionRange(document, position, token)))); + } + + async #provideSelectionRange(document: Document, position: Position, token: CancellationToken): Promise { + const headerRange = await this.#getHeaderSelectionRange(document, position, token); + if (token.isCancellationRequested) { + return; + } + + const blockRange = await this.#getBlockSelectionRange(document, position, headerRange, token); + if (token.isCancellationRequested) { + return; + } + + const inlineRange = createInlineRange(document, position, blockRange); + return inlineRange ?? blockRange ?? headerRange; + } + + async #getBlockSelectionRange(document: Document, position: Position, parent: lsp.SelectionRange | undefined, token: CancellationToken): Promise { + const tokens = this.#parser(document); + if (token.isCancellationRequested) { + return undefined; + } + + const blockTokens = getBlockTokensForPosition(tokens, position, parent); + if (blockTokens.length === 0) { + return undefined; + } + + let currentRange = parent ?? createBlockRange(blockTokens.shift()!, document, position.line, undefined); + for (let i = 0; i < blockTokens.length; i++) { + currentRange = createBlockRange(blockTokens[i], document, position.line, currentRange); + } + return currentRange; + } + + async #getHeaderSelectionRange(document: Document, position: Position, token: CancellationToken): Promise { + const toc = await this.#tocProvider.getForDocument(document); + if (token.isCancellationRequested) { + return undefined; + } + + const headerInfo = getHeadersForPosition(toc.entries, position); + const headers = headerInfo.headers; + + let currentRange: lsp.SelectionRange | undefined; + for (let i = 0; i < headers.length; i++) { + currentRange = createHeaderRange(headers[i], i === headers.length - 1, headerInfo.headerOnThisLine, currentRange, getFirstChildHeader(document, headers[i], toc.entries)); + } + return currentRange; + } } function getHeadersForPosition(toc: readonly TocEntry[], position: Position): { headers: TocEntry[]; headerOnThisLine: boolean } { - const enclosingHeaders = toc.filter(header => isTocHeaderEntry(header) && header.sectionLocation.range.start.line <= position.line && header.sectionLocation.range.end.line >= position.line); - const sortedHeaders = enclosingHeaders.sort((header1, header2) => (header1.line - position.line) - (header2.line - position.line)); - const onThisLine = toc.find(header => header.line === position.line) !== undefined; - return { - headers: sortedHeaders, - headerOnThisLine: onThisLine - }; + const enclosingHeaders = toc.filter(header => isTocHeaderEntry(header) && header.sectionLocation.range.start.line <= position.line && header.sectionLocation.range.end.line >= position.line); + const sortedHeaders = enclosingHeaders.sort((header1, header2) => (header1.line - position.line) - (header2.line - position.line)); + const onThisLine = toc.find(header => header.line === position.line) !== undefined; + return { + headers: sortedHeaders, + headerOnThisLine: onThisLine + }; } function createHeaderRange(header: TocEntry, isClosestHeaderToPosition: boolean, onHeaderLine: boolean, parent?: lsp.SelectionRange, startOfChildRange?: Position): lsp.SelectionRange | undefined { - const range = header.sectionLocation.range; - const contentRange = makeRange(translatePosition(range.start, { lineDelta: 1 }), range.end); - if (onHeaderLine && isClosestHeaderToPosition && startOfChildRange) { - // selection was made on this header line, so select header and its content until the start of its first child - // then all of its content - return makeSelectionRange(modifyRange(range, undefined, startOfChildRange), makeSelectionRange(range, parent)); - } else if (onHeaderLine && isClosestHeaderToPosition) { - // selection was made on this header line and no children so expand to all of its content - return makeSelectionRange(range, parent); - } else if (isClosestHeaderToPosition && startOfChildRange) { - // selection was made within content and has child so select content - // of this header then all content then header - return makeSelectionRange(modifyRange(contentRange, undefined, startOfChildRange), makeSelectionRange(contentRange, (makeSelectionRange(range, parent)))); - } else { - // not on this header line so select content then header - return makeSelectionRange(contentRange, makeSelectionRange(range, parent)); - } + const range = header.sectionLocation.range; + const contentRange = makeRange(translatePosition(range.start, { lineDelta: 1 }), range.end); + if (onHeaderLine && isClosestHeaderToPosition && startOfChildRange) { + // selection was made on this header line, so select header and its content until the start of its first child + // then all of its content + return makeSelectionRange(modifyRange(range, undefined, startOfChildRange), makeSelectionRange(range, parent)); + } else if (onHeaderLine && isClosestHeaderToPosition) { + // selection was made on this header line and no children so expand to all of its content + return makeSelectionRange(range, parent); + } else if (isClosestHeaderToPosition && startOfChildRange) { + // selection was made within content and has child so select content + // of this header then all content then header + return makeSelectionRange(modifyRange(contentRange, undefined, startOfChildRange), makeSelectionRange(contentRange, (makeSelectionRange(range, parent)))); + } else { + // not on this header line so select content then header + return makeSelectionRange(contentRange, makeSelectionRange(range, parent)); + } } function getBlockTokensForPosition(tokens: readonly Token[], position: Position, parent: lsp.SelectionRange | undefined): Token[] { - const enclosingTokens = tokens.filter(token => token.range.start.line <= position.line && token.range.end.line > position.line && (!parent || (token.range.start.line >= parent.range.start.line && token.range.end.line <= parent.range.end.line + 1)) && token.range.start.line !== token.range.end.line); - if (enclosingTokens.length === 0) { - return []; - } - const sortedTokens = enclosingTokens.sort((token1, token2) => (token2.range.end.line - token2.range.start.line) - (token1.range.end.line - token1.range.start.line)); - return sortedTokens; + const enclosingTokens = tokens.filter(token => token.range.start.line <= position.line && token.range.end.line > position.line && (!parent || (token.range.start.line >= parent.range.start.line && token.range.end.line <= parent.range.end.line + 1)) && token.range.start.line !== token.range.end.line); + if (enclosingTokens.length === 0) { + return []; + } + const sortedTokens = enclosingTokens.sort((token1, token2) => (token2.range.end.line - token2.range.start.line) - (token1.range.end.line - token1.range.start.line)); + return sortedTokens; } function createBlockRange(block: Token, document: Document, cursorLine: number, parent: lsp.SelectionRange | undefined): lsp.SelectionRange { - if (block.type === 'CodeBlock') { - return createFencedRange(block, cursorLine, document, parent); - } - - let startLine = isEmptyOrWhitespace(getLine(document, block.range.start.line)) ? block.range.start.line + 1 : block.range.start.line; - let endLine = startLine === block.range.end.line ? block.range.end.line : block.range.end.line - 1; - if (block.type === 'Para' && block.range.end.line - block.range.end.line === 2) { - startLine = endLine = cursorLine; - } else if (isList(block) && isEmptyOrWhitespace(getLine(document, endLine))) { - endLine = endLine - 1; - } - const range = makeRange(startLine, 0, endLine, getLine(document, endLine).length); - if (parent && rangeContains(parent.range, range) && !areRangesEqual(parent.range, range)) { - return makeSelectionRange(range, parent); - } else if (parent && areRangesEqual(parent.range, range)) { - return parent; - } else { - return makeSelectionRange(range, undefined); - } + if (block.type === 'CodeBlock') { + return createFencedRange(block, cursorLine, document, parent); + } + + let startLine = isEmptyOrWhitespace(getLine(document, block.range.start.line)) ? block.range.start.line + 1 : block.range.start.line; + let endLine = startLine === block.range.end.line ? block.range.end.line : block.range.end.line - 1; + if (block.type === 'Para' && block.range.end.line - block.range.end.line === 2) { + startLine = endLine = cursorLine; + } else if (isList(block) && isEmptyOrWhitespace(getLine(document, endLine))) { + endLine = endLine - 1; + } + const range = makeRange(startLine, 0, endLine, getLine(document, endLine).length); + if (parent && rangeContains(parent.range, range) && !areRangesEqual(parent.range, range)) { + return makeSelectionRange(range, parent); + } else if (parent && areRangesEqual(parent.range, range)) { + return parent; + } else { + return makeSelectionRange(range, undefined); + } } function createInlineRange(document: Document, cursorPosition: Position, parent?: lsp.SelectionRange): lsp.SelectionRange | undefined { - const lineText = getLine(document, cursorPosition.line); - const boldSelection = createBoldRange(lineText, cursorPosition.character, cursorPosition.line, parent); - const italicSelection = createOtherInlineRange(lineText, cursorPosition.character, cursorPosition.line, true, parent); - let comboSelection: lsp.SelectionRange | undefined; - if (boldSelection && italicSelection && !areRangesEqual(boldSelection.range, italicSelection.range)) { - if (rangeContains(boldSelection.range, italicSelection.range)) { - comboSelection = createOtherInlineRange(lineText, cursorPosition.character, cursorPosition.line, true, boldSelection); - } else if (rangeContains(italicSelection.range, boldSelection.range)) { - comboSelection = createBoldRange(lineText, cursorPosition.character, cursorPosition.line, italicSelection); - } - } - const linkSelection = createLinkRange(lineText, cursorPosition.character, cursorPosition.line, comboSelection ?? boldSelection ?? italicSelection ?? parent); - const inlineCodeBlockSelection = createOtherInlineRange(lineText, cursorPosition.character, cursorPosition.line, false, linkSelection ?? parent); - return inlineCodeBlockSelection ?? linkSelection ?? comboSelection ?? boldSelection ?? italicSelection; + const lineText = getLine(document, cursorPosition.line); + const boldSelection = createBoldRange(lineText, cursorPosition.character, cursorPosition.line, parent); + const italicSelection = createOtherInlineRange(lineText, cursorPosition.character, cursorPosition.line, true, parent); + let comboSelection: lsp.SelectionRange | undefined; + if (boldSelection && italicSelection && !areRangesEqual(boldSelection.range, italicSelection.range)) { + if (rangeContains(boldSelection.range, italicSelection.range)) { + comboSelection = createOtherInlineRange(lineText, cursorPosition.character, cursorPosition.line, true, boldSelection); + } else if (rangeContains(italicSelection.range, boldSelection.range)) { + comboSelection = createBoldRange(lineText, cursorPosition.character, cursorPosition.line, italicSelection); + } + } + const linkSelection = createLinkRange(lineText, cursorPosition.character, cursorPosition.line, comboSelection ?? boldSelection ?? italicSelection ?? parent); + const inlineCodeBlockSelection = createOtherInlineRange(lineText, cursorPosition.character, cursorPosition.line, false, linkSelection ?? parent); + return inlineCodeBlockSelection ?? linkSelection ?? comboSelection ?? boldSelection ?? italicSelection; } function createFencedRange(token: Token, cursorLine: number, document: Document, parent?: lsp.SelectionRange): lsp.SelectionRange { - const startLine = token.range.start.line; - const endLine = token.range.end.line - 1; - const onFenceLine = cursorLine === startLine || cursorLine === endLine; - const fenceRange = makeRange(startLine, 0, endLine, getLine(document, endLine).length); - const contentRange = endLine - startLine > 2 && !onFenceLine ? makeRange(startLine + 1, 0, endLine - 1, getLine(document, endLine - 1).length) : undefined; - if (contentRange) { - return makeSelectionRange(contentRange, makeSelectionRange(fenceRange, parent)); - } else { - if (parent && areRangesEqual(parent.range, fenceRange)) { - return parent; - } else { - return makeSelectionRange(fenceRange, parent); - } - } + const startLine = token.range.start.line; + const endLine = token.range.end.line - 1; + const onFenceLine = cursorLine === startLine || cursorLine === endLine; + const fenceRange = makeRange(startLine, 0, endLine, getLine(document, endLine).length); + const contentRange = endLine - startLine > 2 && !onFenceLine ? makeRange(startLine + 1, 0, endLine - 1, getLine(document, endLine - 1).length) : undefined; + if (contentRange) { + return makeSelectionRange(contentRange, makeSelectionRange(fenceRange, parent)); + } else { + if (parent && areRangesEqual(parent.range, fenceRange)) { + return parent; + } else { + return makeSelectionRange(fenceRange, parent); + } + } } function createBoldRange(lineText: string, cursorChar: number, cursorLine: number, parent?: lsp.SelectionRange): lsp.SelectionRange | undefined { - const regex = /\*\*([^*]+\*?[^*]+\*?[^*]+)\*\*/gim; - const matches = [...lineText.matchAll(regex)].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length >= cursorChar); - if (matches.length) { - // should only be one match, so select first and index 0 contains the entire match - const bold = matches[0][0]; - const startIndex = lineText.indexOf(bold); - const cursorOnStars = cursorChar === startIndex || cursorChar === startIndex + 1 || cursorChar === startIndex + bold.length || cursorChar === startIndex + bold.length - 1; - const contentAndStars = makeSelectionRange(makeRange(cursorLine, startIndex, cursorLine, startIndex + bold.length), parent); - const content = makeSelectionRange(makeRange(cursorLine, startIndex + 2, cursorLine, startIndex + bold.length - 2), contentAndStars); - return cursorOnStars ? contentAndStars : content; - } - return undefined; + const regex = /\*\*([^*]+\*?[^*]+\*?[^*]+)\*\*/gim; + const matches = [...lineText.matchAll(regex)].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length >= cursorChar); + if (matches.length) { + // should only be one match, so select first and index 0 contains the entire match + const bold = matches[0][0]; + const startIndex = lineText.indexOf(bold); + const cursorOnStars = cursorChar === startIndex || cursorChar === startIndex + 1 || cursorChar === startIndex + bold.length || cursorChar === startIndex + bold.length - 1; + const contentAndStars = makeSelectionRange(makeRange(cursorLine, startIndex, cursorLine, startIndex + bold.length), parent); + const content = makeSelectionRange(makeRange(cursorLine, startIndex + 2, cursorLine, startIndex + bold.length - 2), contentAndStars); + return cursorOnStars ? contentAndStars : content; + } + return undefined; } function createOtherInlineRange(lineText: string, cursorChar: number, cursorLine: number, isItalic: boolean, parent?: lsp.SelectionRange): lsp.SelectionRange | undefined { - const italicRegexes = [/(?:[^*]+)(\*([^*]+)(?:\*\*[^*]*\*\*)*([^*]+)\*)(?:[^*]+)/g, /^(?:[^*]*)(\*([^*]+)(?:\*\*[^*]*\*\*)*([^*]+)\*)(?:[^*]*)$/g]; - let matches = []; - if (isItalic) { - matches = [...lineText.matchAll(italicRegexes[0])].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length >= cursorChar); - if (!matches.length) { - matches = [...lineText.matchAll(italicRegexes[1])].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length >= cursorChar); - } - } else { - matches = [...lineText.matchAll(/`[^`]*`/g)].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length >= cursorChar); - } - if (matches.length) { - // should only be one match, so select first and select group 1 for italics because that contains just the italic section - // doesn't include the leading and trailing characters which are guaranteed to not be * so as not to be confused with bold - const match = isItalic ? matches[0][1] : matches[0][0]; - const startIndex = lineText.indexOf(match); - const cursorOnType = cursorChar === startIndex || cursorChar === startIndex + match.length; - const contentAndType = makeSelectionRange(makeRange(cursorLine, startIndex, cursorLine, startIndex + match.length), parent); - const content = makeSelectionRange(makeRange(cursorLine, startIndex + 1, cursorLine, startIndex + match.length - 1), contentAndType); - return cursorOnType ? contentAndType : content; - } - return undefined; + const italicRegexes = [/(?:[^*]+)(\*([^*]+)(?:\*\*[^*]*\*\*)*([^*]+)\*)(?:[^*]+)/g, /^(?:[^*]*)(\*([^*]+)(?:\*\*[^*]*\*\*)*([^*]+)\*)(?:[^*]*)$/g]; + let matches = []; + if (isItalic) { + matches = [...lineText.matchAll(italicRegexes[0])].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length >= cursorChar); + if (!matches.length) { + matches = [...lineText.matchAll(italicRegexes[1])].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length >= cursorChar); + } + } else { + matches = [...lineText.matchAll(/`[^`]*`/g)].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length >= cursorChar); + } + if (matches.length) { + // should only be one match, so select first and select group 1 for italics because that contains just the italic section + // doesn't include the leading and trailing characters which are guaranteed to not be * so as not to be confused with bold + const match = isItalic ? matches[0][1] : matches[0][0]; + const startIndex = lineText.indexOf(match); + const cursorOnType = cursorChar === startIndex || cursorChar === startIndex + match.length; + const contentAndType = makeSelectionRange(makeRange(cursorLine, startIndex, cursorLine, startIndex + match.length), parent); + const content = makeSelectionRange(makeRange(cursorLine, startIndex + 1, cursorLine, startIndex + match.length - 1), contentAndType); + return cursorOnType ? contentAndType : content; + } + return undefined; } function createLinkRange(lineText: string, cursorChar: number, cursorLine: number, parent?: lsp.SelectionRange): lsp.SelectionRange | undefined { - const regex = /(\[[^()]*\])(\([^[\]]*\))/g; - const matches = [...lineText.matchAll(regex)].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length > cursorChar); + const regex = /(\[[^()]*\])(\([^[\]]*\))/g; + const matches = [...lineText.matchAll(regex)].filter(match => lineText.indexOf(match[0]) <= cursorChar && lineText.indexOf(match[0]) + match[0].length > cursorChar); - if (matches.length) { - // should only be one match, so select first and index 0 contains the entire match, so match = [text](url) - const link = matches[0][0]; - const linkRange = makeSelectionRange(makeRange(cursorLine, lineText.indexOf(link), cursorLine, lineText.indexOf(link) + link.length), parent); + if (matches.length) { + // should only be one match, so select first and index 0 contains the entire match, so match = [text](url) + const link = matches[0][0]; + const linkRange = makeSelectionRange(makeRange(cursorLine, lineText.indexOf(link), cursorLine, lineText.indexOf(link) + link.length), parent); - const linkText = matches[0][1]; - const url = matches[0][2]; + const linkText = matches[0][1]; + const url = matches[0][2]; - // determine if cursor is within [text] or (url) in order to know which should be selected - const nearestType = cursorChar >= lineText.indexOf(linkText) && cursorChar < lineText.indexOf(linkText) + linkText.length ? linkText : url; + // determine if cursor is within [text] or (url) in order to know which should be selected + const nearestType = cursorChar >= lineText.indexOf(linkText) && cursorChar < lineText.indexOf(linkText) + linkText.length ? linkText : url; - const indexOfType = lineText.indexOf(nearestType); - // determine if cursor is on a bracket or paren and if so, return the [content] or (content), skipping over the content range - const cursorOnType = cursorChar === indexOfType || cursorChar === indexOfType + nearestType.length; + const indexOfType = lineText.indexOf(nearestType); + // determine if cursor is on a bracket or paren and if so, return the [content] or (content), skipping over the content range + const cursorOnType = cursorChar === indexOfType || cursorChar === indexOfType + nearestType.length; - const contentAndNearestType = makeSelectionRange(makeRange(cursorLine, indexOfType, cursorLine, indexOfType + nearestType.length), linkRange); - const content = makeSelectionRange(makeRange(cursorLine, indexOfType + 1, cursorLine, indexOfType + nearestType.length - 1), contentAndNearestType); - return cursorOnType ? contentAndNearestType : content; - } - return undefined; + const contentAndNearestType = makeSelectionRange(makeRange(cursorLine, indexOfType, cursorLine, indexOfType + nearestType.length), linkRange); + const content = makeSelectionRange(makeRange(cursorLine, indexOfType + 1, cursorLine, indexOfType + nearestType.length - 1), contentAndNearestType); + return cursorOnType ? contentAndNearestType : content; + } + return undefined; } function getFirstChildHeader(document: Document, header?: TocEntry, toc?: readonly TocEntry[]): Position | undefined { - let childRange: Position | undefined; - if (header && toc) { - const children = toc.filter(t => rangeContains(header.sectionLocation.range, t.sectionLocation.range) && t.sectionLocation.range.start.line > header.sectionLocation.range.start.line).sort((t1, t2) => t1.line - t2.line); - if (children.length > 0) { - childRange = children[0].sectionLocation.range.start; - const lineText = getLine(document, childRange.line - 1); - return childRange ? translatePosition(childRange, { lineDelta: -1, characterDelta: lineText.length }) : undefined; - } - } - return undefined; + let childRange: Position | undefined; + if (header && toc) { + const children = toc.filter(t => rangeContains(header.sectionLocation.range, t.sectionLocation.range) && t.sectionLocation.range.start.line > header.sectionLocation.range.start.line).sort((t1, t2) => t1.line - t2.line); + if (children.length > 0) { + childRange = children[0].sectionLocation.range.start; + const lineText = getLine(document, childRange.line - 1); + return childRange ? translatePosition(childRange, { lineDelta: -1, characterDelta: lineText.length }) : undefined; + } + } + return undefined; } function makeSelectionRange(range: Range, parent: lsp.SelectionRange | undefined): lsp.SelectionRange { - return { range, parent }; -} \ No newline at end of file + return { range, parent }; +} diff --git a/apps/lsp/src/service/providers/workspace-symbols.ts b/apps/lsp/src/service/providers/workspace-symbols.ts index b6a33c56..b005c686 100644 --- a/apps/lsp/src/service/providers/workspace-symbols.ts +++ b/apps/lsp/src/service/providers/workspace-symbols.ts @@ -24,52 +24,52 @@ import { MdDocumentSymbolProvider } from './document-symbols'; export class MdWorkspaceSymbolProvider extends Disposable { - readonly #cache: MdWorkspaceInfoCache; - readonly #symbolProvider: MdDocumentSymbolProvider; + readonly #cache: MdWorkspaceInfoCache; + readonly #symbolProvider: MdDocumentSymbolProvider; - constructor( - workspace: IWorkspace, - symbolProvider: MdDocumentSymbolProvider, - ) { - super(); - this.#symbolProvider = symbolProvider; + constructor( + workspace: IWorkspace, + symbolProvider: MdDocumentSymbolProvider, + ) { + super(); + this.#symbolProvider = symbolProvider; - this.#cache = this._register(new MdWorkspaceInfoCache(workspace, (doc, token) => this.provideDocumentSymbolInformation(doc, token))); - } + this.#cache = this._register(new MdWorkspaceInfoCache(workspace, (doc, token) => this.provideDocumentSymbolInformation(doc, token))); + } - public async provideWorkspaceSymbols(query: string, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return []; - } - - const allSymbols = await this.#cache.values(); - - if (token.isCancellationRequested) { - return []; - } + public async provideWorkspaceSymbols(query: string, token: CancellationToken): Promise { + if (token.isCancellationRequested) { + return []; + } - const normalizedQueryStr = query.toLowerCase(); - return allSymbols.flat().filter(symbolInformation => symbolInformation.name.toLowerCase().includes(normalizedQueryStr)); - } + const allSymbols = await this.#cache.values(); - public async provideDocumentSymbolInformation(document: Document, token: CancellationToken): Promise { - const docSymbols = await this.#symbolProvider.provideDocumentSymbols(document, {}, token); - if (token.isCancellationRequested) { - return []; - } - return Array.from(this.#toSymbolInformation(document.uri, docSymbols)); - } + if (token.isCancellationRequested) { + return []; + } - *#toSymbolInformation(uri: string, docSymbols: readonly lsp.DocumentSymbol[]): Iterable { - for (const symbol of docSymbols) { - yield { - name: symbol.name, - kind: lsp.SymbolKind.String, - location: { uri, range: symbol.selectionRange } - }; - if (symbol.children) { - yield* this.#toSymbolInformation(uri, symbol.children); - } - } - } + const normalizedQueryStr = query.toLowerCase(); + return allSymbols.flat().filter(symbolInformation => symbolInformation.name.toLowerCase().includes(normalizedQueryStr)); + } + + public async provideDocumentSymbolInformation(document: Document, token: CancellationToken): Promise { + const docSymbols = await this.#symbolProvider.provideDocumentSymbols(document, {}, token); + if (token.isCancellationRequested) { + return []; + } + return Array.from(this.#toSymbolInformation(document.uri, docSymbols)); + } + + *#toSymbolInformation(uri: string, docSymbols: readonly lsp.DocumentSymbol[]): Iterable { + for (const symbol of docSymbols) { + yield { + name: symbol.name, + kind: lsp.SymbolKind.String, + location: { uri, range: symbol.selectionRange } + }; + if (symbol.children) { + yield* this.#toSymbolInformation(uri, symbol.children); + } + } + } } diff --git a/apps/lsp/src/service/quarto.ts b/apps/lsp/src/service/quarto.ts index e0bb23a3..d32bc593 100644 --- a/apps/lsp/src/service/quarto.ts +++ b/apps/lsp/src/service/quarto.ts @@ -111,7 +111,7 @@ export function codeEditorContext( embedded: boolean, explicit?: boolean, trigger?: string -) : EditorContext { +): EditorContext { const line = lines(code)[pos.line]; const position = { row: pos.line, column: pos.character }; @@ -145,14 +145,14 @@ export function docEditorContext( pos: Position, explicit: boolean, trigger?: string -) : EditorContext { +): EditorContext { const path = filePathForDoc(doc); const filetype = isQuartoDoc(doc) ? "markdown" : isQuartoYaml(doc) - ? "yaml" - : "markdown"; // should never get here - + ? "yaml" + : "markdown"; // should never get here + const code = doc.getText(); return codeEditorContext( @@ -165,5 +165,3 @@ export function docEditorContext( trigger ) } - - diff --git a/apps/lsp/src/service/slugify.ts b/apps/lsp/src/service/slugify.ts index 8f4366b3..7f4b28d3 100644 --- a/apps/lsp/src/service/slugify.ts +++ b/apps/lsp/src/service/slugify.ts @@ -16,25 +16,25 @@ import { pandocAutoIdentifier } from "core"; export class Slug { - public constructor( - public readonly value: string - ) { } + public constructor( + public readonly value: string + ) { } - public equals(other: Slug): boolean { - return this.value === other.value; - } + public equals(other: Slug): boolean { + return this.value === other.value; + } } /** * Generates unique ids for headers in the Markdown. */ export interface ISlugifier { - fromHeading(heading: string): Slug; + fromHeading(heading: string): Slug; } export const pandocSlugifier: ISlugifier = new class implements ISlugifier { - fromHeading(heading: string): Slug { + fromHeading(heading: string): Slug { const slugifiedHeading = pandocAutoIdentifier(heading); - return new Slug(slugifiedHeading); - } + return new Slug(slugifiedHeading); + } }; diff --git a/apps/lsp/src/service/toc.ts b/apps/lsp/src/service/toc.ts index ab6a8b2e..8ffe3fd4 100644 --- a/apps/lsp/src/service/toc.ts +++ b/apps/lsp/src/service/toc.ts @@ -19,23 +19,23 @@ import * as lsp from 'vscode-languageserver-types'; import { URI } from 'vscode-uri'; import { Disposable } from 'core'; -import { - Token, - isCallout, - isProof, - isTheorem, - makeRange, - parseFrontMatterStr, - isExecutableLanguageBlock, - isWithinRange, - isTabset, - isFrontMatter, - isHeader, - isCodeBlock, - getDocUri, - getLine, - Document, - Parser, +import { + Token, + isCallout, + isProof, + isTheorem, + makeRange, + parseFrontMatterStr, + isExecutableLanguageBlock, + isWithinRange, + isTabset, + isFrontMatter, + isHeader, + isCodeBlock, + getDocUri, + getLine, + Document, + Parser, } from 'quarto-core'; @@ -48,271 +48,271 @@ import { MdDocumentInfoCache } from './workspace-cache'; export enum TocEntryType { Title, Header, CodeCell }; export interface TocEntry { - readonly type: TocEntryType; - readonly slug: Slug; - readonly text: string; - readonly level: number; - readonly line: number; - - /** - * The entire range of the entry. - * - * For the doc: - * - * ```md - * # Head # - * text - * # Next head # - * ``` - * - * This is the range from `# Head #` to `# Next head #` - */ - readonly sectionLocation: lsp.Location; + readonly type: TocEntryType; + readonly slug: Slug; + readonly text: string; + readonly level: number; + readonly line: number; + + /** + * The entire range of the entry. + * + * For the doc: + * + * ```md + * # Head # + * text + * # Next head # + * ``` + * + * This is the range from `# Head #` to `# Next head #` + */ + readonly sectionLocation: lsp.Location; } export interface TocHeaderEntry extends TocEntry { - /** - * The range of the header declaration. - * - * For the doc: - * - * ```md - * # Head # - * text - * ``` - * - * This is the range of `# Head #` - */ - readonly headerLocation: lsp.Location; - - /** - * The range of the header text. - * - * For the doc: - * - * ```md - * # Head # - * text - * ``` - * - * This is the range of `Head` - */ - readonly headerTextLocation: lsp.Location; + /** + * The range of the header declaration. + * + * For the doc: + * + * ```md + * # Head # + * text + * ``` + * + * This is the range of `# Head #` + */ + readonly headerLocation: lsp.Location; + + /** + * The range of the header text. + * + * For the doc: + * + * ```md + * # Head # + * text + * ``` + * + * This is the range of `Head` + */ + readonly headerTextLocation: lsp.Location; } export function isTocHeaderEntry(entry?: TocEntry): entry is TocHeaderEntry { - return entry !== undefined && - 'headerLocation' in entry && - 'headerTextLocation' in entry; + return entry !== undefined && + 'headerLocation' in entry && + 'headerTextLocation' in entry; } export class TableOfContents { - public static async create(parser: Parser, document: Document, token: CancellationToken): Promise { - const entries = await this.#buildPandocToc(parser, document, token); - return new TableOfContents(entries, pandocSlugifier); - } - - public static async createForContainingDoc(parser: Parser, workspace: IWorkspace, document: Document, token: CancellationToken): Promise { - const context = workspace.getContainingDocument?.(getDocUri(document)); - if (context) { - const entries = (await Promise.all(Array.from(context.children, async cell => { - const doc = await workspace.openMarkdownDocument(cell.uri); - if (!doc || token.isCancellationRequested) { - return []; - } - return this.#buildPandocToc(parser, doc, token); - }))).flat(); - return new TableOfContents(entries, pandocSlugifier); - } - - return this.create(parser, document, token); - } - - static async #buildPandocToc(parser: Parser, document: Document, token: CancellationToken): Promise { - - const docUri = getDocUri(document); - - const toc: TocEntry[] = []; - const tokens = parser(document); - if (token.isCancellationRequested) { - return []; - } - - // compute restricted ranges (ignore headings in these ranges) - const isWithinIgnoredRange = isWithinRange(tokens, token =>isCallout(token) || isTheorem(token) || isProof(token)); - const isWithinTabset = isWithinRange(tokens, isTabset); - - const existingSlugEntries = new Map(); - - const toSlug = (text: string) => { - let slug = pandocSlugifier.fromHeading(text); - const existingSlugEntry = existingSlugEntries.get(slug.value); - if (existingSlugEntry) { - ++existingSlugEntry.count; - slug = pandocSlugifier.fromHeading(slug.value + '-' + existingSlugEntry.count); - } else { - existingSlugEntries.set(slug.value, { count: 0 }); - } - return slug; - } - - const asLocation = (range: lsp.Range) : lsp.Location => { - return { - uri: docUri.toString(), - range - } - } - - const maxHeadingLevel = tokens.reduce((max: number, token: Token) => { - return (isHeader(token) && token.data.level < max) ? token.data.level : max; - }, 2); - - let lastLevel = 2; - - for (let i=0; i el.type === "Div" && el.range.end.line > sectionStart.line); - const nextPeerElement = tokens.slice(i+1).find(el => !isWithinTabset(el) && isHeader(el) && (el.data.level <= level)); - const sectionEndLine = (nextPeerElement && containingDivElement) - ? Math.min(nextPeerElement.range.start.line-1, containingDivElement.range.end.line-2) - : nextPeerElement - ? nextPeerElement.range.start.line - 1 - : containingDivElement - ? containingDivElement.range.end.line - 2 - : (document.lineCount-1); - const sectionEndCharacter = getLine(document, sectionEndLine).length; - const sectionLocation = makeRange(sectionStart, lsp.Position.create(sectionEndLine, sectionEndCharacter)); - - // headerLocation - const headerLocation = token.range; - - // headerTextLocation - let headerTextLocation = token.range; - const headerLine = getLine(document, line); - const headerTextMatch = headerLine.match(/(^#*\s+)([^{]+)/); - if (headerTextMatch) { - headerTextLocation = makeRange( - lsp.Position.create(token.range.start.line, headerTextMatch[1].length), - lsp.Position.create(token.range.start.line, headerTextMatch[0].length)) - } - - const tocEntry: TocHeaderEntry = { - type, - slug, - text, - level, - line, - sectionLocation: asLocation(sectionLocation), - headerLocation: asLocation(headerLocation), - headerTextLocation: asLocation(headerTextLocation) - } - - toc.push(tocEntry); - - } else if (isCodeBlock(token) && isExecutableLanguageBlock(token)) { - const match = (token.data).match(/(?:#|\/\/|)\| label:\s+(.+)/); - const text = match ? match[1] : `(code cell)` - toc.push({ - type: TocEntryType.CodeCell, - slug: toSlug(text), - text: text, - level: lastLevel, - line: token.range.start.line, - sectionLocation: asLocation(token.range), - }) - } - } - - return toc; - } - - - public static readonly empty = new TableOfContents([], pandocSlugifier); - - readonly #slugifier: ISlugifier; - - private constructor( - public readonly entries: readonly TocEntry[], - slugifier: ISlugifier, - ) { - this.#slugifier = slugifier; - } - - public lookup(fragment: string): TocEntry | undefined { - const slug = this.#slugifier.fromHeading(fragment); - return this.entries.find(entry => entry.slug.equals(slug)); - } + public static async create(parser: Parser, document: Document, token: CancellationToken): Promise { + const entries = await this.#buildPandocToc(parser, document, token); + return new TableOfContents(entries, pandocSlugifier); + } + + public static async createForContainingDoc(parser: Parser, workspace: IWorkspace, document: Document, token: CancellationToken): Promise { + const context = workspace.getContainingDocument?.(getDocUri(document)); + if (context) { + const entries = (await Promise.all(Array.from(context.children, async cell => { + const doc = await workspace.openMarkdownDocument(cell.uri); + if (!doc || token.isCancellationRequested) { + return []; + } + return this.#buildPandocToc(parser, doc, token); + }))).flat(); + return new TableOfContents(entries, pandocSlugifier); + } + + return this.create(parser, document, token); + } + + static async #buildPandocToc(parser: Parser, document: Document, token: CancellationToken): Promise { + + const docUri = getDocUri(document); + + const toc: TocEntry[] = []; + const tokens = parser(document); + if (token.isCancellationRequested) { + return []; + } + + // compute restricted ranges (ignore headings in these ranges) + const isWithinIgnoredRange = isWithinRange(tokens, token => isCallout(token) || isTheorem(token) || isProof(token)); + const isWithinTabset = isWithinRange(tokens, isTabset); + + const existingSlugEntries = new Map(); + + const toSlug = (text: string) => { + let slug = pandocSlugifier.fromHeading(text); + const existingSlugEntry = existingSlugEntries.get(slug.value); + if (existingSlugEntry) { + ++existingSlugEntry.count; + slug = pandocSlugifier.fromHeading(slug.value + '-' + existingSlugEntry.count); + } else { + existingSlugEntries.set(slug.value, { count: 0 }); + } + return slug; + } + + const asLocation = (range: lsp.Range): lsp.Location => { + return { + uri: docUri.toString(), + range + } + } + + const maxHeadingLevel = tokens.reduce((max: number, token: Token) => { + return (isHeader(token) && token.data.level < max) ? token.data.level : max; + }, 2); + + let lastLevel = 2; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (isFrontMatter(token)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const meta = parseFrontMatterStr(token.data) as any; + if (typeof (meta) === "object" && typeof (meta.title) === "string") { + toc.push({ + type: TocEntryType.Title, + slug: toSlug(meta.title), + text: meta.title, + level: maxHeadingLevel, + line: token.range.start.line, + sectionLocation: asLocation(token.range), + }) + } + } else if (isHeader(token) && !isWithinIgnoredRange(token)) { + + // type + const type = TocEntryType.Header; + + // text + const text = token.data.text; + + // slug + const slug = toSlug(text); + + // line + const line = token.range.start.line; + + // level + const level = isWithinTabset(token) ? lastLevel + 1 : token.data.level; + lastLevel = token.data.level + 1; + + // sectionLocation + const sectionStart = token.range.start; + const containingDivElement = tokens.slice(0, i).reverse().find(el => el.type === "Div" && el.range.end.line > sectionStart.line); + const nextPeerElement = tokens.slice(i + 1).find(el => !isWithinTabset(el) && isHeader(el) && (el.data.level <= level)); + const sectionEndLine = (nextPeerElement && containingDivElement) + ? Math.min(nextPeerElement.range.start.line - 1, containingDivElement.range.end.line - 2) + : nextPeerElement + ? nextPeerElement.range.start.line - 1 + : containingDivElement + ? containingDivElement.range.end.line - 2 + : (document.lineCount - 1); + const sectionEndCharacter = getLine(document, sectionEndLine).length; + const sectionLocation = makeRange(sectionStart, lsp.Position.create(sectionEndLine, sectionEndCharacter)); + + // headerLocation + const headerLocation = token.range; + + // headerTextLocation + let headerTextLocation = token.range; + const headerLine = getLine(document, line); + const headerTextMatch = headerLine.match(/(^#*\s+)([^{]+)/); + if (headerTextMatch) { + headerTextLocation = makeRange( + lsp.Position.create(token.range.start.line, headerTextMatch[1].length), + lsp.Position.create(token.range.start.line, headerTextMatch[0].length)) + } + + const tocEntry: TocHeaderEntry = { + type, + slug, + text, + level, + line, + sectionLocation: asLocation(sectionLocation), + headerLocation: asLocation(headerLocation), + headerTextLocation: asLocation(headerTextLocation) + } + + toc.push(tocEntry); + + } else if (isCodeBlock(token) && isExecutableLanguageBlock(token)) { + const match = (token.data).match(/(?:#|\/\/|)\| label:\s+(.+)/); + const text = match ? match[1] : `(code cell)` + toc.push({ + type: TocEntryType.CodeCell, + slug: toSlug(text), + text: text, + level: lastLevel, + line: token.range.start.line, + sectionLocation: asLocation(token.range), + }) + } + } + + return toc; + } + + + public static readonly empty = new TableOfContents([], pandocSlugifier); + + readonly #slugifier: ISlugifier; + + private constructor( + public readonly entries: readonly TocEntry[], + slugifier: ISlugifier, + ) { + this.#slugifier = slugifier; + } + + public lookup(fragment: string): TocEntry | undefined { + const slug = this.#slugifier.fromHeading(fragment); + return this.entries.find(entry => entry.slug.equals(slug)); + } } export class MdTableOfContentsProvider extends Disposable { - readonly #cache: MdDocumentInfoCache; + readonly #cache: MdDocumentInfoCache; - readonly #parser: Parser; - readonly #workspace: IWorkspace; - readonly #logger: ILogger; + readonly #parser: Parser; + readonly #workspace: IWorkspace; + readonly #logger: ILogger; - constructor( - parser: Parser, - workspace: IWorkspace, - logger: ILogger, - ) { - super(); + constructor( + parser: Parser, + workspace: IWorkspace, + logger: ILogger, + ) { + super(); - this.#parser = parser; - this.#workspace = workspace; - this.#logger = logger; + this.#parser = parser; + this.#workspace = workspace; + this.#logger = logger; - this.#cache = this._register(new MdDocumentInfoCache(workspace, (doc, token) => { - this.#logger.log(LogLevel.Debug, 'TableOfContentsProvider.create', { document: doc.uri, version: doc.version }); - return TableOfContents.create(parser, doc, token); - })); - } + this.#cache = this._register(new MdDocumentInfoCache(workspace, (doc, token) => { + this.#logger.log(LogLevel.Debug, 'TableOfContentsProvider.create', { document: doc.uri, version: doc.version }); + return TableOfContents.create(parser, doc, token); + })); + } - public async get(resource: URI): Promise { - return await this.#cache.get(resource) ?? TableOfContents.empty; - } + public async get(resource: URI): Promise { + return await this.#cache.get(resource) ?? TableOfContents.empty; + } - public getForDocument(doc: Document): Promise { - return this.#cache.getForDocument(doc); - } + public getForDocument(doc: Document): Promise { + return this.#cache.getForDocument(doc); + } - public getForContainingDoc(doc: Document, token: CancellationToken): Promise { - return TableOfContents.createForContainingDoc(this.#parser, this.#workspace, doc, token); - } + public getForContainingDoc(doc: Document, token: CancellationToken): Promise { + return TableOfContents.createForContainingDoc(this.#parser, this.#workspace, doc, token); + } } diff --git a/apps/lsp/src/service/util/cancellation.ts b/apps/lsp/src/service/util/cancellation.ts index 29445b06..71da9bc0 100644 --- a/apps/lsp/src/service/util/cancellation.ts +++ b/apps/lsp/src/service/util/cancellation.ts @@ -17,8 +17,8 @@ import { CancellationToken, Emitter } from 'vscode-languageserver'; export const noopToken: CancellationToken = new class implements CancellationToken { - readonly #onCancellationRequestedEmitter = new Emitter(); - onCancellationRequested = this.#onCancellationRequestedEmitter.event; + readonly #onCancellationRequestedEmitter = new Emitter(); + onCancellationRequested = this.#onCancellationRequestedEmitter.event; - get isCancellationRequested() { return false; } + get isCancellationRequested() { return false; } }(); diff --git a/apps/lsp/src/service/util/file.ts b/apps/lsp/src/service/util/file.ts index 9ef8d754..b4ae2317 100644 --- a/apps/lsp/src/service/util/file.ts +++ b/apps/lsp/src/service/util/file.ts @@ -19,13 +19,13 @@ import { URI, Utils } from 'vscode-uri'; import { LsConfiguration } from '../config'; export function looksLikeMarkdownUri(config: LsConfiguration, resolvedHrefPath: URI): boolean { - return looksLikeMarkdownExt(config, Utils.extname(resolvedHrefPath)); + return looksLikeMarkdownExt(config, Utils.extname(resolvedHrefPath)); } export function looksLikeMarkdownFilePath(config: LsConfiguration, fileName: string): boolean { - return looksLikeMarkdownExt(config, path.extname(fileName)); + return looksLikeMarkdownExt(config, path.extname(fileName)); } function looksLikeMarkdownExt(config: LsConfiguration, rawExt: string): boolean { - return config.markdownFileExtensions.includes(rawExt.toLowerCase().replace('.', '')); + return config.markdownFileExtensions.includes(rawExt.toLowerCase().replace('.', '')); } diff --git a/apps/lsp/src/service/util/path.ts b/apps/lsp/src/service/util/path.ts index 48138ec1..106cf817 100644 --- a/apps/lsp/src/service/util/path.ts +++ b/apps/lsp/src/service/util/path.ts @@ -19,22 +19,22 @@ import { URI, Utils } from 'vscode-uri'; import { Schemes } from './schemes'; export function isParentDir(parent: URI, maybeChild: URI): boolean { - if (parent.scheme === maybeChild.scheme && parent.authority === maybeChild.authority) { - const relative = path.relative(parent.path, maybeChild.path); - return !relative.startsWith('..'); - } - return false; + if (parent.scheme === maybeChild.scheme && parent.authority === maybeChild.authority) { + const relative = path.relative(parent.path, maybeChild.path); + return !relative.startsWith('..'); + } + return false; } export function computeRelativePath(fromDoc: URI, toDoc: URI, preferDotSlash = false): string | undefined { - if (fromDoc.scheme === toDoc.scheme && fromDoc.scheme !== Schemes.untitled) { - const rootDir = Utils.dirname(fromDoc); - let newLink = path.posix.relative(rootDir.path, toDoc.path); - if (preferDotSlash && !(newLink.startsWith('../') || newLink.startsWith('..\\'))) { - newLink = './' + newLink; - } - return newLink; - } + if (fromDoc.scheme === toDoc.scheme && fromDoc.scheme !== Schemes.untitled) { + const rootDir = Utils.dirname(fromDoc); + let newLink = path.posix.relative(rootDir.path, toDoc.path); + if (preferDotSlash && !(newLink.startsWith('../') || newLink.startsWith('..\\'))) { + newLink = './' + newLink; + } + return newLink; + } - return undefined; + return undefined; } diff --git a/apps/lsp/src/service/util/resource-maps.ts b/apps/lsp/src/service/util/resource-maps.ts index da515d61..1163ce4b 100644 --- a/apps/lsp/src/service/util/resource-maps.ts +++ b/apps/lsp/src/service/util/resource-maps.ts @@ -23,58 +23,58 @@ const defaultResourceToKey = (resource: URI): string => resource.toString(); export class ResourceMap { - readonly #map = new Map(); - - readonly #toKey: ResourceToKey; - - constructor(toKey: ResourceToKey = defaultResourceToKey) { - this.#toKey = toKey; - } - - public set(uri: URI, value: T): this { - this.#map.set(this.#toKey(uri), { uri, value }); - return this; - } - - public get(resource: URI): T | undefined { - return this.#map.get(this.#toKey(resource))?.value; - } - - public has(resource: URI): boolean { - return this.#map.has(this.#toKey(resource)); - } - - public get size(): number { - return this.#map.size; - } - - public clear(): void { - this.#map.clear(); - } - - public delete(resource: URI): boolean { - return this.#map.delete(this.#toKey(resource)); - } - - public *values(): IterableIterator { - for (const entry of this.#map.values()) { - yield entry.value; - } - } - - public *keys(): IterableIterator { - for (const entry of this.#map.values()) { - yield entry.uri; - } - } - - public *entries(): IterableIterator<[URI, T]> { - for (const entry of this.#map.values()) { - yield [entry.uri, entry.value]; - } - } - - public [Symbol.iterator](): IterableIterator<[URI, T]> { - return this.entries(); - } + readonly #map = new Map(); + + readonly #toKey: ResourceToKey; + + constructor(toKey: ResourceToKey = defaultResourceToKey) { + this.#toKey = toKey; + } + + public set(uri: URI, value: T): this { + this.#map.set(this.#toKey(uri), { uri, value }); + return this; + } + + public get(resource: URI): T | undefined { + return this.#map.get(this.#toKey(resource))?.value; + } + + public has(resource: URI): boolean { + return this.#map.has(this.#toKey(resource)); + } + + public get size(): number { + return this.#map.size; + } + + public clear(): void { + this.#map.clear(); + } + + public delete(resource: URI): boolean { + return this.#map.delete(this.#toKey(resource)); + } + + public *values(): IterableIterator { + for (const entry of this.#map.values()) { + yield entry.value; + } + } + + public *keys(): IterableIterator { + for (const entry of this.#map.values()) { + yield entry.uri; + } + } + + public *entries(): IterableIterator<[URI, T]> { + for (const entry of this.#map.values()) { + yield [entry.uri, entry.value]; + } + } + + public [Symbol.iterator](): IterableIterator<[URI, T]> { + return this.entries(); + } } diff --git a/apps/lsp/src/service/util/schemes.ts b/apps/lsp/src/service/util/schemes.ts index c44db7af..5dfd3355 100644 --- a/apps/lsp/src/service/util/schemes.ts +++ b/apps/lsp/src/service/util/schemes.ts @@ -15,7 +15,6 @@ */ export const Schemes = Object.freeze({ - file: 'file', - untitled: 'untitled', + file: 'file', + untitled: 'untitled', }); - diff --git a/apps/lsp/src/service/util/string.ts b/apps/lsp/src/service/util/string.ts index 4051fe83..f3a9a1f6 100644 --- a/apps/lsp/src/service/util/string.ts +++ b/apps/lsp/src/service/util/string.ts @@ -15,7 +15,7 @@ */ export function isEmptyOrWhitespace(str: string): boolean { - return /^\s*$/.test(str); + return /^\s*$/.test(str); } export const r = String.raw; diff --git a/apps/lsp/src/service/workspace-cache.ts b/apps/lsp/src/service/workspace-cache.ts index 7e108029..45b4a6b6 100644 --- a/apps/lsp/src/service/workspace-cache.ts +++ b/apps/lsp/src/service/workspace-cache.ts @@ -34,91 +34,91 @@ type GetValueFn = (document: Document, token: CancellationToken) => Promise extends Disposable { - readonly #cache = new ResourceMap<{ - readonly value: Lazy>; - readonly cts: CancellationTokenSource; - }>(); - - readonly #loadingDocuments = new ResourceMap>(); - - readonly #workspace: IWorkspace; - readonly #getValue: GetValueFn; - - public constructor(workspace: IWorkspace, getValue: GetValueFn) { - super(); - - this.#workspace = workspace; - this.#getValue = getValue; - - this._register(this.#workspace.onDidChangeMarkdownDocument(doc => this.#invalidate(doc))); - this._register(this.#workspace.onDidDeleteMarkdownDocument(this.#onDidDeleteDocument, this)); - } - - public async get(resource: URI): Promise { - let existing = this.#cache.get(resource); - if (existing) { - return existing.value.value; - } - - const doc = await this.#loadDocument(resource); - if (!doc) { - return undefined; - } - - // Check if we have invalidated - existing = this.#cache.get(resource); - if (existing) { - return existing.value.value; - } - - return this.#resetEntry(doc)?.value; - } - - public async getForDocument(document: Document): Promise { - const existing = this.#cache.get(getDocUri(document)); - if (existing) { - return existing.value.value; - } - return this.#resetEntry(document).value; - } - - #loadDocument(resource: URI): Promise { - const existing = this.#loadingDocuments.get(resource); - if (existing) { - return existing; - } - - const p = this.#workspace.openMarkdownDocument(resource); - this.#loadingDocuments.set(resource, p); - p.finally(() => { - this.#loadingDocuments.delete(resource); - }); - return p; - } - - #resetEntry(document: Document): Lazy> { - // TODO: cancel old request? - - const cts = new CancellationTokenSource(); - const value = lazy(() => this.#getValue(document, cts.token)); - this.#cache.set(getDocUri(document), { value, cts }); - return value; - } - - #invalidate(document: Document): void { - if (this.#cache.has(getDocUri(document))) { - this.#resetEntry(document); - } - } - - #onDidDeleteDocument(resource: URI) { - const entry = this.#cache.get(resource); - if (entry) { - entry.cts.cancel(); - entry.cts.dispose(); - this.#cache.delete(resource); - } - } + readonly #cache = new ResourceMap<{ + readonly value: Lazy>; + readonly cts: CancellationTokenSource; + }>(); + + readonly #loadingDocuments = new ResourceMap>(); + + readonly #workspace: IWorkspace; + readonly #getValue: GetValueFn; + + public constructor(workspace: IWorkspace, getValue: GetValueFn) { + super(); + + this.#workspace = workspace; + this.#getValue = getValue; + + this._register(this.#workspace.onDidChangeMarkdownDocument(doc => this.#invalidate(doc))); + this._register(this.#workspace.onDidDeleteMarkdownDocument(this.#onDidDeleteDocument, this)); + } + + public async get(resource: URI): Promise { + let existing = this.#cache.get(resource); + if (existing) { + return existing.value.value; + } + + const doc = await this.#loadDocument(resource); + if (!doc) { + return undefined; + } + + // Check if we have invalidated + existing = this.#cache.get(resource); + if (existing) { + return existing.value.value; + } + + return this.#resetEntry(doc)?.value; + } + + public async getForDocument(document: Document): Promise { + const existing = this.#cache.get(getDocUri(document)); + if (existing) { + return existing.value.value; + } + return this.#resetEntry(document).value; + } + + #loadDocument(resource: URI): Promise { + const existing = this.#loadingDocuments.get(resource); + if (existing) { + return existing; + } + + const p = this.#workspace.openMarkdownDocument(resource); + this.#loadingDocuments.set(resource, p); + p.finally(() => { + this.#loadingDocuments.delete(resource); + }); + return p; + } + + #resetEntry(document: Document): Lazy> { + // TODO: cancel old request? + + const cts = new CancellationTokenSource(); + const value = lazy(() => this.#getValue(document, cts.token)); + this.#cache.set(getDocUri(document), { value, cts }); + return value; + } + + #invalidate(document: Document): void { + if (this.#cache.has(getDocUri(document))) { + this.#resetEntry(document); + } + } + + #onDidDeleteDocument(resource: URI) { + const entry = this.#cache.get(resource); + if (entry) { + entry.cts.cancel(); + entry.cts.dispose(); + this.#cache.delete(resource); + } + } } /** @@ -129,86 +129,86 @@ export class MdDocumentInfoCache extends Disposable { */ export class MdWorkspaceInfoCache extends Disposable { - readonly #cache = new ResourceMap<{ - readonly value: Lazy>; - readonly cts: CancellationTokenSource; - }>(); - - #init?: Promise; - - readonly #workspace: IWorkspace; - readonly #getValue: GetValueFn; - - public constructor(workspace: IWorkspace, getValue: GetValueFn) { - super(); - - this.#workspace = workspace; - this.#getValue = getValue; - - this._register(this.#workspace.onDidChangeMarkdownDocument(this.#onDidChangeDocument, this)); - this._register(this.#workspace.onDidCreateMarkdownDocument(this.#onDidChangeDocument, this)); - this._register(this.#workspace.onDidDeleteMarkdownDocument(this.#onDidDeleteDocument, this)); - } - - public async entries(): Promise> { - await this.#ensureInit(); - - return Promise.all(Array.from(this.#cache.entries(), async ([k, v]) => { - return [k, await v.value.value]; - })); - } - - public async values(): Promise> { - await this.#ensureInit(); - return Promise.all(Array.from(this.#cache.entries(), x => x[1].value.value)); - } - - public async getForDocs(docs: readonly Document[]): Promise { - for (const doc of docs) { - if (!this.#cache.has(getDocUri(doc))) { - this.#update(doc); - } - } - - return Promise.all(docs.map(doc => this.#cache.get(getDocUri(doc))!.value.value)); - } - - async #ensureInit(): Promise { - if (!this.#init) { - this.#init = this.#populateCache(); - } - await this.#init; - } - - async #populateCache(): Promise { - const markdownDocuments = await this.#workspace.getAllMarkdownDocuments(); - for (const document of markdownDocuments) { - if (!this.#cache.has(getDocUri(document))) { - this.#update(document); - } - } - } - - #update(document: Document): void { - // TODO: cancel old request? - - const cts = new CancellationTokenSource(); - this.#cache.set(getDocUri(document), { - value: lazy(() => this.#getValue(document, cts.token)), - cts - }); - } - - #onDidChangeDocument(document: Document) { - this.#update(document); - } - - #onDidDeleteDocument(resource: URI) { - const entry = this.#cache.get(resource); - if (entry) { - entry.cts.cancel(); - entry.cts.dispose(); - this.#cache.delete(resource); - } - } + readonly #cache = new ResourceMap<{ + readonly value: Lazy>; + readonly cts: CancellationTokenSource; + }>(); + + #init?: Promise; + + readonly #workspace: IWorkspace; + readonly #getValue: GetValueFn; + + public constructor(workspace: IWorkspace, getValue: GetValueFn) { + super(); + + this.#workspace = workspace; + this.#getValue = getValue; + + this._register(this.#workspace.onDidChangeMarkdownDocument(this.#onDidChangeDocument, this)); + this._register(this.#workspace.onDidCreateMarkdownDocument(this.#onDidChangeDocument, this)); + this._register(this.#workspace.onDidDeleteMarkdownDocument(this.#onDidDeleteDocument, this)); + } + + public async entries(): Promise> { + await this.#ensureInit(); + + return Promise.all(Array.from(this.#cache.entries(), async ([k, v]) => { + return [k, await v.value.value]; + })); + } + + public async values(): Promise> { + await this.#ensureInit(); + return Promise.all(Array.from(this.#cache.entries(), x => x[1].value.value)); + } + + public async getForDocs(docs: readonly Document[]): Promise { + for (const doc of docs) { + if (!this.#cache.has(getDocUri(doc))) { + this.#update(doc); + } + } + + return Promise.all(docs.map(doc => this.#cache.get(getDocUri(doc))!.value.value)); + } + + async #ensureInit(): Promise { + if (!this.#init) { + this.#init = this.#populateCache(); + } + await this.#init; + } + + async #populateCache(): Promise { + const markdownDocuments = await this.#workspace.getAllMarkdownDocuments(); + for (const document of markdownDocuments) { + if (!this.#cache.has(getDocUri(document))) { + this.#update(document); + } + } + } + + #update(document: Document): void { + // TODO: cancel old request? + + const cts = new CancellationTokenSource(); + this.#cache.set(getDocUri(document), { + value: lazy(() => this.#getValue(document, cts.token)), + cts + }); + } + + #onDidChangeDocument(document: Document) { + this.#update(document); + } + + #onDidDeleteDocument(resource: URI) { + const entry = this.#cache.get(resource); + if (entry) { + entry.cts.cancel(); + entry.cts.dispose(); + this.#cache.delete(resource); + } + } } diff --git a/apps/lsp/src/service/workspace.ts b/apps/lsp/src/service/workspace.ts index 5a169233..7bf12dc1 100644 --- a/apps/lsp/src/service/workspace.ts +++ b/apps/lsp/src/service/workspace.ts @@ -24,27 +24,27 @@ import { ResourceMap } from './util/resource-maps'; * Result of {@link IWorkspace.stat stating} a file. */ export interface FileStat { - /** - * True if the file is directory. - */ - readonly isDirectory: boolean; + /** + * True if the file is directory. + */ + readonly isDirectory: boolean; } /** * Information about a parent markdown document that contains sub-documents. - * + * * This could be a notebook document for example, where the `children` are the Markdown cells in the notebook. */ export interface ContainingDocumentContext { - /** - * Uri of the parent document. - */ - readonly uri: URI; - - /** - * List of child markdown documents. - */ - readonly children: Iterable<{ readonly uri: URI }>; + /** + * Uri of the parent document. + */ + readonly uri: URI; + + /** + * List of child markdown documents. + */ + readonly children: Iterable<{ readonly uri: URI }>; } /** @@ -52,102 +52,102 @@ export interface ContainingDocumentContext { */ export interface IWorkspace { - /** - * Get the root folders for this workspace. - */ - get workspaceFolders(): readonly URI[]; - - /** - * Fired when the content of a markdown document changes. - */ - readonly onDidChangeMarkdownDocument: Event; - - /** - * Fired when a markdown document is first created. - */ - readonly onDidCreateMarkdownDocument: Event; - - /** - * Fired when a markdown document is deleted. - */ - readonly onDidDeleteMarkdownDocument: Event; - - /** - * Get complete list of markdown documents. - * - * This may include documents that have not been opened yet (for example, getAllMarkdownDocuments should - * return documents from disk even if they have not been opened yet in the editor) - */ - getAllMarkdownDocuments(): Promise>; - - /** - * Check if a document already exists in the workspace contents. - */ - hasMarkdownDocument(resource: URI): boolean; - - /** - * Try to open a markdown document. - * - * This may either get the document from a cache or open it and add it to the cache. - * - * @returns The document, or `undefined` if the file could not be opened or was not a markdown file. - */ - openMarkdownDocument(resource: URI): Promise; - - /** - * Get metadata about a file. - * - * @param resource URI to check. Does not have to be to a markdown file. - * - * @returns Metadata or `undefined` if the resource does not exist. - */ - stat(resource: URI): Promise; - - /** - * List all files in a directory. - * - * @param resource URI of the directory to check. Does not have to be to a markdown file. - * - * @returns List of `[fileName, metadata]` tuples. - */ - readDirectory(resource: URI): Promise>; - - /** - * Get the document that contains `resource` as a sub document. - * - * If `resource` is a notebook cell for example, this should return the parent notebook. - * - * @returns The parent document info or `undefined` if none. - */ - getContainingDocument?(resource: URI): ContainingDocumentContext | undefined; + /** + * Get the root folders for this workspace. + */ + get workspaceFolders(): readonly URI[]; + + /** + * Fired when the content of a markdown document changes. + */ + readonly onDidChangeMarkdownDocument: Event; + + /** + * Fired when a markdown document is first created. + */ + readonly onDidCreateMarkdownDocument: Event; + + /** + * Fired when a markdown document is deleted. + */ + readonly onDidDeleteMarkdownDocument: Event; + + /** + * Get complete list of markdown documents. + * + * This may include documents that have not been opened yet (for example, getAllMarkdownDocuments should + * return documents from disk even if they have not been opened yet in the editor) + */ + getAllMarkdownDocuments(): Promise>; + + /** + * Check if a document already exists in the workspace contents. + */ + hasMarkdownDocument(resource: URI): boolean; + + /** + * Try to open a markdown document. + * + * This may either get the document from a cache or open it and add it to the cache. + * + * @returns The document, or `undefined` if the file could not be opened or was not a markdown file. + */ + openMarkdownDocument(resource: URI): Promise; + + /** + * Get metadata about a file. + * + * @param resource URI to check. Does not have to be to a markdown file. + * + * @returns Metadata or `undefined` if the resource does not exist. + */ + stat(resource: URI): Promise; + + /** + * List all files in a directory. + * + * @param resource URI of the directory to check. Does not have to be to a markdown file. + * + * @returns List of `[fileName, metadata]` tuples. + */ + readDirectory(resource: URI): Promise>; + + /** + * Get the document that contains `resource` as a sub document. + * + * If `resource` is a notebook cell for example, this should return the parent notebook. + * + * @returns The parent document info or `undefined` if none. + */ + getContainingDocument?(resource: URI): ContainingDocumentContext | undefined; } /** * Configures which events a {@link IFileSystemWatcher} fires. */ export interface FileWatcherOptions { - /** Ignore file creation events. */ - readonly ignoreCreate?: boolean; + /** Ignore file creation events. */ + readonly ignoreCreate?: boolean; - /** Ignore file change events. */ - readonly ignoreChange?: boolean; + /** Ignore file change events. */ + readonly ignoreChange?: boolean; - /** Ignore file delete events. */ - readonly ignoreDelete?: boolean; + /** Ignore file delete events. */ + readonly ignoreDelete?: boolean; } /** * A workspace that also supports watching arbitrary files. */ export interface IWorkspaceWithWatching extends IWorkspace { - /** - * Start watching a given file. - */ - watchFile(path: URI, options: FileWatcherOptions): IFileSystemWatcher; + /** + * Start watching a given file. + */ + watchFile(path: URI, options: FileWatcherOptions): IFileSystemWatcher; } export function isWorkspaceWithFileWatching(workspace: IWorkspace): workspace is IWorkspaceWithWatching { - return 'watchFile' in workspace; + return 'watchFile' in workspace; } /** @@ -155,59 +155,59 @@ export function isWorkspaceWithFileWatching(workspace: IWorkspace): workspace is */ export interface IFileSystemWatcher { - /** - * Dispose of the watcher. This should stop watching and clean up any associated resources. - */ - dispose(): void; + /** + * Dispose of the watcher. This should stop watching and clean up any associated resources. + */ + dispose(): void; - /** Fired when the file is created. */ - readonly onDidCreate: Event; + /** Fired when the file is created. */ + readonly onDidCreate: Event; - /** Fired when the file is changed on the file system. */ - readonly onDidChange: Event; + /** Fired when the file is changed on the file system. */ + readonly onDidChange: Event; - /** Fired when the file is deleted. */ - readonly onDidDelete: Event; + /** Fired when the file is deleted. */ + readonly onDidDelete: Event; } export function getWorkspaceFolder(workspace: IWorkspace, docUri: URI): URI | undefined { - if (workspace.workspaceFolders.length === 0) { - return undefined; - } - - // Find the longest match - const possibleWorkspaces = workspace.workspaceFolders - .filter(folder => - folder.scheme === docUri.scheme - && folder.authority === docUri.authority - && (docUri.fsPath.startsWith(folder.fsPath + '/') || docUri.fsPath.startsWith(folder.fsPath + '\\'))) - .sort((a, b) => b.fsPath.length - a.fsPath.length); - - if (possibleWorkspaces.length) { - return possibleWorkspaces[0]; - } - - // Default to first workspace - // TODO: Does this make sense? - return workspace.workspaceFolders[0]; + if (workspace.workspaceFolders.length === 0) { + return undefined; + } + + // Find the longest match + const possibleWorkspaces = workspace.workspaceFolders + .filter(folder => + folder.scheme === docUri.scheme + && folder.authority === docUri.authority + && (docUri.fsPath.startsWith(folder.fsPath + '/') || docUri.fsPath.startsWith(folder.fsPath + '\\'))) + .sort((a, b) => b.fsPath.length - a.fsPath.length); + + if (possibleWorkspaces.length) { + return possibleWorkspaces[0]; + } + + // Default to first workspace + // TODO: Does this make sense? + return workspace.workspaceFolders[0]; } export async function openLinkToMarkdownFile(config: LsConfiguration, workspace: IWorkspace, resource: URI): Promise { - try { - const doc = await workspace.openMarkdownDocument(resource); - if (doc) { - return doc; - } - } catch { - // Noop - } - - const dotMdResource = tryAppendMarkdownFileExtension(config, resource); - if (dotMdResource) { - return workspace.openMarkdownDocument(dotMdResource); - } - - return undefined; + try { + const doc = await workspace.openMarkdownDocument(resource); + if (doc) { + return doc; + } + } catch { + // Noop + } + + const dotMdResource = tryAppendMarkdownFileExtension(config, resource); + if (dotMdResource) { + return workspace.openMarkdownDocument(dotMdResource); + } + + return undefined; } /** @@ -216,34 +216,34 @@ export async function openLinkToMarkdownFile(config: LsConfiguration, workspace: * @returns The resolved URI or `undefined` if the file does not exist. */ export async function statLinkToMarkdownFile(config: LsConfiguration, workspace: IWorkspace, linkUri: URI, out_statCache?: ResourceMap<{ readonly exists: boolean }>): Promise { - const exists = async (uri: URI): Promise => { - const result = await workspace.stat(uri); - out_statCache?.set(uri, { exists: !!result }); - return !!result; - }; - - if (await exists(linkUri)) { - return linkUri; - } - - // We don't think the file exists. See if we need to append `.md` - const dotMdResource = tryAppendMarkdownFileExtension(config, linkUri); - if (dotMdResource && await exists(dotMdResource)) { - return dotMdResource; - } - - return undefined; + const exists = async (uri: URI): Promise => { + const result = await workspace.stat(uri); + out_statCache?.set(uri, { exists: !!result }); + return !!result; + }; + + if (await exists(linkUri)) { + return linkUri; + } + + // We don't think the file exists. See if we need to append `.md` + const dotMdResource = tryAppendMarkdownFileExtension(config, linkUri); + if (dotMdResource && await exists(dotMdResource)) { + return dotMdResource; + } + + return undefined; } export function tryAppendMarkdownFileExtension(config: LsConfiguration, linkUri: URI): URI | undefined { - const ext = Utils.extname(linkUri).toLowerCase().replace(/^\./, ''); - if (config.markdownFileExtensions.includes(ext)) { - return linkUri; - } - - if (ext === '' || !config.knownLinkedToFileExtensions.includes(ext)) { - return linkUri.with({ path: linkUri.path + '.' + (config.markdownFileExtensions[0] ?? defaultMarkdownFileExtension) }); - } - - return undefined; -} \ No newline at end of file + const ext = Utils.extname(linkUri).toLowerCase().replace(/^\./, ''); + if (config.markdownFileExtensions.includes(ext)) { + return linkUri; + } + + if (ext === '' || !config.knownLinkedToFileExtensions.includes(ext)) { + return linkUri.with({ path: linkUri.path + '.' + (config.markdownFileExtensions[0] ?? defaultMarkdownFileExtension) }); + } + + return undefined; +} diff --git a/apps/lsp/src/workspace.ts b/apps/lsp/src/workspace.ts index 62dd657b..69c4b82f 100644 --- a/apps/lsp/src/workspace.ts +++ b/apps/lsp/src/workspace.ts @@ -30,11 +30,11 @@ import { Position, Range, TextDocument } from "vscode-languageserver-textdocumen import { Document, isQuartoDoc } from "quarto-core"; -import { - FileStat, - ILogger, - LogLevel, - LsConfiguration, +import { + FileStat, + ILogger, + LogLevel, + LsConfiguration, IWorkspace, IWorkspaceWithWatching, FileWatcherOptions @@ -48,10 +48,10 @@ export function languageServiceWorkspace( workspaceFolders: URI[], documents: TextDocuments, connection: Connection, - capabilities: ClientCapabilities, + capabilities: ClientCapabilities, config: LsConfiguration, logger: ILogger -) : IWorkspace | IWorkspaceWithWatching { +): IWorkspace | IWorkspaceWithWatching { // track changes to workspace folders connection.workspace.onDidChangeWorkspaceFolders(async () => { @@ -62,46 +62,46 @@ export function languageServiceWorkspace( const documentCache = new ResourceMap(); const openMarkdownDocumentFromFs = async (resource: URI): Promise => { - if (!looksLikeMarkdownPath(config, resource)) { - return undefined; - } + if (!looksLikeMarkdownPath(config, resource)) { + return undefined; + } - try { + try { const text = await fspromises.readFile(resource.fsPath, { encoding: "utf-8" }); const doc = new VsCodeDocument(resource.toString(), { - onDiskDoc: TextDocument.create(resource.toString(), 'markdown', 0, text) - }); - documentCache.set(resource, doc); - return doc; + onDiskDoc: TextDocument.create(resource.toString(), 'markdown', 0, text) + }); + documentCache.set(resource, doc); + return doc; - } catch (e) { - return undefined; - } - } + } catch (e) { + return undefined; + } + } const statBypassingCache = (resource: URI): FileStat | undefined => { - const uri = resource.toString(); - if (documents.get(uri)) { - return { isDirectory: false }; - } + const uri = resource.toString(); + if (documents.get(uri)) { + return { isDirectory: false }; + } try { const stat = fs.statSync(resource.fsPath); return { isDirectory: stat.isDirectory() }; } catch { return undefined; } - } + } // track changes to documents const onDidChangeMarkdownDocument = new Emitter(); - const onDidCreateMarkdownDocument = new Emitter(); + const onDidCreateMarkdownDocument = new Emitter(); const onDidDeleteMarkdownDocument = new Emitter(); const doDeleteDocument = (uri: URI) => { - logger.log(LogLevel.Trace, 'VsCodeClientWorkspace.deleteDocument', { document: uri.toString() }); - documentCache.delete(uri); - onDidDeleteMarkdownDocument.fire(uri); - } + logger.log(LogLevel.Trace, 'VsCodeClientWorkspace.deleteDocument', { document: uri.toString() }); + documentCache.delete(uri); + onDidDeleteMarkdownDocument.fire(uri); + } documents.onDidOpen(e => { if (!isRelevantMarkdownDocument(e.document)) { @@ -179,7 +179,7 @@ export function languageServiceWorkspace( onDidChangeMarkdownDocument.fire(doc); }); - const workspace : IWorkspace = { + const workspace: IWorkspace = { get workspaceFolders(): readonly URI[] { return workspaceFolders; @@ -199,13 +199,13 @@ export function languageServiceWorkspace( allDocs.set(URI.parse(doc.uri), doc); } - // And then add files on disk + // And then add files on disk for (const workspaceFolder of this.workspaceFolders) { const mdFileGlob = `**/*.{${config.markdownFileExtensions.join(',')}}`; - const ignore = [...config.excludePaths]; - const resources = (await glob(mdFileGlob, { ignore, cwd: workspaceFolder.toString() } )) + const ignore = [...config.excludePaths]; + const resources = (await glob(mdFileGlob, { ignore, cwd: workspaceFolder.toString() })) .map(resource => URI.file(path.join(workspaceFolder.fsPath, resource))) - + // (read max 20 at a time) const maxConcurrent = 20; @@ -226,7 +226,7 @@ export function languageServiceWorkspace( return allDocs.values(); }, - + hasMarkdownDocument(resource: URI): boolean { return !!documents.get(resource.toString()); }, @@ -251,8 +251,8 @@ export function languageServiceWorkspace( } return openMarkdownDocumentFromFs(resource); - }, - + }, + async stat(resource: URI): Promise { logger.log(LogLevel.Trace, 'VsCodeClientWorkspace.stat', { resource: resource.toString() }); if (documentCache.has(resource)) { @@ -264,10 +264,10 @@ export function languageServiceWorkspace( async readDirectory(resource: URI): Promise> { logger.log(LogLevel.Trace, 'VsCodeClientWorkspace.readDirectory', { resource: resource.toString() }); const result = await fspromises.readdir(resource.fsPath, { withFileTypes: true }); - return result.map(value => [value.name, { isDirectory: value.isDirectory( )}]); + return result.map(value => [value.name, { isDirectory: value.isDirectory() }]); }, - + }; // add file watching if supported on the client @@ -312,51 +312,51 @@ export function languageServiceWorkspace( // keep document cache up to date and notify clients for (const change of changes) { - const resource = URI.parse(change.uri); - logger.log(LogLevel.Trace, 'VsCodeClientWorkspace.onDidChangeWatchedFiles', { type: change.type, resource: resource.toString() }); - switch (change.type) { - case FileChangeType.Changed: { - const entry = documentCache.get(resource); - if (entry) { - // Refresh the on-disk state - const document = await openMarkdownDocumentFromFs(resource); - if (document) { - onDidChangeMarkdownDocument.fire(document); - } - } - break; - } - case FileChangeType.Created: { + const resource = URI.parse(change.uri); + logger.log(LogLevel.Trace, 'VsCodeClientWorkspace.onDidChangeWatchedFiles', { type: change.type, resource: resource.toString() }); + switch (change.type) { + case FileChangeType.Changed: { + const entry = documentCache.get(resource); + if (entry) { + // Refresh the on-disk state + const document = await openMarkdownDocumentFromFs(resource); + if (document) { + onDidChangeMarkdownDocument.fire(document); + } + } + break; + } + case FileChangeType.Created: { console.log("FileChangeType.Created"); - const entry = documentCache.get(resource); - if (entry) { - // Create or update the on-disk state - const document = await openMarkdownDocumentFromFs(resource); - if (document) { - onDidCreateMarkdownDocument.fire(document); - } - } - break; - } - case FileChangeType.Deleted: { - const entry = documentCache.get(resource); - if (entry) { - entry.setOnDiskDoc(undefined); - if (entry.isDetached()) { - doDeleteDocument(resource); - } - } - break; - } - } - } - }); - + const entry = documentCache.get(resource); + if (entry) { + // Create or update the on-disk state + const document = await openMarkdownDocumentFromFs(resource); + if (document) { + onDidCreateMarkdownDocument.fire(document); + } + } + break; + } + case FileChangeType.Deleted: { + const entry = documentCache.get(resource); + if (entry) { + entry.setOnDiskDoc(undefined); + if (entry.isDetached()) { + doDeleteDocument(resource); + } + } + break; + } + } + } + }); + // add watching to workspace const fsWorkspace: IWorkspaceWithWatching = { ...workspace, watchFile(resource, options) { - logger.log(LogLevel.Trace, 'VsCodeClientWorkspace.watchFile', { resource: resource.toString() }); + logger.log(LogLevel.Trace, 'VsCodeClientWorkspace.watchFile', { resource: resource.toString() }); const entry = { resource, @@ -365,7 +365,7 @@ export function languageServiceWorkspace( onDidChange: new Emitter(), onDidDelete: new Emitter(), }; - watchers.set(entry.resource.toString(), entry); + watchers.set(entry.resource.toString(), entry); return { onDidCreate: entry.onDidCreate.event, onDidChange: entry.onDidChange.event, @@ -379,8 +379,8 @@ export function languageServiceWorkspace( }, } return fsWorkspace; - - // return vanilla workspace w/o watching + + // return vanilla workspace w/o watching } else { return workspace; } @@ -388,78 +388,78 @@ export function languageServiceWorkspace( } function isRelevantMarkdownDocument(doc: Document) { - return isQuartoDoc(doc) && URI.parse(doc.uri).scheme !== 'vscode-bulkeditpreview'; + return isQuartoDoc(doc) && URI.parse(doc.uri).scheme !== 'vscode-bulkeditpreview'; } function looksLikeMarkdownPath(config: LsConfiguration, resolvedHrefPath: URI) { - return config.markdownFileExtensions.includes(path.extname(resolvedHrefPath.fsPath).toLowerCase().replace('.', '')); + return config.markdownFileExtensions.includes(path.extname(resolvedHrefPath.fsPath).toLowerCase().replace('.', '')); } class VsCodeDocument implements Document { - private inMemoryDoc?: Document; - private onDiskDoc?: Document; + private inMemoryDoc?: Document; + private onDiskDoc?: Document; - readonly uri: string; + readonly uri: string; - constructor(uri: string, init: { inMemoryDoc: Document }); - constructor(uri: string, init: { onDiskDoc: Document }); - constructor(uri: string, init: { inMemoryDoc?: Document; onDiskDoc?: Document }) { - this.uri = uri; - this.inMemoryDoc = init?.inMemoryDoc; - this.onDiskDoc = init?.onDiskDoc; - } + constructor(uri: string, init: { inMemoryDoc: Document }); + constructor(uri: string, init: { onDiskDoc: Document }); + constructor(uri: string, init: { inMemoryDoc?: Document; onDiskDoc?: Document }) { + this.uri = uri; + this.inMemoryDoc = init?.inMemoryDoc; + this.onDiskDoc = init?.onDiskDoc; + } - get languageId() : string | undefined { + get languageId(): string | undefined { return this.inMemoryDoc?.languageId ?? this.onDiskDoc?.languageId; } - get version(): number { - return this.inMemoryDoc?.version ?? this.onDiskDoc?.version ?? 0; - } + get version(): number { + return this.inMemoryDoc?.version ?? this.onDiskDoc?.version ?? 0; + } - get lineCount(): number { - return this.inMemoryDoc?.lineCount ?? this.onDiskDoc?.lineCount ?? 0; - } + get lineCount(): number { + return this.inMemoryDoc?.lineCount ?? this.onDiskDoc?.lineCount ?? 0; + } - getText(range?: Range): string { - if (this.inMemoryDoc) { - return this.inMemoryDoc.getText(range); - } + getText(range?: Range): string { + if (this.inMemoryDoc) { + return this.inMemoryDoc.getText(range); + } - if (this.onDiskDoc) { - return this.onDiskDoc.getText(range); - } + if (this.onDiskDoc) { + return this.onDiskDoc.getText(range); + } - throw new Error('Document has been closed'); - } + throw new Error('Document has been closed'); + } - positionAt(offset: number): Position { - if (this.inMemoryDoc) { - return this.inMemoryDoc.positionAt(offset); - } + positionAt(offset: number): Position { + if (this.inMemoryDoc) { + return this.inMemoryDoc.positionAt(offset); + } - if (this.onDiskDoc) { - return this.onDiskDoc.positionAt(offset); - } + if (this.onDiskDoc) { + return this.onDiskDoc.positionAt(offset); + } - throw new Error('Document has been closed'); - } + throw new Error('Document has been closed'); + } - hasInMemoryDoc(): boolean { - return !!this.inMemoryDoc; - } + hasInMemoryDoc(): boolean { + return !!this.inMemoryDoc; + } - isDetached(): boolean { - return !this.onDiskDoc && !this.inMemoryDoc; - } + isDetached(): boolean { + return !this.onDiskDoc && !this.inMemoryDoc; + } - setInMemoryDoc(doc: Document | undefined) { - this.inMemoryDoc = doc; - } + setInMemoryDoc(doc: Document | undefined) { + this.inMemoryDoc = doc; + } - setOnDiskDoc(doc: TextDocument | undefined) { - this.onDiskDoc = doc; - } -} \ No newline at end of file + setOnDiskDoc(doc: TextDocument | undefined) { + this.onDiskDoc = doc; + } +} diff --git a/apps/vscode/src/providers/copyfiles/drop.ts b/apps/vscode/src/providers/copyfiles/drop.ts index 7a03850a..4c732c0a 100644 --- a/apps/vscode/src/providers/copyfiles/drop.ts +++ b/apps/vscode/src/providers/copyfiles/drop.ts @@ -27,131 +27,131 @@ import { Schemes } from '../../core/schemes'; import './types'; export const imageFileExtensions = new Set([ - 'bmp', - 'gif', - 'ico', - 'jpe', - 'jpeg', - 'jpg', - 'png', - 'psd', - 'svg', - 'tga', - 'tif', - 'tiff', - 'webp', + 'bmp', + 'gif', + 'ico', + 'jpe', + 'jpeg', + 'jpg', + 'png', + 'psd', + 'svg', + 'tga', + 'tif', + 'tiff', + 'webp', ]); export function registerDropIntoEditorSupport(selector: vscode.DocumentSelector) { - return vscode.languages.registerDocumentDropEditProvider(selector, new class implements vscode.DocumentDropEditProvider { - async provideDocumentDropEdits(document: vscode.TextDocument, _position: vscode.Position, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { - const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.drop.enabled', true); - if (!enabled) { - return undefined; - } - - const snippet = await tryGetUriListSnippet(document, dataTransfer, token); - return snippet ? new vscode.DocumentDropEdit(snippet) : undefined; - } - }); + return vscode.languages.registerDocumentDropEditProvider(selector, new class implements vscode.DocumentDropEditProvider { + async provideDocumentDropEdits(document: vscode.TextDocument, _position: vscode.Position, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { + const enabled = vscode.workspace.getConfiguration('markdown', document).get('editor.drop.enabled', true); + if (!enabled) { + return undefined; + } + + const snippet = await tryGetUriListSnippet(document, dataTransfer, token); + return snippet ? new vscode.DocumentDropEdit(snippet) : undefined; + } + }); } export async function tryGetUriListSnippet(document: vscode.TextDocument, dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise { - const urlList = await dataTransfer.get('text/uri-list')?.asString(); - if (!urlList || token.isCancellationRequested) { - return undefined; - } - - const uris: vscode.Uri[] = []; - for (const resource of urlList.split('\n')) { - try { - uris.push(vscode.Uri.parse(resource)); - } catch { - // noop - } - } - - return createUriListSnippet(document, uris); + const urlList = await dataTransfer.get('text/uri-list')?.asString(); + if (!urlList || token.isCancellationRequested) { + return undefined; + } + + const uris: vscode.Uri[] = []; + for (const resource of urlList.split('\n')) { + try { + uris.push(vscode.Uri.parse(resource)); + } catch { + // noop + } + } + + return createUriListSnippet(document, uris); } interface UriListSnippetOptions { - readonly placeholderText?: string; + readonly placeholderText?: string; - readonly placeholderStartIndex?: number; + readonly placeholderStartIndex?: number; - /** - * Should the snippet be for an image? - * - * If `undefined`, tries to infer this from the uri. - */ - readonly insertAsImage?: boolean; + /** + * Should the snippet be for an image? + * + * If `undefined`, tries to infer this from the uri. + */ + readonly insertAsImage?: boolean; - readonly separator?: string; + readonly separator?: string; } export function createUriListSnippet(document: vscode.TextDocument, uris: readonly vscode.Uri[], options?: UriListSnippetOptions): vscode.SnippetString | undefined { - if (!uris.length) { - return undefined; - } + if (!uris.length) { + return undefined; + } - const dir = getDocumentDir(document); + const dir = getDocumentDir(document); - const snippet = new vscode.SnippetString(); - uris.forEach((uri, i) => { - const mdPath = getMdPath(dir, uri); + const snippet = new vscode.SnippetString(); + uris.forEach((uri, i) => { + const mdPath = getMdPath(dir, uri); - const ext = URI.Utils.extname(uri).toLowerCase().replace('.', ''); - const insertAsImage = typeof options?.insertAsImage === 'undefined' ? imageFileExtensions.has(ext) : !!options.insertAsImage; + const ext = URI.Utils.extname(uri).toLowerCase().replace('.', ''); + const insertAsImage = typeof options?.insertAsImage === 'undefined' ? imageFileExtensions.has(ext) : !!options.insertAsImage; - snippet.appendText(insertAsImage ? '![' : '['); + snippet.appendText(insertAsImage ? '![' : '['); - const placeholderText = options?.placeholderText ?? (insertAsImage ? 'Alt text' : 'label'); - const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : undefined; - snippet.appendPlaceholder(placeholderText, placeholderIndex); + const placeholderText = options?.placeholderText ?? (insertAsImage ? 'Alt text' : 'label'); + const placeholderIndex = typeof options?.placeholderStartIndex !== 'undefined' ? options?.placeholderStartIndex + i : undefined; + snippet.appendPlaceholder(placeholderText, placeholderIndex); - snippet.appendText(`](${mdPath})`); + snippet.appendText(`](${mdPath})`); - if (i < uris.length - 1 && uris.length > 1) { - snippet.appendText(options?.separator ?? ' '); - } - }); - return snippet; + if (i < uris.length - 1 && uris.length > 1) { + snippet.appendText(options?.separator ?? ' '); + } + }); + return snippet; } function getMdPath(dir: vscode.Uri | undefined, file: vscode.Uri) { - if (dir && dir.scheme === file.scheme && dir.authority === file.authority) { - if (file.scheme === Schemes.file) { - // On windows, we must use the native `path.relative` to generate the relative path - // so that drive-letters are resolved cast insensitively. However we then want to - // convert back to a posix path to insert in to the document. - const relativePath = path.relative(dir.fsPath, file.fsPath); - return encodeURI(path.posix.normalize(relativePath.split(path.sep).join(path.posix.sep))); - } - - return encodeURI(path.posix.relative(dir.path, file.path)); - } - - return file.toString(false); + if (dir && dir.scheme === file.scheme && dir.authority === file.authority) { + if (file.scheme === Schemes.file) { + // On windows, we must use the native `path.relative` to generate the relative path + // so that drive-letters are resolved cast insensitively. However we then want to + // convert back to a posix path to insert in to the document. + const relativePath = path.relative(dir.fsPath, file.fsPath); + return encodeURI(path.posix.normalize(relativePath.split(path.sep).join(path.posix.sep))); + } + + return encodeURI(path.posix.relative(dir.path, file.path)); + } + + return file.toString(false); } function getDocumentDir(document: vscode.TextDocument): vscode.Uri | undefined { - const docUri = getParentDocumentUri(document); - if (docUri.scheme === Schemes.untitled) { - return vscode.workspace.workspaceFolders?.[0]?.uri; - } - return URI.Utils.dirname(docUri); + const docUri = getParentDocumentUri(document); + if (docUri.scheme === Schemes.untitled) { + return vscode.workspace.workspaceFolders?.[0]?.uri; + } + return URI.Utils.dirname(docUri); } export function getParentDocumentUri(document: vscode.TextDocument): vscode.Uri { - if (document.uri.scheme === Schemes.notebookCell) { - for (const notebook of vscode.workspace.notebookDocuments) { - for (const cell of notebook.getCells()) { - if (cell.document === document) { - return notebook.uri; - } - } - } - } - - return document.uri; + if (document.uri.scheme === Schemes.notebookCell) { + for (const notebook of vscode.workspace.notebookDocuments) { + for (const cell of notebook.getCells()) { + if (cell.document === document) { + return notebook.uri; + } + } + } + } + + return document.uri; } diff --git a/apps/vscode/src/providers/copyfiles/filename.ts b/apps/vscode/src/providers/copyfiles/filename.ts index fc0a6e2a..ffdc9d38 100644 --- a/apps/vscode/src/providers/copyfiles/filename.ts +++ b/apps/vscode/src/providers/copyfiles/filename.ts @@ -22,110 +22,110 @@ import { getParentDocumentUri } from './drop'; import './types'; export async function getNewFileName(document: vscode.TextDocument, file: vscode.DataTransferFile): Promise { - const desiredPath = getDesiredNewFilePath(document, file); - - const root = Utils.dirname(desiredPath); - const ext = path.extname(file.name); - const baseName = path.basename(file.name, ext); - for (let i = 0; ; ++i) { - const name = i === 0 ? baseName : `${baseName}-${i}`; - const uri = vscode.Uri.joinPath(root, `${name}${ext}`); - try { - await vscode.workspace.fs.stat(uri); - } catch { - // Does not exist - return uri; - } - } + const desiredPath = getDesiredNewFilePath(document, file); + + const root = Utils.dirname(desiredPath); + const ext = path.extname(file.name); + const baseName = path.basename(file.name, ext); + for (let i = 0; ; ++i) { + const name = i === 0 ? baseName : `${baseName}-${i}`; + const uri = vscode.Uri.joinPath(root, `${name}${ext}`); + try { + await vscode.workspace.fs.stat(uri); + } catch { + // Does not exist + return uri; + } + } } function getDesiredNewFilePath(document: vscode.TextDocument, file: vscode.DataTransferFile): vscode.Uri { - const docUri = getParentDocumentUri(document); - const config = vscode.workspace.getConfiguration('markdown').get>('experimental.copyFiles.destination') ?? {}; - for (const [rawGlob, rawDest] of Object.entries(config)) { - for (const glob of parseGlob(rawGlob)) { - if (picomatch.isMatch(docUri.path, glob)) { - return resolveCopyDestination(docUri, file.name, rawDest, uri => vscode.workspace.getWorkspaceFolder(uri)?.uri); - } - } - } - - // Default to next to current file - return vscode.Uri.joinPath(Utils.dirname(docUri), file.name); + const docUri = getParentDocumentUri(document); + const config = vscode.workspace.getConfiguration('markdown').get>('experimental.copyFiles.destination') ?? {}; + for (const [rawGlob, rawDest] of Object.entries(config)) { + for (const glob of parseGlob(rawGlob)) { + if (picomatch.isMatch(docUri.path, glob)) { + return resolveCopyDestination(docUri, file.name, rawDest, uri => vscode.workspace.getWorkspaceFolder(uri)?.uri); + } + } + } + + // Default to next to current file + return vscode.Uri.joinPath(Utils.dirname(docUri), file.name); } function parseGlob(rawGlob: string): Iterable { - if (rawGlob.startsWith('/')) { - // Anchor to workspace folders - return (vscode.workspace.workspaceFolders ?? []).map(folder => vscode.Uri.joinPath(folder.uri, rawGlob).path); - } + if (rawGlob.startsWith('/')) { + // Anchor to workspace folders + return (vscode.workspace.workspaceFolders ?? []).map(folder => vscode.Uri.joinPath(folder.uri, rawGlob).path); + } - // Relative path, so implicitly track on ** to match everything - if (!rawGlob.startsWith('**')) { - return ['**/' + rawGlob]; - } + // Relative path, so implicitly track on ** to match everything + if (!rawGlob.startsWith('**')) { + return ['**/' + rawGlob]; + } - return [rawGlob]; + return [rawGlob]; } type GetWorkspaceFolder = (documentUri: vscode.Uri) => vscode.Uri | undefined; export function resolveCopyDestination(documentUri: vscode.Uri, fileName: string, dest: string, getWorkspaceFolder: GetWorkspaceFolder): vscode.Uri { - const resolvedDest = resolveCopyDestinationSetting(documentUri, fileName, dest, getWorkspaceFolder); + const resolvedDest = resolveCopyDestinationSetting(documentUri, fileName, dest, getWorkspaceFolder); - if (resolvedDest.startsWith('/')) { - // Absolute path - return Utils.resolvePath(documentUri, resolvedDest); - } + if (resolvedDest.startsWith('/')) { + // Absolute path + return Utils.resolvePath(documentUri, resolvedDest); + } - // Relative to document - const dirName = Utils.dirname(documentUri); - return Utils.resolvePath(dirName, resolvedDest); + // Relative to document + const dirName = Utils.dirname(documentUri); + return Utils.resolvePath(dirName, resolvedDest); } function resolveCopyDestinationSetting(documentUri: vscode.Uri, fileName: string, dest: string, getWorkspaceFolder: GetWorkspaceFolder): string { - let outDest = dest; + let outDest = dest; - // Destination that start with `/` implicitly means go to workspace root - if (outDest.startsWith('/')) { - outDest = '${documentWorkspaceFolder}/' + outDest.slice(1); - } + // Destination that start with `/` implicitly means go to workspace root + if (outDest.startsWith('/')) { + outDest = '${documentWorkspaceFolder}/' + outDest.slice(1); + } - // Destination that ends with `/` implicitly needs a fileName - if (outDest.endsWith('/')) { - outDest += '${fileName}'; - } + // Destination that ends with `/` implicitly needs a fileName + if (outDest.endsWith('/')) { + outDest += '${fileName}'; + } - const documentDirName = Utils.dirname(documentUri); - const documentBaseName = Utils.basename(documentUri); - const documentExtName = Utils.extname(documentUri); + const documentDirName = Utils.dirname(documentUri); + const documentBaseName = Utils.basename(documentUri); + const documentExtName = Utils.extname(documentUri); - const workspaceFolder = getWorkspaceFolder(documentUri); + const workspaceFolder = getWorkspaceFolder(documentUri); - const vars = new Map([ - ['documentDirName', documentDirName.path], // Parent directory path - ['documentFileName', documentBaseName], // Full filename: file.md - ['documentBaseName', documentBaseName.slice(0, documentBaseName.length - documentExtName.length)], // Just the name: file - ['documentExtName', documentExtName.replace('.', '')], // Just the file ext: md + const vars = new Map([ + ['documentDirName', documentDirName.path], // Parent directory path + ['documentFileName', documentBaseName], // Full filename: file.md + ['documentBaseName', documentBaseName.slice(0, documentBaseName.length - documentExtName.length)], // Just the name: file + ['documentExtName', documentExtName.replace('.', '')], // Just the file ext: md - // Workspace - ['documentWorkspaceFolder', (workspaceFolder ?? documentDirName).path], + // Workspace + ['documentWorkspaceFolder', (workspaceFolder ?? documentDirName).path], - // File - ['fileName', fileName],// Full file name - ]); + // File + ['fileName', fileName],// Full file name + ]); - return outDest.replaceAll(/\$\{(\w+)(?:\/([^\}]+?)\/([^\}]+?)\/)?\}/g, (_, name, pattern, replacement) => { - const entry = vars.get(name); - if (!entry) { - return ''; - } + return outDest.replaceAll(/\$\{(\w+)(?:\/([^\}]+?)\/([^\}]+?)\/)?\}/g, (_, name, pattern, replacement) => { + const entry = vars.get(name); + if (!entry) { + return ''; + } - if (pattern && replacement) { - return entry.replace(new RegExp(pattern), replacement); - } + if (pattern && replacement) { + return entry.replace(new RegExp(pattern), replacement); + } - return entry; - }); + return entry; + }); }