Skip to content
Source
Sign inSign up

file-link-grading.ts

129 lines5.0 KB

1import * as Schema from "effect/Schema";
2import { splitFilePathPosition } from "@t3tools/shared/fileLinks";
3import {
4 audioMimeTypeFromExtension,
5 mediaMimeTypeFromExtension,
6} from "@t3tools/shared/mediaPreview";
7import {
8 extractInlineCodeSpans,
9 extractMarkdownLinkHrefs,
10 inlineCodeFilePathCandidate,
11 isMarkdownFileLinkLabel,
12 normalizeMarkdownLinkDestination,
13 parseMarkdownFileLink,
14 resolveMarkdownFileLinkTarget,
15} from "@t3tools/shared/markdownLinks";
16import {
17 needsWorkspaceBasenameLookup,
18 pickWorkspaceLinkMatch,
19} from "../../packages/client-runtime/src/workspaceBasenameLookup.ts";
20​
21export const FileLinksExpect = Schema.Struct({
22 /** The final answer has at least this many file pills that open a world file. */
23 minWorking: Schema.Number,
24});
25​
26/** Resolves `.` and `..` segments, which the file system resolves when the client opens the path. */
27const normalizeAbsolutePath = (path: string) => {
28 const segments: Array<string> = [];
29 for (const segment of path.split("/")) {
30 if (segment === "..") segments.pop();
31 else if (segment !== "" && segment !== ".") segments.push(segment);
32 }
33 return `/${segments.join("/")}`;
34};
35​
36/** Mobile opens these in a preview at the literal path, with no workspace lookup. */
37const opensAsPreview = (path: string) => {
38 const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
39 return (
40 extension === ".pdf" ||
41 mediaMimeTypeFromExtension(extension) !== null ||
42 audioMimeTypeFromExtension(extension) !== null
43 );
44};
45​
46/** The client opens an exact basename match, or a case-insensitive one when only one exists. */
47const uniqueBasename = (name: string, entries: ReadonlyArray<{ readonly path: string }>) => {
48 const basenames = entries.map((entry) => entry.path.split("/").at(-1) ?? "");
49 const exact = basenames.filter((basename) => basename === name).length;
50 return (
51 exact === 1 ||
52 (exact === 0 &&
53 basenames.filter((basename) => basename.toLowerCase() === name.toLowerCase()).length === 1)
54 );
55};
56​
57/**
58 * Chat turns every file-shaped markdown link and inline code span into a pill resolved against
59 * the thread's cwd. A pill is broken when it lands on no world file or directory and the client's
60 * on-click lookup (basename, repeated prefix, unique suffix) finds no one file under the cwd.
61 */
62export const fileLinkPills = (
63 finalText: string,
64 cwd: string,
65 worldFiles: ReadonlyArray<string>,
66) => {
67 const files = worldFiles.map((file) => (file.startsWith("/") ? file : `${cwd}/${file}`));
68 // A link whose label names its own file renders as one pill, so code in that label is no pill.
69 // Fenced blocks render as code, never as pills.
70 const prose = finalText.replace(/```[\s\S]*?(?:```|$)/g, "");
71 const withoutFileLabels = prose.replace(
72 /\[([^\]\n]*)\]\(([^)\s]+)\)/g,
73 (link, label: string, href: string) =>
74 isMarkdownFileLinkLabel(label.replaceAll("`", ""), normalizeMarkdownLinkDestination(href))
75 ? `[](${href})`
76 : link,
77 );
78 const sources = [
79 // A link written inside inline code renders as code.
80 ...extractMarkdownLinkHrefs(prose.replace(/`[^`\n]+`/g, "")),
81 ...extractInlineCodeSpans(withoutFileLabels).flatMap(
82 (span) => inlineCodeFilePathCandidate(span) ?? [],
83 ),
84 ];
85 return [...new Set(sources)].flatMap((source) => {
86 const target = resolveMarkdownFileLinkTarget(source, cwd);
87 if (target === null) return [];
88 const path = normalizeAbsolutePath(splitFilePathPosition(target).path);
89 // System paths such as /usr/bin/node are not what these cases test; the workspace always is.
90 if (!path.startsWith(`${cwd}/`) && !/^\/(?:home|Users)\//.test(path)) return [];
91 const relative = parseMarkdownFileLink(source)?.path ?? path;
92 const entries = files.flatMap((file) =>
93 file.startsWith(`${cwd}/`)
94 ? [{ path: file.slice(cwd.length + 1), kind: "file" as const }]
95 : [],
96 );
97 const works =
98 files.some((file) => file === path || file.startsWith(`${path}/`)) ||
99 (!relative.startsWith("/") &&
100 !opensAsPreview(path) &&
101 // The client opens the first of several same-named files, which may be the wrong one.
102 (needsWorkspaceBasenameLookup(relative)
103 ? uniqueBasename(relative, entries)
104 : pickWorkspaceLinkMatch(relative, cwd, entries) !== null));
105 return [{ source, path, works }];
106 });
107};
108​
109export const gradeFileLinks = (
110 expect: typeof FileLinksExpect.Type | undefined,
111 finalText: string | undefined,
112 cwd: string | undefined,
113 worldFiles: ReadonlyArray<string>,
114) => {
115 if (expect === undefined) return [];
116 const pills = cwd === undefined ? [] : fileLinkPills(finalText ?? "", cwd, worldFiles);
117 const broken = pills.filter((pill) => !pill.works).map((pill) => pill.source);
118 const working = pills.length - broken.length;
119 return [
120 {
121 check: `fileLinks none broken${broken.length ? `: ${broken.join(", ")}` : ""}`,
122 pass: finalText !== undefined && cwd !== undefined && broken.length === 0,
123 },
124 {
125 check: `fileLinks >= ${expect.minWorking} working (${working})`,
126 pass: working >= expect.minWorking,
127 },
128 ];
129};