From ebf3f3fc6b470c74d292710cd173c311b8ff765c Mon Sep 17 00:00:00 2001 From: Svyat Sobol Date: Fri, 31 Jul 2020 16:11:33 +0300 Subject: [PATCH] Don't rename links within code spans and fenced code blocks --- .../BacklinksTreeDataProvider.spec.ts | 20 +++ src/extensions/fsWatcher.ts | 139 ++++++------------ src/utils/utils.spec.ts | 60 +++++++- src/utils/utils.ts | 62 +++++++- 4 files changed, 183 insertions(+), 98 deletions(-) diff --git a/src/extensions/BacklinksTreeDataProvider.spec.ts b/src/extensions/BacklinksTreeDataProvider.spec.ts index e11cfeb1..b3d12aa1 100644 --- a/src/extensions/BacklinksTreeDataProvider.spec.ts +++ b/src/extensions/BacklinksTreeDataProvider.spec.ts @@ -314,6 +314,26 @@ describe('BacklinksTreeDataProvider()', () => { expect(toPlainObject(await getChildren())).toHaveLength(0); }); + it('should not provide backlinks for link within code span 2', async () => { + const link = rndName(); + const name0 = rndName(); + + await createFile(`${link}.md`); + await createFile( + `a-${name0}.md`, + ` + Preceding text + \`[[${link}]]\` + Following text + `, + ); + + const doc = await openTextDocument(`${link}.md`); + await window.showTextDocument(doc); + + expect(toPlainObject(await getChildren())).toHaveLength(0); + }); + it('should not provide backlinks for link within fenced code block', async () => { const link = rndName(); const name0 = rndName(); diff --git a/src/extensions/fsWatcher.ts b/src/extensions/fsWatcher.ts index 03c1d1c7..1455a7e2 100644 --- a/src/extensions/fsWatcher.ts +++ b/src/extensions/fsWatcher.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { workspace, window, ExtensionContext } from 'vscode'; +import { workspace, window, Uri, ExtensionContext } from 'vscode'; import groupBy from 'lodash.groupby'; import { @@ -9,52 +9,19 @@ import { containsMarkdownExt, cacheWorkspace, getWorkspaceCache, - escapeForRegExp, + replaceRefs, sortPaths, findAllUrisWithUnknownExts, } from '../utils'; -// TODO: Extract to utils -const replaceRefs = ({ - refs, - content, - onMatch, - onReplace, -}: { - refs: { old: string; new: string }[]; - content: string; - onMatch?: () => void; - onReplace?: () => void; -}): string | null => { - const { updatedOnce, nextContent } = refs.reduce( - ({ updatedOnce, nextContent }, ref) => { - const pattern = `\\[\\[${escapeForRegExp(ref.old)}(\\|.*)?\\]\\]`; - - if (new RegExp(pattern, 'i').exec(content)) { - onMatch && onMatch(); - - const nextContent = content.replace(new RegExp(pattern, 'gi'), ($0, $1) => { - onReplace && onReplace(); - - return `[[${ref.new}${$1 || ''}]]`; - }); - - return { - updatedOnce: true, - nextContent, - }; - } +const getBasename = (pathParam: string) => path.basename(pathParam).toLowerCase(); - return { - updatedOnce: updatedOnce, - nextContent: nextContent, - }; - }, - { updatedOnce: false, nextContent: content }, - ); - - return updatedOnce ? nextContent : null; -}; +// Short ref allowed when non-unique filename comes first in the list of sorted uris. +// /a.md - <-- can be referenced via short ref as [[a]], since it comes first according to paths sorting +// /folder1/a.md - can be referenced only via long ref as [[folder1/a]] +// /folder2/subfolder1/a.md - can be referenced only via long ref as [[folder2/subfolder1/a]] +const isFirstUriInGroup = (pathParam: string, urisGroup: Uri[] = []) => + urisGroup.findIndex((uriParam) => uriParam.fsPath === pathParam) === 0; export const activate = (context: ExtensionContext) => { const fileWatcher = workspace.createFileSystemWatcher('**/*.{md,png,jpg,jpeg,svg,gif}'); @@ -73,7 +40,7 @@ export const activate = (context: ExtensionContext) => { const oldFsPaths = files.map(({ oldUri }) => oldUri.fsPath); - const oldUrisByPathBasename = groupBy( + const oldUrisGroupedByBasename = groupBy( sortPaths( [ ...getWorkspaceCache().allUris.filter((uri) => !oldFsPaths.includes(uri.fsPath)), @@ -96,7 +63,7 @@ export const activate = (context: ExtensionContext) => { }) : getWorkspaceCache().allUris; - const newUrisByPathBasename = groupBy(newUris, ({ fsPath }) => + const newUrisGroupedByBasename = groupBy(newUris, ({ fsPath }) => path.basename(fsPath).toLowerCase(), ); @@ -109,20 +76,7 @@ export const activate = (context: ExtensionContext) => { const incrementRefsCounter = () => (refsUpdated += 1); - const isShortRefAllowed = ( - pathParam: string, - urisByPathBasename: typeof newUrisByPathBasename, - ) => { - // Short ref allowed when non-unique filename comes first in the list of sorted uris. - // Notice that note name is not required to be unique across multiple folders but only within a single folder. - // /a.md - <-- can be referenced via short ref as [[a]], since it comes first according to paths sorting - // /folder1/a.md - can be referenced only via long ref as [[folder1/a]] - // /folder2/subfolder1/a.md - can be referenced only via long ref as [[folder2/subfolder1/a]] - const urisGroup = urisByPathBasename[path.basename(pathParam).toLowerCase()] || []; - return urisGroup.findIndex((uriParam) => uriParam.fsPath === pathParam) === 0; - }; - - files.forEach(({ oldUri, newUri }) => { + for (const { oldUri, newUri } of files) { const preserveOldExtension = !containsMarkdownExt(oldUri.fsPath); const preserveNewExtension = !containsMarkdownExt(newUri.fsPath); const workspaceFolder = getWorkspaceFolder()!; @@ -130,72 +84,69 @@ export const activate = (context: ExtensionContext) => { path: oldUri.fsPath, keepExt: preserveOldExtension, }); - const newShortRef = fsPathToRef({ - path: newUri.fsPath, - keepExt: preserveNewExtension, - }); const oldLongRef = fsPathToRef({ path: oldUri.fsPath, basePath: workspaceFolder, keepExt: preserveOldExtension, }); + const newShortRef = fsPathToRef({ + path: newUri.fsPath, + keepExt: preserveNewExtension, + }); const newLongRef = fsPathToRef({ path: newUri.fsPath, basePath: workspaceFolder, keepExt: preserveNewExtension, }); - const oldUriIsShortRef = isShortRefAllowed(oldUri.fsPath, oldUrisByPathBasename); - const newUriIsShortRef = isShortRefAllowed(newUri.fsPath, newUrisByPathBasename); + const oldUriIsShortRef = isFirstUriInGroup( + oldUri.fsPath, + oldUrisGroupedByBasename[getBasename(oldUri.fsPath)], + ); + const newUriIsShortRef = isFirstUriInGroup( + newUri.fsPath, + newUrisGroupedByBasename[getBasename(newUri.fsPath)], + ); if (!oldShortRef || !newShortRef || !oldLongRef || !newLongRef) { return; } - newUris.forEach(({ fsPath: p }) => { - const fileContent = fs.readFileSync(p).toString(); - let nextContent: string | null = null; + for (const { fsPath } of newUris) { + if (!containsMarkdownExt(fsPath)) { + continue; + } + + const doc = await workspace.openTextDocument(Uri.file(fsPath)); + let refs: { old: string; new: string }[] = []; if (!oldUriIsShortRef && !newUriIsShortRef) { // replace long ref with long ref // TODO: Consider finding previous short ref and make it pointing to the long ref - nextContent = replaceRefs({ - refs: [{ old: oldLongRef, new: newLongRef }], - content: fileContent, - onMatch: () => addToPathsUpdated(p), - onReplace: incrementRefsCounter, - }); + refs = [{ old: oldLongRef, new: newLongRef }]; } else if (!oldUriIsShortRef && newUriIsShortRef) { // replace long ref with short ref - nextContent = replaceRefs({ - refs: [{ old: oldLongRef, new: newShortRef }], - content: fileContent, - onMatch: () => addToPathsUpdated(p), - onReplace: incrementRefsCounter, - }); + refs = [{ old: oldLongRef, new: newShortRef }]; } else if (oldUriIsShortRef && !newUriIsShortRef) { // replace short ref with long ref // TODO: Consider finding new short ref and making long refs pointing to the new short ref - nextContent = replaceRefs({ - refs: [{ old: oldShortRef, new: newLongRef }], - content: fileContent, - onMatch: () => addToPathsUpdated(p), - onReplace: incrementRefsCounter, - }); + refs = [{ old: oldShortRef, new: newLongRef }]; } else { // replace short ref with short ref - nextContent = replaceRefs({ - refs: [{ old: oldShortRef, new: newShortRef }], - content: fileContent, - onMatch: () => addToPathsUpdated(p), - onReplace: incrementRefsCounter, - }); + refs = [{ old: oldShortRef, new: newShortRef }]; } + const nextContent = replaceRefs({ + refs, + document: doc, + onMatch: () => addToPathsUpdated(fsPath), + onReplace: incrementRefsCounter, + }); + if (nextContent !== null) { - fs.writeFileSync(p, nextContent); + fs.writeFileSync(fsPath, nextContent); } - }); - }); + } + } if (pathsUpdated.length > 0) { window.showInformationMessage( diff --git a/src/utils/utils.spec.ts b/src/utils/utils.spec.ts index 19c4a7a0..955e7e02 100644 --- a/src/utils/utils.spec.ts +++ b/src/utils/utils.spec.ts @@ -1,4 +1,4 @@ -import { window, Selection } from 'vscode'; +import { window, Selection, workspace } from 'vscode'; import path from 'path'; import { @@ -18,6 +18,7 @@ import { cleanWorkspaceCache, getRefUriUnderCursor, parseRef, + replaceRefs, } from './utils'; describe('containsImageExt()', () => { @@ -253,3 +254,60 @@ describe('parseRef()', () => { expect(parseRef('link|||Label')).toEqual({ ref: 'link', label: '||Label' }); }); }); + +describe('replaceRefs()', () => { + it('should not replace ref within code span', async () => { + const doc = await workspace.openTextDocument({ + language: 'markdown', + content: '`[[test-ref]]`', + }); + + expect( + replaceRefs({ + refs: [{ old: 'test-ref', new: 'new-test-ref' }], + document: doc, + }), + ).toBe('`[[test-ref]]`'); + }); + + it('should not replace ref within code span 2', async () => { + const content = ` + Preceding text + \`[[test-ref]]\` + Following text + `; + const doc = await workspace.openTextDocument({ + language: 'markdown', + content: content, + }); + + expect( + replaceRefs({ + refs: [{ old: 'test-ref', new: 'new-test-ref' }], + document: doc, + }), + ).toBe(content); + }); + + it('should not replace ref within fenced code block', async () => { + const initialContent = ` + \`\`\` + Preceding text + [[test-ref]] + Following text + \`\`\` + `; + + const doc = await workspace.openTextDocument({ + language: 'markdown', + content: initialContent, + }); + + expect( + replaceRefs({ + refs: [{ old: 'test-ref', new: 'new-test-ref' }], + document: doc, + }), + ).toBe(initialContent); + }); +}); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 54e80c8a..4802bc49 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -95,8 +95,6 @@ export const cleanWorkspaceCache = () => { export const getWorkspaceFolder = () => vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0].uri.fsPath; -export const getDateInYYYYMMDDFormat = () => new Date().toISOString().slice(0, 10); - export function getConfigProperty(property: string, fallback: T): T { return vscode.workspace.getConfiguration().get(`memo.${property}`, fallback); } @@ -183,7 +181,7 @@ export const findReferences = async ( if ( isInFencedCodeBlock(currentDocument, lineStart.lineNumber) || - isInCodeSpan(currentDocument, lineStart.lineNumber, offset) + isInCodeSpan(currentDocument, lineStart.lineNumber, refStart.character) ) { return; } @@ -280,3 +278,61 @@ export const parseRef = (rawRef: string): RefT => { label: dividerPosition !== -1 ? rawRef.slice(dividerPosition + 1, rawRef.length) : '', }; }; + +export const replaceRefs = ({ + refs, + document, + onMatch, + onReplace, +}: { + refs: { old: string; new: string }[]; + document: vscode.TextDocument; + onMatch?: () => void; + onReplace?: () => void; +}): string | null => { + const content = document.getText(); + + const { updatedOnce, nextContent } = refs.reduce( + ({ updatedOnce, nextContent }, ref) => { + const pattern = `\\[\\[${escapeForRegExp(ref.old)}(\\|.*)?\\]\\]`; + + if (new RegExp(pattern, 'i').exec(content)) { + let replacedOnce = false; + + const nextContent = content.replace(new RegExp(pattern, 'gi'), ($0, $1, offset) => { + const pos = document.positionAt(offset); + + if ( + isInFencedCodeBlock(document, pos.line) || + isInCodeSpan(document, pos.line, pos.character) + ) { + return $0; + } + + if (!replacedOnce) { + onMatch && onMatch(); + } + + onReplace && onReplace(); + + replacedOnce = true; + + return `[[${ref.new}${$1 || ''}]]`; + }); + + return { + updatedOnce: true, + nextContent, + }; + } + + return { + updatedOnce: updatedOnce, + nextContent: nextContent, + }; + }, + { updatedOnce: false, nextContent: content }, + ); + + return updatedOnce ? nextContent : null; +};