Skip to content

Commit

Permalink
Don't rename links within code spans and fenced code blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
svsool committed Jul 31, 2020
1 parent d644dbf commit ebf3f3f
Show file tree
Hide file tree
Showing 4 changed files with 183 additions and 98 deletions.
20 changes: 20 additions & 0 deletions src/extensions/BacklinksTreeDataProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
139 changes: 45 additions & 94 deletions src/extensions/fsWatcher.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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}');
Expand All @@ -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)),
Expand All @@ -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(),
);

Expand All @@ -109,93 +76,77 @@ 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()!;
const oldShortRef = fsPathToRef({
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(
Expand Down
60 changes: 59 additions & 1 deletion src/utils/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { window, Selection } from 'vscode';
import { window, Selection, workspace } from 'vscode';
import path from 'path';

import {
Expand All @@ -18,6 +18,7 @@ import {
cleanWorkspaceCache,
getRefUriUnderCursor,
parseRef,
replaceRefs,
} from './utils';

describe('containsImageExt()', () => {
Expand Down Expand Up @@ -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);
});
});
62 changes: 59 additions & 3 deletions src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(property: string, fallback: T): T {
return vscode.workspace.getConfiguration().get(`memo.${property}`, fallback);
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
};

0 comments on commit ebf3f3f

Please sign in to comment.