1import type { ComposerContextId, ComposerContextKind } from "@t3tools/contracts";
2import {
3 collectComposerContextReferences,
4 formatComposerContextReference,
5 replaceComposerContextReferences,
6} from "@t3tools/shared/composerContextReferences";
7
8/**
9 * Prompt-string operations on inline context references, independent of kind. Each context
10 * kind's draft record supplies a `ComposerContextReference` (kind, id, label); the prompt owns
11 * where the reference sits.
12 */
13
14export interface ComposerContextReference {
15 kind: ComposerContextKind;
16 contextId: string;
17 label: string;
18}
19
20const CONTEXT_ID_PATTERN = /^[a-z0-9_-]{1,128}$/i;
21
22/**
23 * Two independent FNV-1a passes, one forward and one with a different offset basis over the
24 * reversed input. 64 bits of digest, because the slug in front of it is truncated: two long
25 * producer ids that agree on their first 48 characters are told apart by this alone.
26 */
27function fnv1a64(value: string): string {
28 let forward = 0x811c9dc5;
29 let reverse = 0x9dc5811c;
30 for (let index = 0; index < value.length; index += 1) {
31 forward ^= value.charCodeAt(index);
32 forward = Math.imul(forward, 0x01000193) >>> 0;
33 reverse ^= value.charCodeAt(value.length - 1 - index);
34 reverse = Math.imul(reverse, 0x01000193) >>> 0;
35 }
36 return `${forward.toString(16).padStart(8, "0")}${reverse.toString(16).padStart(8, "0")}`;
37}
38
39/**
40 * Producers mint ids in their own grammars (`pull-request-finding:42`,
41 * `file-comment-<ms>-<n>`). A context id must survive a Markdown link and the wire
42 * schema, so anything outside `[a-z0-9_-]` is folded into a readable slug plus a hash of
43 * the original. Deterministic, so the same producer id always maps to the same context id.
44 */
45export function toComposerContextId(producerId: string): ComposerContextId {
46 if (CONTEXT_ID_PATTERN.test(producerId)) return producerId as ComposerContextId;
47 const slug = producerId
48 .replace(/[^a-z0-9_-]+/gi, "-")
49 .replace(/^-+|-+$/g, "")
50 .slice(0, 48);
51 return `${slug || "ctx"}-${fnv1a64(producerId)}` as ComposerContextId;
52}
53
54/** Raw producer IDs are always scoped, even when they already start with the kind name. */
55export function toKindScopedComposerContextId(
56 kind: ComposerContextKind,
57 producerId: string,
58): ComposerContextId {
59 const prefix = `${kind}_`;
60 return toComposerContextId(`${prefix}${producerId}`);
61}
62
63/** Only for importing canonical records: undo one namespace before rebuilding a draft. */
64export function producerIdFromComposerContextId(
65 kind: ComposerContextKind,
66 contextId: string,
67): string {
68 const prefix = `${kind}_`;
69 return contextId.startsWith(prefix) ? contextId.slice(prefix.length) : contextId;
70}
71
72export function formatInlineContextReference(reference: ComposerContextReference): string {
73 return formatComposerContextReference({
74 kind: reference.kind,
75 contextId: reference.contextId as ComposerContextId,
76 label: reference.label,
77 });
78}
79
80/** Payload ids referenced by the prompt, once each in first-occurrence order. */
81export function collectInlineContextIds(prompt: string): string[] {
82 return Array.from(
83 new Set(collectComposerContextReferences(prompt).map((occurrence) => occurrence.contextId)),
84 );
85}
86
87/** Prose without any context link, for "does this prompt say anything" checks. */
88export function stripInlineContextReferences(prompt: string): string {
89 return replaceComposerContextReferences(prompt, () => "");
90}
91
92function isBoundaryWhitespace(char: string | undefined): boolean {
93 return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r";
94}
95
96/** Replaces a selected range with chips, keeping word boundaries and the trailing caret space. */
97export function inlineContextReferenceReplacement(
98 prompt: string,
99 selection: { start: number; end: number },
100 references: ReadonlyArray<ComposerContextReference>,
101): { start: number; end: number; text: string } {
102 const start = Math.max(0, Math.min(prompt.length, Math.floor(selection.start)));
103 const end = Math.max(start, Math.min(prompt.length, Math.floor(selection.end)));
104 const needsLeadingSpace = !isBoundaryWhitespace(prompt[start - 1]);
105 return {
106 start,
107 end: prompt[end] === " " ? end + 1 : end,
108 text: `${needsLeadingSpace ? " " : ""}${references.map(formatInlineContextReference).join(" ")} `,
109 };
110}
111
112/** Inserts a link at the cursor, padding with spaces only where words would otherwise join. */
113export function insertInlineContextReference(
114 prompt: string,
115 cursorInput: number,
116 reference: ComposerContextReference,
117): { prompt: string; cursor: number } {
118 const edit = inlineContextReferenceReplacement(prompt, { start: cursorInput, end: cursorInput }, [
119 reference,
120 ]);
121 return {
122 prompt: `${prompt.slice(0, edit.start)}${edit.text}${prompt.slice(edit.end)}`,
123 cursor: edit.start + edit.text.length,
124 };
125}
126
127/** Appends a link at the end of the prompt: the fallback when the caret is unknown. */
128export function appendInlineContextReference(
129 prompt: string,
130 reference: ComposerContextReference,
131): string {
132 return insertInlineContextReference(prompt, prompt.length, reference).prompt;
133}
134
135/** Removes every reference to `contextId` plus one neighbouring space each so words don't join. */
136export function removeInlineContextReference(
137 prompt: string,
138 contextId: string,
139): { prompt: string; cursor: number } {
140 const occurrences = collectComposerContextReferences(prompt).filter(
141 (candidate) => candidate.contextId === contextId,
142 );
143 if (occurrences.length === 0) return { prompt, cursor: prompt.length };
144 let result = prompt;
145 let cursor = prompt.length;
146 for (const occurrence of occurrences.reverse()) {
147 let { start, end } = occurrence;
148 if (result[end] === " ") end += 1;
149 else if (result[start - 1] === " ") start -= 1;
150 result = `${result.slice(0, start)}${result.slice(end)}`;
151 cursor = start;
152 }
153 if (cursor >= result.length) {
154 result = result.trimEnd();
155 cursor = result.length;
156 }
157 return { prompt: result, cursor };
158}
159
160/** Appends links for records the prompt does not reference yet, in the order given. */
161export function ensureInlineContextReferences(
162 prompt: string,
163 references: ReadonlyArray<ComposerContextReference>,
164): string {
165 const referenced = new Set(collectInlineContextIds(prompt));
166 let result = prompt;
167 for (const reference of references) {
168 if (referenced.has(reference.contextId)) continue;
169 referenced.add(reference.contextId);
170 result = appendInlineContextReference(result, reference);
171 }
172 return result;
173}