341 lines · 10.6 KB · TypeScript
RawDownload
import { isWindowsAbsolutePath } from "@t3tools/shared/path"; const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/;const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;const RELATIVE_FILE_PATH_PATTERN =  /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;const RELATIVE_FILE_NAME_PATTERN =  /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/;const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i;const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;const PATH_SEPARATOR_PATTERN = /[\\/]/;const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;// Standard OS and dev-container roots; deliberately excludes app-route-ish// prefixes like /app/ or /chat/ so SPA routes never read as files.const POSIX_FILE_ROOT_PREFIXES = [  "/Users/",  "/home/",  "/tmp/",  "/var/",  "/etc/",  "/opt/",  "/mnt/",  "/Volumes/",  "/private/",  "/root/",  "/usr/",  "/bin/",  "/sbin/",  "/lib/",  "/lib64/",  "/srv/",  "/dev/",  "/proc/",  "/sys/",  "/run/",  "/boot/",  "/media/",  "/workspace/",  "/workspaces/",] as const;// `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`.const EXTENSIONLESS_FILE_NAMES = new Set([  "Makefile",  "makefile",  "GNUmakefile",  "Dockerfile",  "Containerfile",  "Justfile",  "justfile",  "Rakefile",  "Gemfile",  "Procfile",  "Brewfile",  "Caddyfile",  "Vagrantfile",  "Jenkinsfile",  "Podfile",  "Fastfile",  "BUILD",  "WORKSPACE",  "LICENSE",  "LICENCE",  "COPYING",  "NOTICE",  "AUTHORS",  "CONTRIBUTORS",  "CHANGELOG",  "README",  "CODEOWNERS",]);const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);// These allowlists avoid classifying dotted directories such as `conf.d/`// or filenames such as `Makefile.in:12` as hosts.const GENERIC_HOSTNAME_TLDS = new Set([  "com",  "net",  "org",  "io",  "dev",  "app",  "ai",  "co",  "edu",  "gov",  "mil",  "info",  "biz",  "xyz",  "me",  "tv",  "cc",  "gg",  "chat",  "cloud",  "site",  "online",  "tech",  "store",  "link",]);// Country codes also name file extensions. A :line suffix makes `.pl`// and `.pt` files more likely than hostnames.const COUNTRY_HOSTNAME_TLDS = new Set([  "uk",  "de",  "fr",  "nl",  "se",  "no",  "fi",  "dk",  "pl",  "ch",  "at",  "be",  "es",  "it",  "pt",  "eu",  "us",  "ca",  "au",  "nz",  "jp",  "kr",  "cn",  "br",  "ru",  "mx",  "ie",  "cz",  "tr",  "sg",  "hk",]); function looksLikeHostname(segment: string, hasPosition: boolean): boolean {  if (segment.startsWith(".")) return false;  const lowered = segment.toLowerCase();  if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;  if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;  const labels = lowered.split(".");  const lastLabel = labels.at(-1);  if (labels.length < 2 || lastLabel === undefined) return false;  if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;  return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);} /** * Picks path-shaped inline code for the client's markdown file-link resolver. * It does not resolve paths or turn plain prose and fenced code into links. */export function inlineCodeFilePathCandidate(codeText: string): string | null {  const trimmed = codeText.trim();  if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;   const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/");  const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);  if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;   const hasExplicitPathShape =    RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||    candidate.startsWith("/") ||    isWindowsAbsolutePath(candidate);  if (!hasExplicitPathShape) {    const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");    const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;    if (looksLikeHostname(firstSegment, hasPosition)) return null;    const basename =      withoutPosition        .replace(/[/\\]+$/, "")        .split(/[\\/]/)        .at(-1) ?? "";    if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null;  }  return candidate;} export function safeDecodeURIComponent(value: string): string {  try {    return decodeURIComponent(value);  } catch {    return value;  }} export function normalizeMarkdownLinkDestination(value: string): string {  const trimmed = value.trim();  return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;} /** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */export function stripSlashPrefixedWindowsDrive(path: string): string {  return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path;} export function splitMarkdownLinkSearchAndHash(value: string): {  readonly path: string;  readonly hash: string;} {  const hashIndex = value.indexOf("#");  const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;  const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";  const queryIndex = pathWithSearch.indexOf("?");  return {    path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch,    hash,  };} /** * Turns a `file:` URL into a host path, still percent-encoded so callers that * decode every destination in one place do not decode file URLs twice. A * non-localhost authority becomes a UNC share. */export function parseFileUrlHref(  href: string,): { readonly path: string; readonly hash: string } | null {  try {    const parsed = new URL(href);    if (parsed.protocol.toLowerCase() !== "file:") return null;     const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;    const path = uncHostname      ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`      : parsed.pathname;    if (path.length === 0) return null;    return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash };  } catch {    return null;  }} export interface FilePathPosition {  readonly path: string;  readonly line?: number;  readonly column?: number;} export function splitFilePathPosition(path: string, hash = ""): FilePathPosition {  const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN);  const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN);  if (!match?.[1]) return { path };   const line = Number.parseInt(match[1], 10);  const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10);  return {    path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path,    ...(line > 0 ? { line } : {}),    ...(column !== undefined && column > 0 ? { column } : {}),  };} export function formatFilePathPosition(position: FilePathPosition): string {  if (!position.line) return position.path;  return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;} export function isRelativeFilePath(path: string): boolean {  return (    RELATIVE_PATH_PREFIX_PATTERN.test(path) ||    (!path.startsWith("/") && !isWindowsAbsolutePath(path))  );} 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 EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename);} /** * Decides whether a decoded link destination is a file path rather than a route * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L` * anchor never turns `/chat/settings` into a file. */function looksLikeFilePath(path: string, authoredPath: string): boolean {  if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;  if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath);  if (EXTENSIONLESS_FILE_NAMES.has(path)) return true;  return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path);} function hasExternalScheme(path: string): boolean {  if (isWindowsAbsolutePath(path)) return false;  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 parseMarkdownFileLink(href: string): FilePathPosition | null {  const normalized = normalizeMarkdownLinkDestination(href);  if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) {    return null;  }   const source =    (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ??    splitMarkdownLinkSearchAndHash(normalized);  // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded.  const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim()));  const hash = safeDecodeURIComponent(source.hash.trim());  if (path.length === 0 || hasExternalScheme(path)) return null;   const position = splitFilePathPosition(path, hash);  return looksLikeFilePath(position.path, path) ? position : null;} export function fileBasename(path: string): string {  // A trailing separator is a valid way to write a directory. Trim it before  // taking the final segment so the label is never empty.  const trimmed = path.replace(/[/\\]+$/, "");  if (trimmed.length === 0) return path;  const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));  return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;} export function workspaceRelativeFilePath(  path: string,  workspaceRoot: string | null | undefined,): string | null {  if (!workspaceRoot) return null;  const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/"));  const normalizedRoot = stripSlashPrefixedWindowsDrive(    workspaceRoot.replaceAll("\\", "/"),  ).replace(/\/+$/, "");  const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot));  const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;  const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;  if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;  return normalizedPath.slice(normalizedRoot.length + 1);}