Skip to content

Commit

Permalink
Merge pull request #172 from mjbvz/dev/mjbvz/update-pasted-links
Browse files Browse the repository at this point in the history
Add updated pasted links
  • Loading branch information
mjbvz committed Apr 2, 2024
2 parents a387b02 + 214f5f3 commit 4f5b2a5
Show file tree
Hide file tree
Showing 8 changed files with 625 additions and 20 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 0.5.0-alpha.3 — April 1, 2024
- Add experimental support for update links in text copied across Markdown files.

## 0.5.0-alpha.2 — March 28, 2024
- Fix renaming for cases where headers are duplicated.
- Give slugifiers control over how duplicate header ids are generated instead of hardcoding.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vscode-markdown-languageservice",
"description": "Markdown language service",
"version": "0.5.0-alpha.2",
"version": "0.5.0-alpha.3",
"author": "Microsoft Corporation",
"license": "MIT",
"engines": {
Expand Down
32 changes: 31 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { MdOrganizeLinkDefinitionProvider } from './languageFeatures/organizeLin
import { MdPathCompletionProvider, PathCompletionOptions } from './languageFeatures/pathCompletions';
import { MdReferencesProvider } from './languageFeatures/references';
import { MdRenameProvider } from './languageFeatures/rename';
import { MdUpdatePastedLinksProvider } from './languageFeatures/updatePastedLinks';
import { MdSelectionRangeProvider } from './languageFeatures/smartSelect';
import { MdWorkspaceSymbolProvider } from './languageFeatures/workspaceSymbols';
import { ILogger } from './logging';
Expand All @@ -35,7 +36,7 @@ export { IncludeWorkspaceHeaderCompletions, PathCompletionOptions as MdPathCompl
export { RenameNotSupportedAtLocationError } from './languageFeatures/rename';
export { ILogger, LogLevel } from './logging';
export { IMdParser, Token } from './parser';
export { githubSlugifier, ISlugifier, ISlug } from './slugify';
export { githubSlugifier, ISlug, ISlugifier } from './slugify';
export { ITextDocument } from './types/textDocument';
export { ContainingDocumentContext, FileStat, FileWatcherOptions, IFileSystemWatcher, IWorkspace, IWorkspaceWithWatching } from './workspace';

Expand Down Expand Up @@ -170,6 +171,32 @@ export interface IMdLanguageService {
*/
getDocumentHighlights(document: ITextDocument, position: lsp.Position, token: lsp.CancellationToken): Promise<lsp.DocumentHighlight[]>;

/**
* Called on copy to support update links when they are pasted.
*
* @param document The document being copied from.
* @param ranges The ranges being copied.
* @param token A cancellation token.
*
* @returns A json string with internal metadata. This metadata is passed to {@linkcode getUpdatePastedLinksEdit}.
* It has no meaning on its own.
*/
prepareUpdatePastedLinks(document: ITextDocument, ranges: readonly lsp.Range[], token: lsp.CancellationToken): Promise<string>;

/**
* Get the edits that update links when copy pasting from one Markdown document to another.
*
* Must be used with {@linkcode prepareUpdatePastedLinks}.
*
* @param document The document being pasted into.
* @param paste Edits that apply the paste.
* @param rawCopyMetadata String blob generated by {@linkcode prepareUpdatePastedLinks}.
* @param token A cancellation token.
*
* @returns A set of edits that applies the full paste if any links were rewritten. Returns undefined if no links were rewritten.
*/
getUpdatePastedLinksEdit(document: ITextDocument, paste: readonly lsp.TextEdit[], rawCopyMetadata: string, token: lsp.CancellationToken): Promise<lsp.TextEdit[] | undefined>;

/**
* Compute diagnostics for a given file.
*
Expand Down Expand Up @@ -237,6 +264,7 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL
const workspaceSymbolProvider = new MdWorkspaceSymbolProvider(init.workspace, docSymbolProvider);
const organizeLinkDefinitions = new MdOrganizeLinkDefinitionProvider(linkProvider);
const documentHighlightProvider = new MdDocumentHighlightProvider(config, tocProvider, linkProvider);
const rewritePastedLinksProvider = new MdUpdatePastedLinksProvider(init.parser, init.workspace);

const extractCodeActionProvider = new MdExtractLinkDefinitionCodeActionProvider(linkProvider);
const removeLinkDefinitionActionProvider = new MdRemoveLinkDefinitionCodeActionProvider();
Expand Down Expand Up @@ -275,6 +303,8 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL
getDocumentHighlights: (document: ITextDocument, position: lsp.Position, token: lsp.CancellationToken): Promise<lsp.DocumentHighlight[]> => {
return documentHighlightProvider.getDocumentHighlights(document, position, token);
},
prepareUpdatePastedLinks: rewritePastedLinksProvider.prepareDocumentPaste.bind(rewritePastedLinksProvider),
getUpdatePastedLinksEdit: rewritePastedLinksProvider.provideDocumentPasteEdits.bind(rewritePastedLinksProvider),
computeDiagnostics: async (doc: ITextDocument, options: DiagnosticOptions, token: lsp.CancellationToken): Promise<lsp.Diagnostic[]> => {
return (await diagnosticsComputer.compute(doc, options, token))?.diagnostics;
},
Expand Down
42 changes: 24 additions & 18 deletions src/languageFeatures/codeActions/extractLinkDef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,10 @@ export class MdExtractLinkDefinitionCodeActionProvider {
}
}

// And append new definition to link definition block
const definitionText = this.#getLinkTargetText(doc, targetLink).trim();
const definitionText = getLinkTargetText(doc, targetLink).trim();
const definitions = linkInfo.links.filter(link => link.kind === MdLinkKind.Definition) as MdLinkDefinition[];
const defBlock = getExistingDefinitionBlock(doc, definitions);
if (!defBlock) {
builder.insert(resource, { line: doc.lineCount, character: 0 }, `\n\n[${placeholder}]: ${definitionText}`);
} else {
const line = getLine(doc, defBlock.endLine);
builder.insert(resource, { line: defBlock.endLine, character: line.length }, `\n[${placeholder}]: ${definitionText}`);
}
const defEdit = createAddDefinitionEdit(doc, definitions, [{ definitionText, placeholder }]);
builder.insert(resource, defEdit.range.start, defEdit.newText);

const renamePosition = translatePosition(targetLink.source.targetRange.start, { characterDelta: 1 });
return {
Expand All @@ -116,15 +110,6 @@ export class MdExtractLinkDefinitionCodeActionProvider {
};
}

#getLinkTargetText(doc: ITextDocument, link: MdInlineLink | MdAutoLink) {
const afterHrefRange = link.kind === MdLinkKind.AutoLink
? link.source.targetRange
: makeRange(
translatePosition(link.source.targetRange.start, { characterDelta: 1 }),
translatePosition(link.source.targetRange.end, { characterDelta: -1 }));
return doc.getText(afterHrefRange);
}

#getPlaceholder(definitions: LinkDefinitionSet): string {
const base = 'def';
for (let i = 1; ; ++i) {
Expand All @@ -147,3 +132,24 @@ export class MdExtractLinkDefinitionCodeActionProvider {
return false;
}
}

export function createAddDefinitionEdit(doc: ITextDocument, existingDefinitions: readonly MdLinkDefinition[], newDefs: ReadonlyArray<{ definitionText: string, placeholder: string }>): lsp.TextEdit {
const defBlock = getExistingDefinitionBlock(doc, existingDefinitions);
const newDefText = newDefs.map(({ definitionText, placeholder }) => `[${placeholder}]: ${definitionText}`).join('\n');

if (!defBlock) {
return lsp.TextEdit.insert({ line: doc.lineCount, character: 0 }, '\n\n' + newDefText);
} else {
const line = getLine(doc, defBlock.endLine);
return lsp.TextEdit.insert({ line: defBlock.endLine, character: line.length }, '\n' + newDefText);
}
}

function getLinkTargetText(doc: ITextDocument, link: MdInlineLink | MdAutoLink) {
const afterHrefRange = link.kind === MdLinkKind.AutoLink
? link.source.targetRange
: makeRange(
translatePosition(link.source.targetRange.start, { characterDelta: 1 }),
translatePosition(link.source.targetRange.end, { characterDelta: -1 }));
return doc.getText(afterHrefRange);
}
189 changes: 189 additions & 0 deletions src/languageFeatures/updatePastedLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as lsp from 'vscode-languageserver-protocol';
import { URI } from 'vscode-uri';
import { IMdParser } from '../parser';
import { InMemoryDocument } from '../types/inMemoryDocument';
import { rangeContains } from '../types/range';
import { getDocUri, ITextDocument } from '../types/textDocument';
import { computeRelativePath } from '../util/path';
import { IWorkspace } from '../workspace';
import { createAddDefinitionEdit } from './codeActions/extractLinkDef';
import { HrefKind, LinkDefinitionSet, MdLinkComputer, MdLinkDefinition } from './documentLinks';

class PasteLinksCopyMetadata {

static fromJSON(json: string): PasteLinksCopyMetadata {
const obj = JSON.parse(json);
return new PasteLinksCopyMetadata(URI.parse(obj.source), new LinkDefinitionSet(obj.links));
}

constructor(
readonly source: URI,
readonly links: LinkDefinitionSet | undefined,
) { }

toJSON(): string {
return JSON.stringify({
source: this.source.toString(),
links: this.links ? Array.from(this.links) : undefined,
});
}
}

export class MdUpdatePastedLinksProvider {

readonly #linkComputer: MdLinkComputer;

constructor(
tokenizer: IMdParser,
workspace: IWorkspace,
) {
this.#linkComputer = new MdLinkComputer(tokenizer, workspace);
}

async prepareDocumentPaste(document: ITextDocument, _ranges: readonly lsp.Range[], token: lsp.CancellationToken): Promise<string> {
const links = await this.#linkComputer.getAllLinks(document, token);
if (token.isCancellationRequested) {
return '';
}

const metadata = new PasteLinksCopyMetadata(getDocUri(document), new LinkDefinitionSet(links));
return metadata.toJSON();
}

async provideDocumentPasteEdits(
targetDocument: ITextDocument,
pastes: readonly lsp.TextEdit[],
rawCopyMetadata: string,
token: lsp.CancellationToken,
): Promise<lsp.TextEdit[] | undefined> {
const metadata = this.#parseMetadata(rawCopyMetadata);
if (!metadata) {
return;
}

// If pasting into same doc copied from, there's no need to rewrite anything
if (getDocUri(targetDocument).toString() === metadata.toString()) {
return;
}

// Bail early if there's nothing that looks like it could be a link in the pasted text
if (!pastes.some(p => p.newText.includes(']') || p.newText.includes('<'))) {
return undefined;
}

const sortedPastes = Array.from(pastes).sort((a, b) => targetDocument.offsetAt(a.range.start) - targetDocument.offsetAt(b.range.start));

// Find the links in the pasted text by applying the paste edits to an in-memory document.
// Use `copySource` as the doc uri to make sure links are resolved in its context
const editedDoc = new InMemoryDocument(metadata.source, targetDocument.getText());
editedDoc.updateContent(editedDoc.applyEdits(sortedPastes));

const allLinks = await this.#linkComputer.getAllLinks(editedDoc, token);
if (token.isCancellationRequested) {
return;
}

const pastedRanges = this.#computedPastedRanges(sortedPastes, targetDocument, editedDoc);

const currentDefinitionSet = new LinkDefinitionSet(allLinks);
const linksToRewrite = allLinks
// We only rewrite relative links and references
.filter(link => {
if (link.href.kind === HrefKind.Reference) {
return true;
}
return link.href.kind === HrefKind.Internal
&& !link.source.hrefText.startsWith('/') // No need to rewrite absolute paths
&& link.href.path.scheme === metadata.source.scheme && link.href.path.authority === metadata.source.authority; // Only rewrite links that are in the same workspace
})
// And the link be newly added (i.e. in one of the pasted ranges)
.filter(link => pastedRanges.some(range => rangeContains(range, link.source.range)));

// Generate edits
const newDefinitionsToAdd: MdLinkDefinition[] = [];
const rewriteLinksEdits: lsp.TextEdit[] = [];
for (const link of linksToRewrite) {
if (link.href.kind === HrefKind.Reference) {
// See if we've already added the def
if (new LinkDefinitionSet(newDefinitionsToAdd).lookup(link.href.ref)) {
continue;
}

const originalRef = metadata.links?.lookup(link.href.ref);
if (!originalRef) {
continue;
}

// If there's an existing definition with the same exact ref, we don't need to add it again
if (currentDefinitionSet.lookup(link.href.ref)?.source.hrefText === originalRef.source.hrefText) {
continue;
}

newDefinitionsToAdd.push(originalRef);

} else if (link.href.kind === HrefKind.Internal) {
const newPathText = computeRelativePath(getDocUri(targetDocument), link.href.path);
if (!newPathText) {
continue;
}

let newHrefText = newPathText;
if (link.source.fragmentRange) {
newHrefText += '#' + link.href.fragment;
}

if (link.source.hrefText !== newHrefText) {
rewriteLinksEdits.push(lsp.TextEdit.replace(link.source.hrefRange, newHrefText));
}
}
}

// Plus add an edit that inserts new definitions
if (newDefinitionsToAdd.length) {
rewriteLinksEdits.push(createAddDefinitionEdit(editedDoc, [...currentDefinitionSet], newDefinitionsToAdd.map(def => ({ placeholder: def.ref.text, definitionText: def.source.hrefText }))));
}

// If nothing was rewritten we can just use normal text paste.
if (!rewriteLinksEdits.length) {
return;
}

// Generate the final edits by grabbing text from the edited document
const finalDoc = new InMemoryDocument(editedDoc.$uri, editedDoc.applyEdits(rewriteLinksEdits));

// TODO: generate more minimal edit
return [
lsp.TextEdit.replace(lsp.Range.create(0, 0, 100_000, 0), finalDoc.getText()),
];
}

#parseMetadata(rawCopyMetadata: string): PasteLinksCopyMetadata | undefined {
try {
return PasteLinksCopyMetadata.fromJSON(rawCopyMetadata);
} catch {
return undefined;
}
}

#computedPastedRanges(sortedPastes: lsp.TextEdit[], targetDocument: ITextDocument, editedDoc: InMemoryDocument) {
const pastedRanges: lsp.Range[] = [];

let offsetAdjustment = 0;
for (const paste of sortedPastes) {
const originalStartOffset = targetDocument.offsetAt(paste.range.start);
const originalEndOffset = targetDocument.offsetAt(paste.range.end);

pastedRanges.push(lsp.Range.create(
editedDoc.positionAt(originalStartOffset + offsetAdjustment),
editedDoc.positionAt(originalStartOffset + offsetAdjustment + paste.newText.length)));

offsetAdjustment += paste.newText.length - (originalEndOffset - originalStartOffset);
}

return pastedRanges;
}
}
Loading

0 comments on commit 4f5b2a5

Please sign in to comment.