1export type ComposerInlineToken =
2 | {
3 readonly type: "mention";
4 readonly value: string;
5 readonly source: string;
6 readonly start: number;
7 readonly end: number;
8 }
9 | {
10 readonly type: "skill";
11 readonly value: string;
12 readonly source: string;
13 readonly start: number;
14 readonly end: number;
15 };
16
17export interface CollectComposerInlineTokensOptions {
18 readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>;
19}
20
21/**
22 * A skill name may start with a digit, but compact monetary amounts and
23 * numeric expressions like "$20", "$20k", "$100M", and "$1e6" must stay prose:
24 * the composer chips any matched `$name` token, known or not. Tokens beginning
25 * with digits must not match numbers with currency/exponent suffixes, and must
26 * contain at least one letter. Any currency symbol is accepted as the sigil.
27 */
28const SKILL_MENTION_SOURCE =
29 /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)/u
30 .source;
31// While typing, a token only becomes a chip once a delimiter follows it, so a
32// half-typed name at the end of the text stays plain.
33const SKILL_TOKEN_REGEX = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s)`, "gu");
34/**
35 * Skill mentions in a sent prompt, which may also end at the end of the text.
36 * Group 1 is the leading delimiter and group 2 the skill name. The pattern is
37 * global, so use it with `matchAll` or `replace`, not `test` or `exec`.
38 */
39export const SKILL_MENTION_PATTERN = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s|$)`, "gu");
40const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
41/**
42 * The label body is bounded rather than `*`. Unbounded, every whitespace in
43 * the composer is a candidate start: the engine scans the rest of the text for
44 * a closing `]`, fails, and rescans from the next whitespace — quadratic on
45 * input like " [[[[[…". A cap makes each attempt constant-bounded.
46 *
47 * Only a basename ever survives the `label !== basename` check below, so this
48 * cannot reject a link a user could meaningfully write; the longest filename
49 * any common filesystem allows is 255.
50 */
51const MAX_FILE_LINK_LABEL_LENGTH = 512;
52const FILE_LINK_TOKEN_REGEX = new RegExp(
53 `(^|\\s)\\[((?:\\\\.|[^\\]\\\\]){0,${MAX_FILE_LINK_LABEL_LENGTH}})\\]\\(([^)\\s]+)\\)(?=\\s)`,
54 "g",
55);
56const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
57const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
58// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package.
59const SCOPED_PACKAGE_REFERENCE_REGEX =
60 /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/;
61
62function collectMentionTokens(text: string): ComposerInlineToken[] {
63 const matches: ComposerInlineToken[] = [];
64
65 for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) {
66 const fullMatch = match[0];
67 const prefix = match[1] ?? "";
68 const label = (match[2] ?? "").replace(/\\(.)/g, "$1");
69 const encodedPath = match[3] ?? "";
70 let path = encodedPath;
71 try {
72 path = decodeURIComponent(encodedPath);
73 } catch {
74 // Preserve malformed source rather than dropping a user-authored token.
75 }
76 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
77 const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
78 const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path);
79 if (!path || hasExternalScheme || label !== basename) {
80 continue;
81 }
82 const start = (match.index ?? 0) + prefix.length;
83 const end = start + fullMatch.length - prefix.length;
84 matches.push({
85 type: "mention",
86 value: path,
87 source: text.slice(start, end),
88 start,
89 end,
90 });
91 }
92
93 for (const match of text.matchAll(MENTION_TOKEN_REGEX)) {
94 const fullMatch = match[0];
95 const prefix = match[1] ?? "";
96 const quotedPath = match[2];
97 const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? "");
98 if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) {
99 continue;
100 }
101 const start = (match.index ?? 0) + prefix.length;
102 const end = start + fullMatch.length - prefix.length;
103 matches.push({
104 type: "mention",
105 value: path,
106 source: text.slice(start, end),
107 start,
108 end,
109 });
110 }
111
112 return matches;
113}
114
115export function collectComposerInlineTokens(
116 text: string,
117 options: CollectComposerInlineTokensOptions = {},
118): ReadonlyArray<ComposerInlineToken> {
119 const matches = collectMentionTokens(text);
120
121 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
122 const fullMatch = match[0];
123 const prefix = match[1] ?? "";
124 const value = match[2] ?? "";
125 if (!value) {
126 continue;
127 }
128 const start = (match.index ?? 0) + prefix.length;
129 const end = start + fullMatch.length - prefix.length;
130 matches.push({
131 type: "skill",
132 value,
133 source: text.slice(start, end),
134 start,
135 end,
136 });
137 }
138
139 for (const token of options.preserveTrailingFrom ?? []) {
140 if (
141 token.end === text.length &&
142 text.slice(token.start, token.end) === token.source &&
143 !matches.some(
144 (match) =>
145 match.type === token.type && match.start === token.start && match.end === token.end,
146 )
147 ) {
148 matches.push(token);
149 }
150 }
151
152 return [...matches].sort((left, right) => left.start - right.start);
153}