Fix assistant file link resolution (#1025)

This commit is contained in:
Mohamed Boudra
2026-05-14 20:55:41 +08:00
committed by GitHub
parent a19b780eca
commit 7e255a5249
9 changed files with 1421 additions and 59 deletions
@@ -0,0 +1,395 @@
import { describe, expect, it, vi } from "vitest";
import {
createAssistantFileLinkResolver,
getAssistantFileLinkToken,
type AssistantFileLinkContext,
type DirectorySuggestionResult,
} from "./assistant-file-link-resolver";
const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
workspaceRoot: "/Users/test/project",
};
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
return { entries, error: null };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe("assistant file link resolver", () => {
it("dedupes in-flight prefetches and serves the click from cache", async () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
);
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await Promise.all([
resolver.prefetch({ context: CONTEXT, source }),
resolver.prefetch({ context: CONTEXT, source }),
]);
const result = await resolver.open({ context: CONTEXT, source });
expect(suggestions).toHaveBeenCalledTimes(1);
expect(suggestions).toHaveBeenCalledWith({
query: "dumm.md",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "dumm.md",
path: "/Users/test/project/src/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(result.opened).toBe(true);
});
it("click consumes an in-flight hover resolution", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const suggestions = vi.fn(() => deferred.promise);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
const prefetch = resolver.prefetch({ context: CONTEXT, source });
const opened = resolver.open({ context: CONTEXT, source });
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await prefetch;
const result = await opened;
expect(suggestions).toHaveBeenCalledTimes(1);
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(result.opened).toBe(true);
});
it("retries a click after hover prefetch fails to query suggestions", async () => {
const suggestions = vi
.fn()
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
const openResult = await resolver.open({ context: CONTEXT, source });
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(openResult.opened).toBe(true);
});
it("does not cache unresolved candidates", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const first = await resolver.open({ context: CONTEXT, source });
const second = await resolver.open({ context: CONTEXT, source });
expect(first).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
expect(second.opened).toBe(true);
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
});
it("keys cache entries by server, workspace, and token", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await resolver.open({ context: CONTEXT, source });
await resolver.open({
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
source,
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenLastCalledWith({
raw: "dumm.md",
path: "/Users/test/other/two/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("does not apply stale async results after the active context changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
let isCurrent = true;
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(() => deferred.promise),
openWorkspaceFile,
openExternalUrl: vi.fn(),
isCurrentContext: () => isCurrent,
});
const opened = resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
});
isCurrent = false;
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
const result = await opened;
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(result.opened).toBe(false);
expect(result.kind).toBe("file");
});
it("opens direct workspace file links without querying suggestions", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
});
expect(suggestions).not.toHaveBeenCalled();
expect(openWorkspaceFile).toHaveBeenCalledWith({
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
});
expect(result.opened).toBe(true);
});
it("keeps explicit external URLs external", async () => {
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md" },
});
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
expect(result).toEqual({
kind: "external",
url: "http://dumm.md",
opened: true,
});
});
it("keeps auto-linkified normal domains external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
expect(result).toEqual({
kind: "external",
url: "http://google.com",
opened: true,
});
});
it("keeps auto-linkified normal domain paths external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
expect(result).toEqual({
kind: "external",
url: "http://openai.com/path",
opened: true,
});
});
it("does not open unresolved linkified markdown filenames in the browser", async () => {
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const prefetchResult = await resolver.prefetch({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
});
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(openExternalUrl).not.toHaveBeenCalled();
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("keeps failed ambiguous resolution out of the browser", async () => {
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => {
throw new Error("daemon unavailable");
}),
openWorkspaceFile: vi.fn(),
openExternalUrl,
onUnresolvedFileCandidate,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect(
getAssistantFileLinkToken({
href: "http://dumm.md",
text: "dumm.md",
markup: "linkify",
sourceInfo: "auto",
}),
).toBe("dumm.md");
expect(
getAssistantFileLinkToken({
href: "http://google.com",
text: "google.com",
markup: "linkify",
sourceInfo: "auto",
}),
).toBe("http://google.com");
expect(
getAssistantFileLinkToken({
href: "http://dumm.md",
text: "dumm.md",
markup: "",
sourceInfo: "",
}),
).toBe("http://dumm.md");
});
});
@@ -0,0 +1,258 @@
import {
classifyAssistantFileLink,
isFileLookingAssistantToken,
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./inline-path";
export interface AssistantFileLinkContext {
serverId?: string;
workspaceRoot?: string;
}
export interface AssistantFileLinkSource {
href: string;
text?: string;
markup?: string;
sourceInfo?: string;
sourceType?: string;
}
export interface DirectorySuggestionEntry {
path: string;
kind: "file" | "directory";
}
export interface DirectorySuggestionResult {
entries: DirectorySuggestionEntry[];
error: string | null;
}
export interface AssistantFileLinkResolverDependencies {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
limit: number;
}) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget) => void;
openExternalUrl: (url: string) => void | Promise<void>;
onUnresolvedFileCandidate?: (token: string) => void;
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
}
export interface AssistantFileLinkResolver {
prefetch(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink>;
open(input: AssistantFileLinkInteraction): Promise<AssistantFileLinkOpenResult>;
}
export interface AssistantFileLinkInteraction {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
}
export type ResolvedAssistantFileLink =
| {
kind: "external";
url: string;
}
| {
kind: "file";
target: InlinePathTarget;
}
| {
kind: "unresolvedFileCandidate";
token: string;
}
| {
kind: "ignored";
};
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
opened: boolean;
};
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
interface ParsedAssistantFileLinkInteraction {
token: string;
classification: AssistantFileLinkClassification;
}
export function createAssistantFileLinkResolver(
dependencies: AssistantFileLinkResolverDependencies,
): AssistantFileLinkResolver {
const cache = new Map<string, CachedAssistantFileLink>();
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
async function resolve(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink> {
const parsed = parseInteraction(input);
if (!parsed) {
return { kind: "ignored" };
}
if (parsed.classification.kind === "external") {
return { kind: "external", url: parsed.classification.raw };
}
if (parsed.classification.kind === "directFile") {
return { kind: "file", target: parsed.classification.target };
}
const key = getResolutionKey(input.context, parsed.token);
const cached = cache.get(key);
if (cached) {
return cached;
}
const active = inFlight.get(key);
if (active) {
return active;
}
const request = resolveAmbiguousCandidate({
context: input.context,
token: parsed.token,
target: parsed.classification.target,
getDirectorySuggestions: dependencies.getDirectorySuggestions,
})
.then((result) => {
if (result.kind === "file") {
cache.set(key, result);
}
inFlight.delete(key);
return result;
})
.catch((): CachedAssistantFileLink => {
inFlight.delete(key);
return { kind: "unresolvedFileCandidate", token: parsed.token };
});
inFlight.set(key, request);
return request;
}
return {
prefetch(input) {
return resolve(input);
},
async open(input) {
const resolved = await resolve(input);
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
return { ...resolved, opened: false };
}
if (resolved.kind === "file") {
dependencies.openWorkspaceFile(resolved.target);
return { ...resolved, opened: true };
}
if (resolved.kind === "external") {
await dependencies.openExternalUrl(resolved.url);
return { ...resolved, opened: true };
}
if (resolved.kind === "unresolvedFileCandidate") {
dependencies.onUnresolvedFileCandidate?.(resolved.token);
}
return { ...resolved, opened: false };
},
};
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
if (isLinkifiedSource(source)) {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
return text;
}
}
return source.href;
}
function parseInteraction(
input: AssistantFileLinkInteraction,
): ParsedAssistantFileLinkInteraction | null {
const token = getAssistantFileLinkToken(input.source).trim();
if (!token) {
return null;
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: input.context.workspaceRoot,
});
if (!classification) {
return null;
}
return { token, classification };
}
async function resolveAmbiguousCandidate(input: {
context: AssistantFileLinkContext;
token: string;
target: InlinePathTarget;
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
}): Promise<CachedAssistantFileLink> {
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
const suggestions = await input.getDirectorySuggestions({
query,
cwd: workspaceRoot,
includeFiles: true,
includeDirectories: false,
limit: 1,
});
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
return {
kind: "file",
target: {
...input.target,
path: joinWorkspacePath(workspaceRoot, match.path),
},
};
}
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = target.path.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
const lastSlash = normalizedPath.lastIndexOf("/");
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
}
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
}
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
return source.markup === "linkify" || source.sourceInfo === "auto";
}
function canApplyResult(
context: AssistantFileLinkContext,
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
): boolean {
return isCurrentContext ? isCurrentContext(context) : true;
}
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
return root ? `${root}/${child}` : child;
}
+142
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
classifyAssistantFileLink,
normalizeInlinePathTarget,
parseAssistantFileLink,
parseFileProtocolUrl,
@@ -70,7 +71,143 @@ describe("parseFileProtocolUrl", () => {
});
});
describe("classifyAssistantFileLink", () => {
it("keeps explicit external URLs out of file parsing", () => {
expect(
classifyAssistantFileLink("http://dumm.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "external",
raw: "http://dumm.md",
});
expect(classifyAssistantFileLink("mailto:test@example.com")).toEqual({
kind: "external",
raw: "mailto:test@example.com",
});
});
it("classifies bare workspace candidates separately from direct relative files", () => {
expect(
classifyAssistantFileLink("dumm.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "ambiguousFileCandidate",
target: {
raw: "dumm.md",
path: "/Users/test/project/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
});
expect(
classifyAssistantFileLink("message-renderer.tsx", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "ambiguousFileCandidate",
target: {
raw: "message-renderer.tsx",
path: "/Users/test/project/message-renderer.tsx",
lineStart: undefined,
lineEnd: undefined,
},
});
expect(
classifyAssistantFileLink("src/components/message.tsx#L33", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
kind: "directFile",
target: {
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
});
});
it("does not classify normal bare domains as file candidates", () => {
expect(
classifyAssistantFileLink("google.com", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
expect(
classifyAssistantFileLink("example.com", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
expect(
classifyAssistantFileLink("openai.com/path", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
});
});
describe("parseAssistantFileLink", () => {
it("resolves bare markdown filenames against the active workspace", () => {
expect(
parseAssistantFileLink("dumm.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "dumm.md",
path: "/Users/test/project/dumm.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("resolves bare source filenames with line suffixes against the active workspace", () => {
expect(
parseAssistantFileLink("file.ts:12", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "file.ts:12",
path: "/Users/test/project/file.ts",
lineStart: 12,
lineEnd: undefined,
});
});
it("rejects bare domains and domain-like paths", () => {
expect(
parseAssistantFileLink("google.com", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
expect(
parseAssistantFileLink("google.com:80", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
expect(
parseAssistantFileLink("openai.com/path", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
});
it("resolves relative paths against the active workspace", () => {
expect(
parseAssistantFileLink("src/components/message.tsx#L33", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
});
});
it("parses absolute POSIX hrefs inside the active workspace", () => {
expect(
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
@@ -146,6 +283,11 @@ describe("parseAssistantFileLink", () => {
it("rejects external URLs", () => {
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
expect(
parseAssistantFileLink("http://dumm.md", {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
});
it("rejects invalid line fragments", () => {
+323 -1
View File
@@ -9,11 +9,82 @@ export interface InlinePathTarget {
const FILE_PROTOCOL = "file:";
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
const ASSISTANT_FILE_EXTENSIONS = new Set([
"astro",
"bash",
"c",
"cc",
"cjs",
"cpp",
"cs",
"css",
"cts",
"cxx",
"env",
"fish",
"go",
"gql",
"gradle",
"graphql",
"h",
"hpp",
"htm",
"html",
"ini",
"java",
"js",
"json",
"jsonc",
"jsx",
"kt",
"kts",
"less",
"lock",
"lua",
"md",
"mdx",
"mjs",
"mts",
"php",
"proto",
"py",
"rb",
"rs",
"sass",
"scss",
"sh",
"sql",
"svelte",
"swift",
"toml",
"ts",
"tsx",
"txt",
"vue",
"xml",
"yaml",
"yml",
"zsh",
]);
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
}
export type AssistantFileLinkClassification =
| {
kind: "external";
raw: string;
}
| {
kind: "directFile";
target: InlinePathTarget;
}
| {
kind: "ambiguousFileCandidate";
target: InlinePathTarget;
};
export interface NormalizedInlinePathTarget {
directory: string;
file?: string;
@@ -172,6 +243,41 @@ function parseAssistantInlinePathLink(
};
}
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,
};
}
const target = parseAssistantFileLink(trimmed, options);
if (!target) {
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
return {
kind: "ambiguousFileCandidate",
target,
};
}
return {
kind: "directFile",
target,
};
}
export function parseAssistantFileLink(
value: string,
options: AssistantHrefParseOptions = {},
@@ -186,7 +292,7 @@ export function parseAssistantFileLink(
return null;
}
if (trimmed.includes("://")) {
if (isExternalHref(trimmed)) {
return null;
}
@@ -216,6 +322,13 @@ export function parseAssistantFileLink(
};
}
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
});
if (relativeTarget) {
return relativeTarget;
}
if (!isAbsolutePath(trimmed)) {
return null;
}
@@ -248,6 +361,91 @@ export function parseAssistantFileLink(
};
}
export function isFileLookingAssistantToken(value: string): boolean {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?") || normalized.includes("://")) {
return false;
}
const path = getHeuristicLocalPath(normalized);
if (!path) {
return false;
}
return isPlausibleAssistantLocalPath(path);
}
function parseWorkspaceRelativeFileLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
const workspaceRoot = normalizePathInput(options.workspaceRoot);
if (!workspaceRoot) {
return null;
}
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
const normalizedPath = resolveRelativePathUnderRoot(parsed.path, workspaceRoot);
if (!normalizedPath) {
return null;
}
return {
raw: value,
path: normalizedPath,
...parsed.lines,
};
}
function parseLocalPathParts(
value: string,
): { 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;
}
const inlinePathTarget = parseInlinePathToken(beforeHash);
if (inlinePathTarget) {
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
return null;
}
return {
path: inlinePathTarget.path,
lines: {
lineStart: inlinePathTarget.lineStart,
lineEnd: inlinePathTarget.lineEnd,
},
};
}
if (!beforeHash || beforeHash.includes(":")) {
return null;
}
if (!isPlausibleAssistantLocalPath(beforeHash)) {
return null;
}
return {
path: beforeHash,
lines: fragmentLines,
};
}
export function normalizeInlinePathTarget(
rawPath: string,
cwd?: string,
@@ -308,6 +506,130 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
}
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,
workspaceRoot?: string,
): boolean {
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
return false;
}
const parsed = parseLocalPathParts(value);
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) {
return !isDomainLikePathSegment(firstSegment);
}
if (firstSegment.startsWith(".") && firstSegment.length > 1) {
return true;
}
const lastDot = firstSegment.lastIndexOf(".");
if (lastDot < 0) {
return true;
}
const extension = firstSegment.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;