mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
Authored Markdown destinations were filtered by the filename heuristics intended for inferred text, silently ignoring screenshot links. Resolve explicit destinations directly through the existing workspace opener so reads and failures reach the file pane.
730 lines
18 KiB
TypeScript
730 lines
18 KiB
TypeScript
import { ASSISTANT_FILE_EXTENSIONS } from "./extensions";
|
|
import { isAbsolutePath } from "@/utils/path";
|
|
|
|
function safeDecodeURIComponent(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
export interface InlinePathTarget {
|
|
raw: string;
|
|
path: string;
|
|
lineStart?: number;
|
|
lineEnd?: number;
|
|
}
|
|
|
|
const FILE_PROTOCOL = "file:";
|
|
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:C[0-9]+)?(?:-L?([0-9]+)(?:C[0-9]+)?)?$/i;
|
|
const INLINE_COLON_LINE_SUFFIX = /^(.+?):([0-9]+)(?::[0-9]+)?(?:-([0-9]+)(?::[0-9]+)?)?$/;
|
|
const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0-9]+)?)?\)$/;
|
|
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
|
|
export interface AssistantHrefParseOptions {
|
|
workspaceRoot?: string;
|
|
/** Authored links identify a file; only inferred text needs filename heuristics. */
|
|
explicitLink?: boolean;
|
|
/**
|
|
* True only when `value` is a markdown href — the renderer percent-encodes
|
|
* non-ASCII and space characters, so the path must be decoded. Raw path
|
|
* text (inline code, linkified tokens) is not encoded; decoding it would
|
|
* send a real file named `my%20file.md` to `my file.md`.
|
|
*/
|
|
decodeHref?: boolean;
|
|
}
|
|
|
|
export type AssistantFileLinkClassification =
|
|
| {
|
|
kind: "external";
|
|
raw: string;
|
|
}
|
|
| {
|
|
kind: "directFile";
|
|
target: InlinePathTarget;
|
|
}
|
|
| {
|
|
kind: "ambiguousFileCandidate";
|
|
target: InlinePathTarget;
|
|
};
|
|
|
|
export interface NormalizedInlinePathTarget {
|
|
directory: string;
|
|
file?: string;
|
|
}
|
|
|
|
function normalizePathToken(value: string): string | null {
|
|
const trimmed = value
|
|
.trim()
|
|
.replace(/^['"`]/, "")
|
|
.replace(/['"`]$/, "");
|
|
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
return trimmed.replace(/\\/g, "/");
|
|
}
|
|
|
|
function parseLineFragment(value: string): Pick<InlinePathTarget, "lineStart" | "lineEnd"> | null {
|
|
const rawFragment = value.startsWith("#") ? value.slice(1) : value;
|
|
if (!rawFragment) {
|
|
return { lineStart: undefined, lineEnd: undefined };
|
|
}
|
|
|
|
const lineMatch = rawFragment.match(INLINE_LINE_FRAGMENT);
|
|
const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined;
|
|
const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined;
|
|
|
|
if (
|
|
(lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) ||
|
|
(lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) ||
|
|
(lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return { lineStart, lineEnd };
|
|
}
|
|
|
|
/**
|
|
* Strict VSCode-style markers only.
|
|
*
|
|
* Supported:
|
|
* - `filename:linenumber`
|
|
* - `filename:linenumber:columnnumber` as a line target
|
|
* - `filename:lineStart-lineEnd`
|
|
*
|
|
* Not supported (by design):
|
|
* - plain `filename` (no line)
|
|
* - `:linenumber` (range-only)
|
|
*/
|
|
export function parseInlinePathToken(value: string): InlinePathTarget | null {
|
|
const rawValue = value ?? "";
|
|
const trimmed = rawValue.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const match =
|
|
trimmed.match(INLINE_COLON_LINE_SUFFIX) ??
|
|
trimmed.match(INLINE_PAREN_LINE_SUFFIX) ??
|
|
trimmed.match(INLINE_WORD_LINE_SUFFIX);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const basePathRaw = match[1]?.trim();
|
|
if (!basePathRaw) {
|
|
return null;
|
|
}
|
|
|
|
// Avoid accidentally treating URLs as file paths.
|
|
if (basePathRaw.includes("://")) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedPath = normalizePathToken(basePathRaw);
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
|
|
const lineStart = parseInt(match[2], 10);
|
|
if (!Number.isFinite(lineStart) || lineStart <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const lineEnd = match[3] ? parseInt(match[3], 10) : undefined;
|
|
if (lineEnd !== undefined) {
|
|
if (!Number.isFinite(lineEnd) || lineEnd <= 0) {
|
|
return null;
|
|
}
|
|
if (lineEnd < lineStart) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
raw: rawValue,
|
|
path: normalizedPath,
|
|
lineStart,
|
|
lineEnd,
|
|
};
|
|
}
|
|
|
|
export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(trimmed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (parsedUrl.protocol !== FILE_PROTOCOL) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedPath = normalizeFileUrlPath(parsedUrl.pathname);
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
|
|
const lines = parseLineFragment(parsedUrl.hash);
|
|
if (!lines) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
raw: value,
|
|
path: normalizedPath,
|
|
...lines,
|
|
};
|
|
}
|
|
|
|
function parseAssistantInlinePathLink(
|
|
value: string,
|
|
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
|
): InlinePathTarget | null {
|
|
const inlinePathTarget = parseInlinePathToken(value);
|
|
if (!inlinePathTarget) {
|
|
return null;
|
|
}
|
|
|
|
let pathPart = inlinePathTarget.path;
|
|
if (options.decodeHref) {
|
|
pathPart = safeDecodeURIComponent(pathPart);
|
|
}
|
|
const normalizedPath = normalizePathToken(pathPart);
|
|
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...inlinePathTarget,
|
|
path: normalizedPath,
|
|
};
|
|
}
|
|
|
|
export function classifyAssistantFileLink(
|
|
value: string,
|
|
options: AssistantHrefParseOptions = {},
|
|
): AssistantFileLinkClassification | null {
|
|
const raw = value ?? "";
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (isExternalHref(trimmed)) {
|
|
return {
|
|
kind: "external",
|
|
raw,
|
|
};
|
|
}
|
|
|
|
if (/\s/.test(trimmed)) {
|
|
return null;
|
|
}
|
|
|
|
const target = parseAssistantFileLink(trimmed, options);
|
|
if (!target) {
|
|
return null;
|
|
}
|
|
|
|
if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
|
return {
|
|
kind: "ambiguousFileCandidate",
|
|
target,
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: "directFile",
|
|
target,
|
|
};
|
|
}
|
|
|
|
export function parseAssistantFileLink(
|
|
value: string,
|
|
options: AssistantHrefParseOptions = {},
|
|
): InlinePathTarget | null {
|
|
const fileUrlTarget = parseFileProtocolUrl(value);
|
|
if (fileUrlTarget) {
|
|
return fileUrlTarget;
|
|
}
|
|
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (isExternalHref(trimmed)) {
|
|
return null;
|
|
}
|
|
|
|
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
|
|
if (inlinePathTarget) {
|
|
return inlinePathTarget;
|
|
}
|
|
|
|
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
|
|
if (windowsTarget) {
|
|
return windowsTarget;
|
|
}
|
|
|
|
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
|
workspaceRoot: options.workspaceRoot,
|
|
decodeHref: options.decodeHref,
|
|
explicitLink: options.explicitLink,
|
|
});
|
|
if (relativeTarget) {
|
|
return relativeTarget;
|
|
}
|
|
|
|
if (!isAbsolutePath(trimmed)) {
|
|
return null;
|
|
}
|
|
|
|
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
|
|
// normalize before decode. Raw paths must not: `new URL` percent-encodes
|
|
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
|
|
// still-encoded name.
|
|
let pathPart: string;
|
|
let hashPart: string;
|
|
if (options.decodeHref) {
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
|
} catch {
|
|
return null;
|
|
}
|
|
pathPart = safeDecodeURIComponent(parsedUrl.pathname);
|
|
hashPart = parsedUrl.hash;
|
|
} else {
|
|
const hashIndex = trimmed.indexOf("#");
|
|
pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
|
|
hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
|
|
}
|
|
|
|
const normalizedPath = normalizePathToken(pathPart);
|
|
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
|
return null;
|
|
}
|
|
|
|
const lines = parseLineFragment(hashPart);
|
|
if (!lines) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
raw: value,
|
|
path: normalizedPath,
|
|
...lines,
|
|
};
|
|
}
|
|
|
|
export function isFileLookingAssistantToken(value: string): boolean {
|
|
const normalized = normalizePathToken(value);
|
|
if (
|
|
!normalized ||
|
|
/\s/.test(normalized) ||
|
|
normalized.includes("?") ||
|
|
normalized.includes("://")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const path = getHeuristicLocalPath(normalized);
|
|
if (!path) {
|
|
return false;
|
|
}
|
|
|
|
return isPlausibleAssistantLocalPath(path);
|
|
}
|
|
|
|
function parseWindowsFileLink(
|
|
trimmed: string,
|
|
raw: string,
|
|
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
|
): InlinePathTarget | null {
|
|
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
|
if (!windowsPathMatch) {
|
|
return null;
|
|
}
|
|
|
|
let pathPart = windowsPathMatch[1] ?? "";
|
|
if (options.decodeHref) {
|
|
pathPart = safeDecodeURIComponent(pathPart);
|
|
}
|
|
const normalizedPath = normalizePathToken(pathPart);
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
|
|
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
|
if (!lines) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
raw,
|
|
path: normalizedPath,
|
|
...lines,
|
|
};
|
|
}
|
|
|
|
function parseWorkspaceRelativeFileLink(
|
|
value: string,
|
|
options: AssistantHrefParseOptions,
|
|
): InlinePathTarget | null {
|
|
const parsed = parseLocalPathParts(value, options);
|
|
if (!parsed || isAbsolutePath(parsed.path)) {
|
|
return null;
|
|
}
|
|
|
|
if (isHomeRelativePath(parsed.path)) {
|
|
return {
|
|
raw: value,
|
|
path: parsed.path,
|
|
...parsed.lines,
|
|
};
|
|
}
|
|
|
|
const workspaceRoot = normalizePathInput(options.workspaceRoot);
|
|
if (!workspaceRoot) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedPath = resolveRelativePathUnderRoot(parsed.path, workspaceRoot);
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
raw: value,
|
|
path: normalizedPath,
|
|
...parsed.lines,
|
|
};
|
|
}
|
|
|
|
function parseLocalPathParts(
|
|
value: string,
|
|
options: Pick<AssistantHrefParseOptions, "decodeHref" | "explicitLink"> = {},
|
|
): {
|
|
path: string;
|
|
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
|
|
} | null {
|
|
const normalized = normalizePathToken(value);
|
|
if (!normalized || normalized.includes("?")) {
|
|
return null;
|
|
}
|
|
|
|
const hashIndex = normalized.indexOf("#");
|
|
const beforeHash = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
|
const hash = hashIndex >= 0 ? normalized.slice(hashIndex) : "";
|
|
const fragmentLines = parseLineFragment(hash);
|
|
if (!fragmentLines) {
|
|
return null;
|
|
}
|
|
|
|
// Split literal line markers before decoding; encoded punctuation belongs
|
|
// to the filename. Raw inline-code/path text stays untouched.
|
|
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
|
if (inlinePathTarget) {
|
|
let pathPart = inlinePathTarget.path;
|
|
if (options.decodeHref) {
|
|
pathPart = safeDecodeURIComponent(pathPart);
|
|
}
|
|
if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
|
|
return null;
|
|
}
|
|
return {
|
|
path: pathPart,
|
|
lines: {
|
|
lineStart: inlinePathTarget.lineStart,
|
|
lineEnd: inlinePathTarget.lineEnd,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (!beforeHash || beforeHash.includes(":")) {
|
|
return null;
|
|
}
|
|
|
|
let decodedBeforeHash = beforeHash;
|
|
if (options.decodeHref) {
|
|
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
|
|
}
|
|
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
|
|
if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
path: decodedBeforeHash,
|
|
lines: fragmentLines,
|
|
};
|
|
}
|
|
|
|
export function normalizeInlinePathTarget(
|
|
rawPath: string,
|
|
cwd?: string,
|
|
): NormalizedInlinePathTarget | null {
|
|
if (!rawPath) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedInput = normalizePathInput(rawPath);
|
|
if (!normalizedInput) {
|
|
return null;
|
|
}
|
|
|
|
let normalized = normalizedInput;
|
|
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
|
|
if (cwdRelative) {
|
|
normalized = cwdRelative;
|
|
}
|
|
|
|
if (normalized.startsWith("./")) {
|
|
normalized = normalized.slice(2) || ".";
|
|
}
|
|
|
|
if (!normalized.length) {
|
|
normalized = ".";
|
|
}
|
|
|
|
if (normalized === ".") {
|
|
return { directory: "." };
|
|
}
|
|
|
|
if (normalized.endsWith("/")) {
|
|
const dir = normalized.replace(/\/+$/, "");
|
|
return { directory: dir.length > 0 ? dir : "." };
|
|
}
|
|
|
|
const lastSlash = normalized.lastIndexOf("/");
|
|
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
|
|
|
return {
|
|
directory: directory.length > 0 ? directory : ".",
|
|
file: normalized,
|
|
};
|
|
}
|
|
|
|
function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boolean {
|
|
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
|
if (!normalizedWorkspaceRoot) {
|
|
return true;
|
|
}
|
|
|
|
const comparePath = normalizePathForCompare(pathValue);
|
|
const compareWorkspaceRoot = normalizePathForCompare(
|
|
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/",
|
|
);
|
|
const comparePrefix = compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
|
|
|
|
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
|
|
}
|
|
|
|
function isHomeRelativePath(pathValue: string): boolean {
|
|
return pathValue === "~" || pathValue.startsWith("~/") || pathValue.startsWith("~\\");
|
|
}
|
|
|
|
function isExternalHref(value: string): boolean {
|
|
if (value.includes("://")) {
|
|
return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`);
|
|
}
|
|
|
|
const inlinePathTarget = parseInlinePathToken(value);
|
|
if (inlinePathTarget) {
|
|
return false;
|
|
}
|
|
|
|
return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) && !/^[A-Za-z]:[\\/]/.test(value);
|
|
}
|
|
|
|
function isAmbiguousWorkspaceCandidate(
|
|
value: string,
|
|
target: InlinePathTarget,
|
|
options: AssistantHrefParseOptions = {},
|
|
): boolean {
|
|
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
|
|
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
|
|
return false;
|
|
}
|
|
|
|
const parsed = parseLocalPathParts(value, options);
|
|
if (!parsed || isAbsolutePath(parsed.path)) {
|
|
return false;
|
|
}
|
|
|
|
return !parsed.path.includes("/");
|
|
}
|
|
|
|
function getHeuristicLocalPath(value: string): string | null {
|
|
const hashIndex = value.indexOf("#");
|
|
const beforeHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
|
|
const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
|
|
if (!parseLineFragment(hash)) {
|
|
return null;
|
|
}
|
|
|
|
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
|
if (inlinePathTarget) {
|
|
return inlinePathTarget.path;
|
|
}
|
|
|
|
if (!beforeHash || beforeHash.includes(":")) {
|
|
return null;
|
|
}
|
|
|
|
return beforeHash;
|
|
}
|
|
|
|
function isPlausibleAssistantLocalPath(pathValue: string): boolean {
|
|
const normalized = normalizePathToken(pathValue);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
if (isAbsolutePath(normalized)) {
|
|
return true;
|
|
}
|
|
|
|
const explicitRelative =
|
|
normalized.startsWith("./") || normalized.startsWith("../") || normalized.startsWith("~/");
|
|
if (explicitRelative) {
|
|
return true;
|
|
}
|
|
|
|
const segments = normalized.split("/").filter(Boolean);
|
|
const firstSegment = segments[0];
|
|
if (!firstSegment) {
|
|
return false;
|
|
}
|
|
|
|
if (segments.length > 1) {
|
|
const lastSegment = segments[segments.length - 1];
|
|
return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment);
|
|
}
|
|
|
|
return isPlausibleAssistantFileName(firstSegment);
|
|
}
|
|
|
|
function isPlausibleAssistantFileName(fileName: string | undefined): boolean {
|
|
if (!fileName) {
|
|
return false;
|
|
}
|
|
|
|
if (fileName.startsWith(".") && fileName.length > 1) {
|
|
return true;
|
|
}
|
|
|
|
const lastDot = fileName.lastIndexOf(".");
|
|
if (lastDot < 0) {
|
|
return false;
|
|
}
|
|
|
|
const extension = fileName.slice(lastDot + 1).toLowerCase();
|
|
return ASSISTANT_FILE_EXTENSIONS.has(extension);
|
|
}
|
|
|
|
function isDomainLikePathSegment(segment: string): boolean {
|
|
return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment);
|
|
}
|
|
|
|
function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null {
|
|
const normalizedPath = normalizePathToken(pathValue);
|
|
if (!normalizedPath || isAbsolutePath(normalizedPath)) {
|
|
return null;
|
|
}
|
|
|
|
const root = workspaceRoot.replace(/\/+$/, "") || "/";
|
|
const pathSegments = normalizedPath.split("/");
|
|
const resolvedSegments: string[] = [];
|
|
for (const segment of pathSegments) {
|
|
if (!segment || segment === ".") {
|
|
continue;
|
|
}
|
|
if (segment === "..") {
|
|
if (resolvedSegments.length === 0) {
|
|
return null;
|
|
}
|
|
resolvedSegments.pop();
|
|
continue;
|
|
}
|
|
resolvedSegments.push(segment);
|
|
}
|
|
|
|
if (resolvedSegments.length === 0) {
|
|
return root;
|
|
}
|
|
|
|
return root === "/" ? `/${resolvedSegments.join("/")}` : `${root}/${resolvedSegments.join("/")}`;
|
|
}
|
|
|
|
function normalizeFileUrlPath(pathname: string): string | null {
|
|
if (!pathname) {
|
|
return null;
|
|
}
|
|
|
|
const decoded = safeDecodeURIComponent(pathname).replace(/\\/g, "/");
|
|
if (!decoded) {
|
|
return null;
|
|
}
|
|
|
|
if (/^\/[A-Za-z]:\//.test(decoded)) {
|
|
return decoded.slice(1);
|
|
}
|
|
|
|
return decoded;
|
|
}
|
|
|
|
function normalizePathInput(value: string | undefined): string | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = value
|
|
.trim()
|
|
.replace(/^['"`]/, "")
|
|
.replace(/['"`]$/, "");
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
}
|
|
|
|
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
|
const normalizedCwd = normalizePathInput(cwd);
|
|
if (!normalizedCwd || !isAbsolutePath(pathValue) || !isAbsolutePath(normalizedCwd)) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
|
|
const comparePath = normalizePathForCompare(pathValue);
|
|
const compareCwd = normalizePathForCompare(normalizedCwdBase);
|
|
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
|
|
const comparePrefix = normalizePathForCompare(prefix);
|
|
|
|
if (comparePath === compareCwd) {
|
|
return ".";
|
|
}
|
|
|
|
if (comparePath.startsWith(comparePrefix)) {
|
|
return pathValue.slice(prefix.length) || ".";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizePathForCompare(value: string): string {
|
|
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
|
}
|