import vscode, { CancellationToken, GlobPattern, Uri, workspace } from 'vscode'; import path from 'path'; import fs from 'fs'; import { RefT, FoundRefT, LinkRuleT, ExtractedRefT } from '../types'; import { cache } from '../workspace'; import { isInCodeSpan, isInFencedCodeBlock } from './externalUtils'; const markdownExtRegex = /\.md$/i; export const imageExts = ['png', 'jpg', 'jpeg', 'svg', 'gif', 'webp']; const imageExtsRegex = new RegExp(`.(${imageExts.join('|')})$`, 'i'); export const otherExts = [ 'doc', 'docx', 'rtf', 'txt', 'odt', 'xls', 'xlsx', 'ppt', 'pptm', 'pptx', 'pdf', 'pages', 'mp4', 'mov', 'wmv', 'flv', 'avi', 'mkv', 'mp3', 'webm', 'wav', 'm4a', 'ogg', '3gp', 'flac', ]; const otherExtsRegex = new RegExp(`.(${otherExts.join('|')})$`, 'i'); // Remember to edit accordingly when extensions above edited export const commonExtsHint = '.md,.png,.jpg,.jpeg,.svg,.gif,.doc,.docx,.rtf,.txt,.odt,.xls,.xlsx,.ppt,.pptm,.pptx,.pdf'; export const refPattern = '(\\[\\[)([^\\[\\]]+?)(\\]\\])'; export const containsImageExt = (pathParam: string): boolean => !!imageExtsRegex.exec(path.parse(pathParam).ext); export const containsMarkdownExt = (pathParam: string): boolean => !!markdownExtRegex.exec(path.parse(pathParam).ext); export const containsOtherKnownExts = (pathParam: string): boolean => !!otherExtsRegex.exec(path.parse(pathParam).ext); export const containsUnknownExt = (pathParam: string): boolean => path.parse(pathParam).ext !== '' && !containsMarkdownExt(pathParam) && !containsImageExt(pathParam) && !containsOtherKnownExts(pathParam); export const trimLeadingSlash = (value: string) => value.replace(/^\/+|^\\+/g, ''); export const trimTrailingSlash = (value: string) => value.replace(/\/+$|\\+$/g, ''); export const trimSlashes = (value: string) => trimLeadingSlash(trimTrailingSlash(value)); export const isLongRef = (path: string) => path.split('/').length > 1; export const normalizeSlashes = (value: string) => value.replace(/\\/gi, '/'); export const fsPathToRef = ({ path: fsPath, keepExt, basePath, }: { path: string; keepExt?: boolean; basePath?: string; }): string | null => { const ref = basePath && fsPath.startsWith(basePath) ? normalizeSlashes(fsPath.replace(basePath, '')) : path.basename(fsPath); if (keepExt) { return trimLeadingSlash(ref); } return trimLeadingSlash(ref.includes('.') ? ref.slice(0, ref.lastIndexOf('.')) : ref); }; const refRegexp = new RegExp(refPattern, 'gi'); export const extractDanglingRefs = (content: string) => { const refs: string[] = []; content.split(/\r?\n/g).forEach((lineText, lineNum) => { for (const match of matchAll(refRegexp, lineText)) { const [, , reference] = match; if (reference) { const offset = (match.index || 0) + 2; if (isInFencedCodeBlock(content, lineNum) || isInCodeSpan(content, lineNum, offset)) { continue; } const { ref } = parseRef(reference); if (!findUriByRef(cache.getWorkspaceCache().allUris, ref)) { refs.push(ref); } } } }); return Array.from(new Set(refs)); }; export const findDanglingRefsByFsPath = async (uris: vscode.Uri[]) => { const refsByFsPath: { [key: string]: string[] } = {}; for (const { fsPath } of uris) { const fsPathExists = fs.existsSync(fsPath); if ( !fsPathExists || !containsMarkdownExt(fsPath) || (fsPathExists && fs.lstatSync(fsPath).isDirectory()) ) { continue; } const doc = workspace.textDocuments.find((doc) => doc.uri.fsPath === fsPath); const refs = extractDanglingRefs(doc ? doc.getText() : fs.readFileSync(fsPath).toString()); if (refs.length) { refsByFsPath[fsPath] = refs; } } return refsByFsPath; }; export const getWorkspaceFolder = (): string | undefined => vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0].uri.fsPath; export function getConfigProperty<T>(property: string, fallback: T): T { return vscode.workspace.getConfiguration().get(property, fallback); } export type MemoBoolConfigProp = | 'links.completion.enabled' | 'links.following.enabled' | 'links.preview.enabled' | 'links.references.enabled' | 'links.sync.enabled' | 'backlinksPanel.enabled' | 'markdownPreview.enabled'; export function getMemoConfigProperty( property: 'links.format', fallback: 'short', ): 'short' | 'long'; export function getMemoConfigProperty( property: 'backlinksPanel.collapseParentItems', fallback: null | boolean, ): boolean; export function getMemoConfigProperty( property: 'links.preview.imageMaxHeight', fallback: null | number, ): number; export function getMemoConfigProperty( property: 'links.rules', fallback: null | Array<LinkRuleT>, ): LinkRuleT[]; export function getMemoConfigProperty(property: MemoBoolConfigProp, fallback: boolean): boolean; export function getMemoConfigProperty<T>(property: string, fallback: T): T { return getConfigProperty(`memo.${property}`, fallback); } export const matchAll = (pattern: RegExp, text: string): Array<RegExpMatchArray> => { let match: RegExpMatchArray | null; const out: RegExpMatchArray[] = []; pattern.lastIndex = 0; while ((match = pattern.exec(text))) { out.push(match); } return out; }; export const getReferenceAtPosition = ( document: vscode.TextDocument, position: vscode.Position, ): { range: vscode.Range; ref: string; label: string } | null => { if ( isInFencedCodeBlock(document, position.line) || isInCodeSpan(document, position.line, position.character) ) { return null; } const range = document.getWordRangeAtPosition(position, new RegExp(refPattern)); if (!range) { return null; } const { ref, label } = parseRef( document.getText(range).replace('![[', '').replace('[[', '').replace(']]', ''), ); return { ref, label, range, }; }; export const escapeForRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export const extractEmbedRefs = (content: string) => { const matches = matchAll(new RegExp(`!\\[\\[(([^\\[\\]]+?)(\\|.*)?)\\]\\]`, 'gi'), content); return matches.map((match) => { const [, $1] = match; return $1; }); }; const refsToRegExp = (refs: string | string[]) => { const refsArr = typeof refs === 'string' ? [refs] : refs; return new RegExp( `\\[\\[((${refsArr.map((ref) => escapeForRegExp(ref)).join('|')})(\\|[^\\[\\]]+?)?)\\]\\]`, 'gi', ); }; export const extractRefsFromText = ( refs: string | string[] | RegExp, text: string, ): ExtractedRefT[] => { const refsRegexp = refs instanceof RegExp ? refs : refsToRegExp(refs); const foundRefs: ExtractedRefT[] = []; text.split(/\r?\n/g).forEach((lineText, lineNum) => { for (const match of matchAll(refsRegexp, lineText)) { const [, reference] = match; const offset = (match.index || 0) + 2; if (isInFencedCodeBlock(text, lineNum) || isInCodeSpan(text, lineNum, offset)) { return; } foundRefs.push({ ref: { position: { start: new vscode.Position(lineNum, offset), end: new vscode.Position(lineNum, offset + reference.length), }, text: lineText.slice(Math.max(offset, 0), offset + reference.length), }, line: { trailingText: lineText.slice(Math.max(offset - 2, 0), lineText.length), }, }); } }); return foundRefs; }; export const findReferences = async ( refs: string | string[], excludePaths: string[] = [], ): Promise<FoundRefT[]> => { const foundRefs: FoundRefT[] = []; for (const { fsPath } of cache.getWorkspaceCache().markdownUris) { if (excludePaths.includes(fsPath) || !fs.existsSync(fsPath)) { continue; } const extractedRefs = extractRefsFromText(refs, fs.readFileSync(fsPath).toString()).map( ({ ref, line }) => ({ location: new vscode.Location( vscode.Uri.file(fsPath), new vscode.Range(ref.position.start, ref.position.end), ), matchText: line.trailingText, }), ); foundRefs.push(...extractedRefs); } return foundRefs; }; export const getFileUrlForMarkdownPreview = (filePathParam: string): string => { const workspaceFolder = getWorkspaceFolder(); const filePath = workspaceFolder ? filePathParam.replace(workspaceFolder, '') : filePathParam; return vscode.Uri.file(filePath).toString().replace('file://', ''); }; export const getImgUrlForMarkdownPreview = (imagePath: string): string => vscode.Uri.file(imagePath).toString().replace('file://', ''); const uncPathRegex = /^[\\\/]{2,}[^\\\/]+[\\\/]+[^\\\/]+/; export const isUncPath = (path: string): boolean => uncPathRegex.test(path); export const findFilesByExts = async (exts: string[]): Promise<vscode.Uri[]> => await findNonIgnoredFiles(`**/*.{${exts.join(',')}}`); export const findAllUrisWithUnknownExts = async (uris: vscode.Uri[]) => { const unknownExts = Array.from( new Set( uris .filter(({ fsPath }) => containsUnknownExt(fsPath)) .map(({ fsPath }) => path.parse(fsPath).ext.replace(/^\./, '')), ), ); return unknownExts.length ? await findFilesByExts(unknownExts) : []; }; export const extractExt = (value: string) => path.parse(value).ext.replace(/^\./, ''); export const findUriByRef = (uris: vscode.Uri[], ref: string): vscode.Uri | undefined => { return uris.find((uri) => { const relativeFsPath = path.sep + path.relative(getWorkspaceFolder()!.toLowerCase(), uri.fsPath.toLowerCase()); if (containsImageExt(ref) || containsOtherKnownExts(ref) || containsUnknownExt(ref)) { if (isLongRef(ref)) { const relativeFsPathNormalized = normalizeSlashes(relativeFsPath); const refLowerCased = ref.toLowerCase(); return ( relativeFsPathNormalized.endsWith(refLowerCased) || relativeFsPathNormalized.endsWith(`${refLowerCased}.md`) ); } const basenameLowerCased = path.basename(uri.fsPath).toLowerCase(); return ( basenameLowerCased === ref.toLowerCase() || basenameLowerCased === `${ref.toLowerCase()}.md` ); } if (isLongRef(ref)) { return normalizeSlashes(relativeFsPath).endsWith(`${ref.toLowerCase()}.md`); } const name = path.parse(uri.fsPath).name.toLowerCase(); return containsMarkdownExt(path.basename(uri.fsPath)) && name === ref.toLowerCase(); }); }; export const ensureDirectoryExists = (filePath: string) => { const dirname = path.dirname(filePath); if (!fs.existsSync(dirname)) { ensureDirectoryExists(dirname); fs.mkdirSync(dirname); } }; export const getRefUnderCursor = (): | { range: vscode.Range; ref: string; label: string } | null | undefined => { const activeTextEditor = vscode.window.activeTextEditor; const refAtPos = activeTextEditor && getReferenceAtPosition(activeTextEditor.document, activeTextEditor.selection.start); return refAtPos; }; export const getRefUriUnderCursor = (): vscode.Uri | null | undefined => { const refAtPos = getRefUnderCursor(); return refAtPos && findUriByRef(cache.getWorkspaceCache().allUris, refAtPos.ref); }; export const parseRef = (rawRef: string): RefT => { const escapedDividerPosition = rawRef.indexOf('\\|'); const dividerPosition = escapedDividerPosition !== -1 ? escapedDividerPosition : rawRef.indexOf('|'); return { ref: dividerPosition !== -1 ? rawRef.slice(0, dividerPosition) : rawRef, label: dividerPosition !== -1 ? rawRef.slice(dividerPosition + (escapedDividerPosition !== -1 ? 2 : 1), rawRef.length) : '', }; }; export const findNonIgnoredFiles = async ( include: GlobPattern, excludeParam?: string | null, maxResults?: number, token?: CancellationToken, ): Promise<Uri[]> => { const exclude = [ ...Object.keys(getConfigProperty('search.exclude', {})), ...Object.keys(getConfigProperty('file.exclude', {})), ...(typeof excludeParam === 'string' ? [excludeParam] : []), ].join(','); const files = await workspace.findFiles(include, `{${exclude}}`, maxResults, token); return files; }; export const getRefWithExt = (ref: string) => { const paths = ref.split('/'); const refExt = path.parse(ref).ext; return path.join( ...paths.slice(0, -1), `${paths.slice(-1)}${refExt !== '.md' && refExt !== '' ? '' : '.md'}`, ); }; export const getDirRelativeToWorkspace = (uri: Uri | undefined) => { const workspaceFolder = uri && workspace.getWorkspaceFolder(uri)?.uri.fsPath; if (!workspaceFolder || !uri) { return; } return path.dirname(uri.fsPath).replace(workspaceFolder, ''); }; export const resolveShortRefFolder = (ref: string): string | undefined => { const linksFormat = getMemoConfigProperty('links.format', 'short'); const linksRules = getMemoConfigProperty('links.rules', []); const refWithExt = getRefWithExt(ref); if (linksFormat !== 'short' || isLongRef(ref) || !Array.isArray(linksRules)) { return; } let linkRegExpMatchArr: RegExpMatchArray | null = null; let linkRule: LinkRuleT | null = null; for (const rule of linksRules) { const matchRes = new RegExp(rule.rule).exec(refWithExt); if (matchRes) { linkRegExpMatchArr = matchRes; linkRule = rule; break; } } if (!linkRule) { return; } const date = new Date(); // https://github.com/microsoft/vscode/blob/main/src/vs/editor/contrib/snippet/snippetVariables.ts const varsReplacementMap: { [varName: string]: string } = { $CURRENT_FILE_DIRECTORY: getDirRelativeToWorkspace(vscode.window.activeTextEditor?.document.uri) || '', $CURRENT_YEAR: String(date.getFullYear()), $CURRENT_YEAR_SHORT: String(date.getFullYear()).slice(-2), $CURRENT_MONTH: String(date.getMonth().valueOf() + 1).padStart(2, '0'), $CURRENT_DATE: String(date.getDate().valueOf()).padStart(2, '0'), $CURRENT_HOUR: String(date.getHours().valueOf()).padStart(2, '0'), $CURRENT_MINUTE: String(date.getMinutes().valueOf()).padStart(2, '0'), $CURRENT_SECOND: String(date.getSeconds().valueOf()).padStart(2, '0'), $CURRENT_SECONDS_UNIX: String(Math.floor(date.getTime() / 1000)), }; if (linkRegExpMatchArr) { linkRegExpMatchArr.forEach((match, index) => { varsReplacementMap[`$${index}`] = match; }); } let folder = linkRule.folder; for (const varName of Object.keys(varsReplacementMap)) { const varVal = varsReplacementMap[varName]; folder = folder.replace(new RegExp(escapeForRegExp(varName), 'g'), varVal); } return folder; }; export const isDefined = <T>(argument: T | undefined): argument is T => argument !== undefined;