import { formatWorkspaceRelativePath } from "./filePathDisplay";import { isTerminalLinkActivation, 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._-]+)*\/)+[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+)?$/;// 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;const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; export interface MarkdownFileLinkMeta { filePath: string; targetPath: string; displayPath: string; workspaceRelativePath: string | null; basename: string; line?: number; column?: number;} export function extractMarkdownLinkHrefs(markdown: string): string[] { const hrefs: string[] = []; for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { const href = (match[1] ?? match[2])?.trim(); if (href) hrefs.push(href); } return hrefs;} export function shouldOpenMarkdownFileLinkInEditor( event: Pick<MouseEvent, "metaKey" | "ctrlKey">, platform?: string,): boolean { return isTerminalLinkActivation(event, platform);} 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 uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; const rawPath = uncHostname ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` : 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);} const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;const PATH_SEPARATOR_PATTERN = /[\\/]/;const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/;// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so// extensionless linking is limited to conventional filenames.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"]);// Allowlists, not full public-suffix detection: treating every dotted first// segment as a host would swallow real paths like `conf.d/x.conf` or// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`,// `ts`, `rs`, `in`, ...) are deliberately absent from both sets.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 collide with file extensions (`.pl` Perl, `.pt` PyTorch,// `.es` ES modules), so they only count as host evidence when the candidate// lacks a :line suffix — an explicit line reference marks a file and wins.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",]); /** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */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[labels.length - 1]; if (labels.length < 2 || lastLabel === undefined) return false; if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true; return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);} /** * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, * `origin/main`) rather than deliberate link destinations, so auto-linking * them demands stronger path evidence than an explicit markdown link does: * an unambiguous path prefix, a file extension, or a :line suffix. */export function resolveInlineCodeFileLinkMeta( codeText: string, cwd?: string,): MarkdownFileLinkMeta | null { const trimmed = codeText.trim(); if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; // Windows drive/UNC paths keep their backslashes; any other backslashes are // relative Windows-style paths, which neither the shape checks nor the // downstream resolver understand — normalize them to forward slashes. const candidate = WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(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("/") || WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || WINDOWS_UNC_PATH_PATTERN.test(candidate); if (!hasExplicitPathShape) { const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; if (looksLikeHostname(firstSegment, hasPosition)) return null; if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) { return null; } } const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); if (resolved) return resolved; // `Makefile:12` — conventional extensionless names fail the generic // markdown-link candidate patterns, but here the :line suffix already // marked the span as a file reference. if ( cwd && BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) && EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, "")) ) { return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); } return null;} function basenameOfPath(path: string): string { // A trailing separator is a valid way to write a directory, so trim it before // taking the final segment. Without this the segment reads as empty and the // chip renders with no label at all. const trimmed = path.replace(/[/\\]+$/, "") || path; const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;} 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; return buildFileLinkMetaFromTarget(targetPath, cwd);} function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { 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 {
isTerminalLinkActivation,
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._-]+)*\/)+[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+)?$/;
// 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;
const MARKDOWN_LINK_HREF_PATTERN =
/\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
export interface MarkdownFileLinkMeta {
filePath: string;
targetPath: string;
displayPath: string;
workspaceRelativePath: string | null;
basename: string;
line?: number;
column?: number;
}
export function extractMarkdownLinkHrefs(markdown: string): string[] {
const hrefs: string[] = [];
for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
const href = (match[1] ?? match[2])?.trim();
if (href) hrefs.push(href);
}
return hrefs;
}
export function shouldOpenMarkdownFileLinkInEditor(
event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
platform?: string,
): boolean {
return isTerminalLinkActivation(event, platform);
}
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 uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
const rawPath = uncHostname
? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
: 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);
}
const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
const PATH_SEPARATOR_PATTERN = /[\\/]/;
const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/;
// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so
// extensionless linking is limited to conventional filenames.
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"]);
// Allowlists, not full public-suffix detection: treating every dotted first
// segment as a host would swallow real paths like `conf.d/x.conf` or
// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`,
// `ts`, `rs`, `in`, ...) are deliberately absent from both sets.
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 collide with file extensions (`.pl` Perl, `.pt` PyTorch,
// `.es` ES modules), so they only count as host evidence when the candidate
// lacks a :line suffix — an explicit line reference marks a file and wins.
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",
]);
/** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */
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[labels.length - 1];
if (labels.length < 2 || lastLabel === undefined) return false;
if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;
return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);
}
/**
* Inline code spans mostly hold identifiers, commands, and refs (`node.meta`,
* `origin/main`) rather than deliberate link destinations, so auto-linking
* them demands stronger path evidence than an explicit markdown link does:
* an unambiguous path prefix, a file extension, or a :line suffix.
*/
export function resolveInlineCodeFileLinkMeta(
codeText: string,
cwd?: string,
): MarkdownFileLinkMeta | null {
const trimmed = codeText.trim();
if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
// Windows drive/UNC paths keep their backslashes; any other backslashes are
// relative Windows-style paths, which neither the shape checks nor the
// downstream resolver understand — normalize them to forward slashes.
const candidate =
WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(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("/") ||
WINDOWS_DRIVE_PATH_PATTERN.test(candidate) ||
WINDOWS_UNC_PATH_PATTERN.test(candidate);
if (!hasExplicitPathShape) {
const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
if (looksLikeHostname(firstSegment, hasPosition)) return null;
if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) {
return null;
}
}
const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);
if (resolved) return resolved;
// `Makefile:12` — conventional extensionless names fail the generic
// markdown-link candidate patterns, but here the :line suffix already
// marked the span as a file reference.
if (
cwd &&
BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) &&
EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, ""))
) {
return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);
}
return null;
}
function basenameOfPath(path: string): string {
// A trailing separator is a valid way to write a directory, so trim it before
// taking the final segment. Without this the segment reads as empty and the
// chip renders with no label at all.
const trimmed = path.replace(/[/\\]+$/, "") || path;
const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;
}
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;
return buildFileLinkMetaFromTarget(targetPath, cwd);
}
function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta {
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 } : {}),
};
}