import { formatWorkspaceRelativePath } from "./filePathDisplay";import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", "/home/", "/tmp/", "/var/", "/etc/", "/opt/", "/mnt/", "/Volumes/", "/private/", "/root/",] as const; export interface MarkdownFileLinkMeta { filePath: string; targetPath: string; displayPath: string; workspaceRelativePath: string | null; basename: string; line?: number; column?: number;} function safeDecode(value: string): string { try { return decodeURIComponent(value); } catch { return value; }} function unwrapMarkdownLinkDestination(value: string): string { return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;} export function normalizeMarkdownLinkDestination(value: string): string { return unwrapMarkdownLinkDestination(value.trim());} function stripSearchAndHash(value: string): { path: string; hash: string } { const hashIndex = value.indexOf("#"); const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : ""; const queryIndex = pathWithSearch.indexOf("?"); const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch; return { path, hash: rawHash };} function normalizeWindowsDrivePath(path: string): string { return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path;} function parseFileUrlHref( href: string, options?: { readonly decodePath?: boolean },): { path: string; hash: string } | null { try { const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") return null; const rawPath = parsed.pathname; if (rawPath.length === 0) return null; // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. const normalizedPath = normalizeWindowsDrivePath(rawPath); return { path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath), hash: parsed.hash, }; } catch { return null; }} export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { if (!href) return null; const normalizedHref = normalizeMarkdownLinkDestination(href); const target = parseFileUrlHref(normalizedHref, { decodePath: false }); if (!target) return null; return `${target.path}${target.hash}`;} function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) return false; if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; if (POSITION_SUFFIX_PATTERN.test(path)) return true; const basename = path.slice(path.lastIndexOf("/") + 1); return /\.[A-Za-z0-9_-]+$/.test(basename);} function appendLineColumnFromHash(path: string, hash: string): string { if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path; const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i); if (!match?.[1]) return path; const line = match[1]; const column = match[2]; return `${path}:${line}${column ? `:${column}` : ""}`;} function isLikelyPathCandidate(path: string): boolean { if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true; if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; if (path.startsWith("/")) return looksLikePosixFilesystemPath(path); return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path);} function isRelativePath(path: string): boolean { return ( RELATIVE_PATH_PREFIX_PATTERN.test(path) || (!path.startsWith("/") && !WINDOWS_DRIVE_PATH_PATTERN.test(path) && !WINDOWS_UNC_PATH_PATTERN.test(path)) );} function hasExternalScheme(path: string): boolean { const match = path.match(EXTERNAL_SCHEME_PATTERN); if (!match) return false; const rest = match[2] ?? ""; if (rest.startsWith("//")) return true; return !POSITION_ONLY_PATTERN.test(rest);} export function resolveMarkdownFileLinkTarget( href: string | undefined, cwd?: string,): string | null { if (!href) return null; const rawHref = normalizeMarkdownLinkDestination(href); if (rawHref.length === 0 || rawHref.startsWith("#")) return null; const fileUrlTarget = rawHref.toLowerCase().startsWith("file:") ? parseFileUrlHref(rawHref) : null; const source = fileUrlTarget ?? stripSearchAndHash(rawHref); const decodedPath = normalizeWindowsDrivePath( fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()), ); const decodedHash = safeDecode(source.hash.trim()); if (decodedPath.length === 0) return null; if ( !WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) && !WINDOWS_UNC_PATH_PATTERN.test(decodedPath) && hasExternalScheme(decodedPath) ) { return null; } if (!isLikelyPathCandidate(decodedPath)) return null; const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash); if (!isRelativePath(pathWithPosition)) { return pathWithPosition; } if (!cwd) return null; return resolvePathLinkTarget(pathWithPosition, cwd);} function basenameOfPath(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;} function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { if (!workspaceRoot) return null; const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/")); const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace( /\/+$/, "", ); const pathForCompare = normalizedPath.toLowerCase(); const rootForCompare = normalizedRoot.toLowerCase(); if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; return normalizedPath.slice(normalizedRoot.length + 1);} export function resolveMarkdownFileLinkMeta( href: string | undefined, cwd?: string,): MarkdownFileLinkMeta | null { const targetPath = resolveMarkdownFileLinkTarget(href, cwd); if (!targetPath) return null; const { path, line, column } = splitPathAndPosition(targetPath); const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined; const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined; return { filePath: path, targetPath, displayPath: formatWorkspaceRelativePath(targetPath, cwd), workspaceRelativePath: workspaceRelativePath(path, cwd), basename: basenameOfPath(path), ...(lineNumber !== undefined ? { line: lineNumber } : {}), ...(columnNumber !== undefined ? { column: columnNumber } : {}), };}
import { formatWorkspaceRelativePath } from "./filePathDisplay";
import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links";
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;
const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
"/Users/",
"/home/",
"/tmp/",
"/var/",
"/etc/",
"/opt/",
"/mnt/",
"/Volumes/",
"/private/",
"/root/",
] as const;
export interface MarkdownFileLinkMeta {
filePath: string;
targetPath: string;
displayPath: string;
workspaceRelativePath: string | null;
basename: string;
line?: number;
column?: number;
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function unwrapMarkdownLinkDestination(value: string): string {
return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;
}
export function normalizeMarkdownLinkDestination(value: string): string {
return unwrapMarkdownLinkDestination(value.trim());
}
function stripSearchAndHash(value: string): { path: string; hash: string } {
const hashIndex = value.indexOf("#");
const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : "";
const queryIndex = pathWithSearch.indexOf("?");
const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch;
return { path, hash: rawHash };
}
function normalizeWindowsDrivePath(path: string): string {
return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path;
}
function parseFileUrlHref(
href: string,
options?: { readonly decodePath?: boolean },
): { path: string; hash: string } | null {
try {
const parsed = new URL(href);
if (parsed.protocol.toLowerCase() !== "file:") return null;
const rawPath = parsed.pathname;
if (rawPath.length === 0) return null;
// Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.
const normalizedPath = normalizeWindowsDrivePath(rawPath);
return {
path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath),
hash: parsed.hash,
};
} catch {
return null;
}
}
export function rewriteMarkdownFileUriHref(href: string | undefined): string | null {
if (!href) return null;
const normalizedHref = normalizeMarkdownLinkDestination(href);
const target = parseFileUrlHref(normalizedHref, { decodePath: false });
if (!target) return null;
return `${target.path}${target.hash}`;
}
function looksLikePosixFilesystemPath(path: string): boolean {
if (!path.startsWith("/")) return false;
if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
if (POSITION_SUFFIX_PATTERN.test(path)) return true;
const basename = path.slice(path.lastIndexOf("/") + 1);
return /\.[A-Za-z0-9_-]+$/.test(basename);
}
function appendLineColumnFromHash(path: string, hash: string): string {
if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path;
const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i);
if (!match?.[1]) return path;
const line = match[1];
const column = match[2];
return `${path}:${line}${column ? `:${column}` : ""}`;
}
function isLikelyPathCandidate(path: string): boolean {
if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true;
if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;
if (path.startsWith("/")) return looksLikePosixFilesystemPath(path);
return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path);
}
function isRelativePath(path: string): boolean {
return (
RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
(!path.startsWith("/") &&
!WINDOWS_DRIVE_PATH_PATTERN.test(path) &&
!WINDOWS_UNC_PATH_PATTERN.test(path))
);
}
function hasExternalScheme(path: string): boolean {
const match = path.match(EXTERNAL_SCHEME_PATTERN);
if (!match) return false;
const rest = match[2] ?? "";
if (rest.startsWith("//")) return true;
return !POSITION_ONLY_PATTERN.test(rest);
}
export function resolveMarkdownFileLinkTarget(
href: string | undefined,
cwd?: string,
): string | null {
if (!href) return null;
const rawHref = normalizeMarkdownLinkDestination(href);
if (rawHref.length === 0 || rawHref.startsWith("#")) return null;
const fileUrlTarget = rawHref.toLowerCase().startsWith("file:")
? parseFileUrlHref(rawHref)
: null;
const source = fileUrlTarget ?? stripSearchAndHash(rawHref);
const decodedPath = normalizeWindowsDrivePath(
fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()),
);
const decodedHash = safeDecode(source.hash.trim());
if (decodedPath.length === 0) return null;
if (
!WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) &&
!WINDOWS_UNC_PATH_PATTERN.test(decodedPath) &&
hasExternalScheme(decodedPath)
) {
return null;
}
if (!isLikelyPathCandidate(decodedPath)) return null;
const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash);
if (!isRelativePath(pathWithPosition)) {
return pathWithPosition;
}
if (!cwd) return null;
return resolvePathLinkTarget(pathWithPosition, cwd);
}
function basenameOfPath(path: string): string {
const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
}
function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null {
if (!workspaceRoot) return null;
const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/"));
const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace(
/\/+$/,
"",
);
const pathForCompare = normalizedPath.toLowerCase();
const rootForCompare = normalizedRoot.toLowerCase();
if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
return normalizedPath.slice(normalizedRoot.length + 1);
}
export function resolveMarkdownFileLinkMeta(
href: string | undefined,
cwd?: string,
): MarkdownFileLinkMeta | null {
const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
if (!targetPath) return null;
const { path, line, column } = splitPathAndPosition(targetPath);
const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN;
const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN;
const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined;
const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined;
return {
filePath: path,
targetPath,
displayPath: formatWorkspaceRelativePath(targetPath, cwd),
workspaceRelativePath: workspaceRelativePath(path, cwd),
basename: basenameOfPath(path),
...(lineNumber !== undefined ? { line: lineNumber } : {}),
...(columnNumber !== undefined ? { column: columnNumber } : {}),
};
}