From 116b67625b9ff47806ffe5735a373044cfe757c4 Mon Sep 17 00:00:00 2001 From: Guillaume Grossetie Date: Tue, 11 Jul 2023 20:45:18 +0200 Subject: [PATCH] Navigate to anchor using xref (#744) * Navigate to anchor using xref * Add tests --- src/features/documentLinkProvider.ts | 68 ++++++++++++++++++++++++++- src/test/documentLinkProvider.test.ts | 58 +++++++++++++++++++---- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/features/documentLinkProvider.ts b/src/features/documentLinkProvider.ts index f36432c3..48b3f704 100644 --- a/src/features/documentLinkProvider.ts +++ b/src/features/documentLinkProvider.ts @@ -11,7 +11,9 @@ import { AsciidocParser } from '../asciidocParser' * Reference: https://gist.github.com/dperini/729294 */ // eslint-disable-next-line max-len -const urlRx = /(?:(?:https?|ftp|irc):)?\/\/(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4])|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+[a-z\u00a1-\uffff]{2,}\.?)(?::\d{2,5})?(?:[/?#]\S*)?/ig +const urlRx = /(?:(?:https?|ftp|irc):)?\/\/(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4])|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+[a-z\u00a1-\uffff]{2,}\.?)(?::\d{2,5})?(?:[/?#][^[]*)?/ig +const inlineAnchorRx = /^\[\[(?[^,]+)(?:,[^\]]+)*]]$/m +const xrefRx = /xref:(?[^#|^[]+)(?#[^[]+)?\[[^\]]*]/ig const localize = nls.loadMessageBundle() function normalizeLink ( @@ -49,6 +51,9 @@ export default class LinkProvider implements vscode.DocumentLinkProvider { const baseDocumentRegexIncludes = new Map() const results: vscode.DocumentLink[] = [] const lines = textDocument.getText().split('\n') + const anchors = {} + const xrefProxies = [] + const base = textDocument.uri.path.substring(0, textDocument.uri.path.lastIndexOf('/')) for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { const line = lines[lineNumber] const match = includeDirective.exec(line) @@ -74,6 +79,66 @@ export default class LinkProvider implements vscode.DocumentLinkProvider { } } } + if (line.startsWith('[[') && line.endsWith(']]')) { + const inlineAnchorFound = line.match(inlineAnchorRx) + if (inlineAnchorFound) { + const inlineAnchorId = inlineAnchorFound.groups.id + anchors[`${textDocument.uri.path}#${inlineAnchorId}`] = { + lineNumber: lineNumber + 1, + } + } + } + if (line.includes('xref:')) { + const xrefsFound = line.matchAll(xrefRx) + if (xrefsFound) { + for (const xrefFound of xrefsFound) { + const index = xrefFound.index + const target = xrefFound.groups.target + let fragment = xrefFound.groups.fragment || '' + const originalTarget = `${target}${fragment}` + let targetUri + if (path.isAbsolute(target)) { + targetUri = vscode.Uri.parse(target) + } else { + targetUri = vscode.Uri.parse(base + '/' + target) + } + if (targetUri.path === textDocument.uri.path) { + xrefProxies.push((anchors) => { + const anchorFound = anchors[`${targetUri.path}${fragment}`] + if (anchorFound) { + fragment = `#L${anchorFound.lineNumber}` + } + const documentLink = new vscode.DocumentLink( + new vscode.Range( + // exclude xref: prefix + new vscode.Position(lineNumber, index + 5), + new vscode.Position(lineNumber, originalTarget.length + index + 5) + ), + normalizeLink(textDocument, `${target}${fragment}`, base) + ) + documentLink.tooltip = localize('documentLink.openFile.tooltip', 'Open file {0}', target) + return documentLink + }) + } else { + const documentLink = new vscode.DocumentLink( + new vscode.Range( + new vscode.Position(lineNumber, index + 5), + new vscode.Position(lineNumber, originalTarget.length + index + 5) + ), + normalizeLink(textDocument, `${target}${fragment}`, base) + ) + documentLink.tooltip = localize('documentLink.openFile.tooltip', 'Open file {0}', target) + results.push(documentLink) + } + } + } + } + } + + if (xrefProxies && xrefProxies.length > 0) { + for (const xrefProxy of xrefProxies) { + results.push(xrefProxy(anchors)) + } } // find a corrected mapping for line numbers @@ -94,7 +159,6 @@ export default class LinkProvider implements vscode.DocumentLinkProvider { // create include links if (baseDocumentProcessorIncludes) { - const base = path.dirname(textDocument.uri.fsPath) baseDocumentProcessorIncludes.forEach((entry) => { const lineNo = entry.position - 1 const documentLink = new vscode.DocumentLink( diff --git a/src/test/documentLinkProvider.test.ts b/src/test/documentLinkProvider.test.ts index 55bf9045..8e76b2af 100644 --- a/src/test/documentLinkProvider.test.ts +++ b/src/test/documentLinkProvider.test.ts @@ -1,15 +1,9 @@ -/*--------------------------------------------------------------------------------------------- - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - import * as assert from 'assert' import 'mocha' import * as vscode from 'vscode' import LinkProvider from '../features/documentLinkProvider' import { InMemoryDocument } from './inMemoryDocument' -const testFileName = vscode.Uri.file('test.md') - const noopToken = new class implements vscode.CancellationToken { private _onCancellationRequestedEmitter = new vscode.EventEmitter() public onCancellationRequested = this._onCancellationRequestedEmitter.event @@ -17,8 +11,8 @@ const noopToken = new class implements vscode.CancellationToken { get isCancellationRequested () { return false } }() -async function getLinksForFile (fileContents: string) { - const doc = new InMemoryDocument(testFileName, fileContents) +async function getLinksForFile (fileContents: string, testFileName?: vscode.Uri) { + const doc = new InMemoryDocument(testFileName || vscode.Uri.file('test.adoc'), fileContents) const provider = new LinkProvider() return provider.provideDocumentLinks(doc, noopToken) } @@ -82,4 +76,52 @@ b assertRangeEqual(link.range, new vscode.Range(4, 9, 4, 16)) } }) + + test('Should detect inline anchor using [[idname]] syntax and xref', async () => { + const links = await getLinksForFile(`= Title + +[[first-section]] +== Section Title + +Paragraph. + +== Second Section Title + +See xref:test.adoc#first-section[] +`) + assert.strictEqual(links.length, 1) + const [link] = links + + assert.strictEqual(link.target.scheme, 'command') + assert.deepStrictEqual(link.target.path, '_asciidoc.openDocumentLink') + assert.strictEqual(link.target.query, JSON.stringify({ + path: 'test.adoc', + fragment: 'L3', + })) + assertRangeEqual(link.range, new vscode.Range(9, 9, 9, 32)) + }) + + test('Should detect xref and inline anchor using [[idname]] syntax', async () => { + const links = await getLinksForFile(`= Title + +[[first-section]] +== Section Title + +Paragraph. +See xref:test.adoc#second-section[] + +[[second-section]] +== Second Section Title + +`) + assert.strictEqual(links.length, 1) + const [link] = links + assert.strictEqual(link.target.scheme, 'command') + assert.deepStrictEqual(link.target.path, '_asciidoc.openDocumentLink') + assert.strictEqual(link.target.query, JSON.stringify({ + path: 'test.adoc', + fragment: 'L9', + })) + assertRangeEqual(link.range, new vscode.Range(6, 9, 6, 33)) + }) })