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);}
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);
}