| 1 | export 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 | |
| 17 | export 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 | */ |
| 28 | const 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. |
| 33 | const 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 | */ |
| 39 | export const SKILL_MENTION_PATTERN = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s|$)`, "gu"); |
| 40 | const 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 | */ |
| 51 | const MAX_FILE_LINK_LABEL_LENGTH = 512; |
| 52 | const FILE_LINK_TOKEN_REGEX = new RegExp( |
| 53 | `(^|\\s)\\[((?:\\\\.|[^\\]\\\\]){0,${MAX_FILE_LINK_LABEL_LENGTH}})\\]\\(([^)\\s]+)\\)(?=\\s)`, |
| 54 | "g", |
| 55 | ); |
| 56 | const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; |
| 57 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; |
| 58 | // Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package. |
| 59 | const SCOPED_PACKAGE_REFERENCE_REGEX = |
| 60 | /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; |
| 61 | |
| 62 | function 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 | |
| 115 | export 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 | } |