Skip to content

Commit 70b0cfb

Browse files
authored
fix(annotate): support @ markdown file references (#488)
OpenCode can pass annotate targets as @file references, so resolve markdown paths with an @ fallback only after the primary lookup misses. This preserves real @-prefixed filenames while making @README.md and quoted @ inputs resolve reliably.
1 parent 03b3bdd commit 70b0cfb

2 files changed

Lines changed: 102 additions & 3 deletions

File tree

packages/server/resolve-file.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,33 @@ describe("resolveMarkdownFile", () => {
106106
});
107107
});
108108

109+
test("resolves @ filename via fallback when @ file does not exist", async () => {
110+
const root = createTempProject({ "README.md": "# Hello" });
111+
const result = resolveMarkdownFile("@README.md", root);
112+
expect(result).toEqual({
113+
kind: "found",
114+
path: resolve(root, "README.md"),
115+
});
116+
});
117+
118+
test("prioritizes real @ filename before fallback", async () => {
119+
const root = createTempProject({ "@README.md": "# At" });
120+
const result = resolveMarkdownFile("@README.md", root);
121+
expect(result).toEqual({
122+
kind: "found",
123+
path: resolve(root, "@README.md"),
124+
});
125+
});
126+
127+
test("resolves quoted @ filename", async () => {
128+
const root = createTempProject({ "README.md": "# Hello" });
129+
const result = resolveMarkdownFile('"@README.md"', root);
130+
expect(result).toEqual({
131+
kind: "found",
132+
path: resolve(root, "README.md"),
133+
});
134+
});
135+
109136
test("resolves relative paths with Windows separators", async () => {
110137
const root = createTempProject({ "docs/test-plan.md": "# Test plan\n" });
111138
const result = resolveMarkdownFile("docs\\test-plan.md", root);
@@ -147,6 +174,19 @@ describe("resolveMarkdownFile", () => {
147174
}
148175
});
149176

177+
test("returns ambiguous in @ fallback when target exists multiple times", async () => {
178+
const root = createTempProject({
179+
"docs/plan.md": "# Plan 1",
180+
"api/plan.md": "# Plan 2",
181+
});
182+
const result = resolveMarkdownFile("@plan.md", root);
183+
expect(result.kind).toBe("ambiguous");
184+
if (result.kind === "ambiguous") {
185+
expect(result.input).toBe("@plan.md");
186+
expect(result.matches).toHaveLength(2);
187+
}
188+
});
189+
150190
// Ignored directories
151191

152192
test("skips node_modules", async () => {
@@ -190,6 +230,12 @@ describe("resolveMarkdownFile", () => {
190230
expect(result.kind).toBe("not_found");
191231
});
192232

233+
test("returns not_found for @ path that cannot be resolved", async () => {
234+
const root = createTempProject();
235+
const result = resolveMarkdownFile("@nope.md", root);
236+
expect(result).toEqual({ kind: "not_found", input: "@nope.md" });
237+
});
238+
193239
test("handles deeply nested files", async () => {
194240
const root = createTempProject({
195241
"a/b/c/d/deep.md": "# Deep",

packages/shared/resolve-file.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,20 @@ function isSearchableMarkdownPath(input: string): boolean {
7878
return MARKDOWN_PATH_REGEX.test(input.trim());
7979
}
8080

81+
function stripWrappingQuotes(input: string): string {
82+
if (input.length < 2) {
83+
return input;
84+
}
85+
86+
const first = input[0];
87+
const last = input[input.length - 1];
88+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
89+
return input.slice(1, -1);
90+
}
91+
92+
return input;
93+
}
94+
8195
/** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */
8296
function hasWindowsDriveLetter(input: string): boolean {
8397
return /^[a-zA-Z]:[/\\]/.test(input);
@@ -155,12 +169,10 @@ export function isAbsoluteMarkdownPath(
155169
* @param input - User-provided path (absolute, relative, or bare filename)
156170
* @param projectRoot - Project root directory to search within
157171
*/
158-
export function resolveMarkdownFile(
172+
function resolveMarkdownFileCore(
159173
input: string,
160174
projectRoot: string,
161175
): ResolveResult {
162-
// Trim whitespace/CR that may leak from Windows shell pipelines
163-
input = input.trim();
164176
const normalizedInput = normalizeMarkdownPathInput(input);
165177
const searchInput = normalizeSeparators(normalizedInput);
166178
const isBareFilename = !searchInput.includes("/");
@@ -218,6 +230,47 @@ export function resolveMarkdownFile(
218230
return { kind: "not_found", input };
219231
}
220232

233+
/**
234+
* Resolve a markdown file path within a project root.
235+
*
236+
* @param input - User-provided path (absolute, relative, or bare filename)
237+
* @param projectRoot - Project root directory to search within
238+
*/
239+
export function resolveMarkdownFile(
240+
input: string,
241+
projectRoot: string,
242+
): ResolveResult {
243+
const originalInput = input.trim();
244+
const unquotedInput = stripWrappingQuotes(originalInput);
245+
246+
const primary = resolveMarkdownFileCore(unquotedInput, projectRoot);
247+
if (primary.kind === "found") {
248+
return primary;
249+
}
250+
if (primary.kind === "ambiguous") {
251+
return { ...primary, input: originalInput };
252+
}
253+
254+
if (!unquotedInput.startsWith("@")) {
255+
return { kind: "not_found", input: originalInput };
256+
}
257+
258+
const normalizedInput = unquotedInput.replace(/^@+/, "");
259+
if (!normalizedInput) {
260+
return { kind: "not_found", input: originalInput };
261+
}
262+
263+
const fallback = resolveMarkdownFileCore(normalizedInput, projectRoot);
264+
if (fallback.kind === "found") {
265+
return fallback;
266+
}
267+
if (fallback.kind === "ambiguous") {
268+
return { ...fallback, input: originalInput };
269+
}
270+
271+
return { kind: "not_found", input: originalInput };
272+
}
273+
221274
/**
222275
* Check if a directory contains at least one markdown file.
223276
* Used to validate folder annotation targets.

0 commit comments

Comments
 (0)