| 1 | // Candidates the answer body names but never declared with `present`. |
| 2 | // |
| 3 | // Extraction runs on the parsed blocks — inline code and local-path links — |
| 4 | // never on raw source, so a command, a regular expression, or the inside of a |
| 5 | // fenced block cannot become a file reference. Nothing here decides whether a |
| 6 | // file exists: every candidate is verified by the host before it becomes |
| 7 | // clickable, and a candidate that fails verification keeps its original text. |
| 8 | |
| 9 | import type { Element, RootContent } from "hast"; |
| 10 | import { fileIdentity, isAbsolutePath, pathBasename, pathExtension } from "./filePaths"; |
| 11 | import { localPathFromHref } from "./localFileUrl"; |
| 12 | import { unescapeRefPath } from "./refToken"; |
| 13 | |
| 14 | export interface ChatFileCandidate { |
| 15 | /** Renderer identity; also the key the host echoes back. */ |
| 16 | key: string; |
| 17 | /** The path exactly as the answer spelled it. */ |
| 18 | path: string; |
| 19 | } |
| 20 | |
| 21 | /** A code span is only worth verifying when it carries a directory of its own. */ |
| 22 | const DIRECTORY_SEPARATOR_RE = /[\\/]/; |
| 23 | |
| 24 | function isCodeSpanCandidate(text: string): boolean { |
| 25 | const value = unescapeRefPath(text.trim()); |
| 26 | if (!value || value.length > 4096 || value.includes("\n")) return false; |
| 27 | if (isAbsolutePath(value)) return true; |
| 28 | // A bare filename matches the turn's known file set instead, so it is never |
| 29 | // sent to the host: resolving it would guess at a file the answer never named. |
| 30 | return DIRECTORY_SEPARATOR_RE.test(value) && pathBasename(value) !== ""; |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * An explicit Markdown link may name a local file directly (`[x](/tmp/a.pdf)`) |
| 35 | * as well as through a `file://` URL. Only an absolute path with a file |
| 36 | * extension qualifies: a bare `/docs/getting-started` style site path must not |
| 37 | * become a file reference. |
| 38 | */ |
| 39 | function hrefLocalPath(href: unknown): string | undefined { |
| 40 | if (typeof href !== "string") return undefined; |
| 41 | const decoded = localPathFromHref(href); |
| 42 | if (decoded) return decoded; |
| 43 | const trimmed = href.trim(); |
| 44 | if (!isAbsolutePath(trimmed) || !pathExtension(trimmed)) return undefined; |
| 45 | return trimmed; |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * Walks one parsed block. `code` elements are inline spans only — a fenced |
| 50 | * block arrives as a `<pre><code>` pair and is skipped whole. |
| 51 | */ |
| 52 | function collectFromNode(node: RootContent, out: ChatFileCandidate[], seen: Set<string>): void { |
| 53 | if (node.type === "element") { |
| 54 | const element = node as Element; |
| 55 | const tag = element.tagName.toLowerCase(); |
| 56 | if (tag === "pre") return; |
| 57 | if (tag === "code") { |
| 58 | const text = textOf(element).trim(); |
| 59 | if (text && isCodeSpanCandidate(text)) push(out, seen, unescapeRefPath(text)); |
| 60 | return; |
| 61 | } |
| 62 | if (tag === "a") { |
| 63 | const decoded = hrefLocalPath(element.properties?.href); |
| 64 | if (decoded) push(out, seen, decoded); |
| 65 | // A link's own text is not scanned: it is a label, not a path. |
| 66 | return; |
| 67 | } |
| 68 | } |
| 69 | if ("children" in node && Array.isArray(node.children)) { |
| 70 | for (const child of node.children) collectFromNode(child as RootContent, out, seen); |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | function push(out: ChatFileCandidate[], seen: Set<string>, raw: string): void { |
| 75 | const path = raw.trim(); |
| 76 | if (!path || path.length > 4096) return; |
| 77 | const identity = fileIdentity(path); |
| 78 | if (!identity || seen.has(identity)) return; |
| 79 | seen.add(identity); |
| 80 | out.push({ key: identity, path }); |
| 81 | } |
| 82 | |
| 83 | function textOf(element: Element): string { |
| 84 | let text = ""; |
| 85 | for (const child of element.children ?? []) { |
| 86 | if (child.type === "text") text += child.value; |
| 87 | else if (child.type === "element") text += textOf(child as Element); |
| 88 | } |
| 89 | return text; |
| 90 | } |
| 91 | |
| 92 | /** Deduplicated candidates for one parsed block list, in document order. */ |
| 93 | export function chatFileCandidates(blocks: readonly { children: RootContent[] }[]): ChatFileCandidate[] { |
| 94 | const out: ChatFileCandidate[] = []; |
| 95 | const seen = new Set<string>(); |
| 96 | for (const block of blocks) { |
| 97 | for (const child of block.children) collectFromNode(child, out, seen); |
| 98 | } |
| 99 | return out; |
| 100 | } |
| 101 |