213 lines · 7.2 KB · TypeScript
RawDownload
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 } : {}),  };}