export type ComposerInlineToken = | { readonly type: "mention"; readonly value: string; readonly source: string; readonly start: number; readonly end: number; } | { readonly type: "skill"; readonly value: string; readonly source: string; readonly start: number; readonly end: number; }; export interface CollectComposerInlineTokensOptions { readonly preserveTrailingFrom?: ReadonlyArray; } const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; // Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package. const SCOPED_PACKAGE_REFERENCE_REGEX = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; function collectMentionTokens(text: string): ComposerInlineToken[] { const matches: ComposerInlineToken[] = []; for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { const fullMatch = match[0]; const prefix = match[1] ?? ""; const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); const encodedPath = match[3] ?? ""; let path = encodedPath; try { path = decodeURIComponent(encodedPath); } catch { // Preserve malformed source rather than dropping a user-authored token. } const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); if (!path || hasExternalScheme || label !== basename) { continue; } const start = (match.index ?? 0) + prefix.length; const end = start + fullMatch.length - prefix.length; matches.push({ type: "mention", value: path, source: text.slice(start, end), start, end, }); } for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { const fullMatch = match[0]; const prefix = match[1] ?? ""; const quotedPath = match[2]; const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) { continue; } const start = (match.index ?? 0) + prefix.length; const end = start + fullMatch.length - prefix.length; matches.push({ type: "mention", value: path, source: text.slice(start, end), start, end, }); } return matches; } export function collectComposerInlineTokens( text: string, options: CollectComposerInlineTokensOptions = {}, ): ReadonlyArray { const matches = collectMentionTokens(text); for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { const fullMatch = match[0]; const prefix = match[1] ?? ""; const value = match[2] ?? ""; if (!value) { continue; } const start = (match.index ?? 0) + prefix.length; const end = start + fullMatch.length - prefix.length; matches.push({ type: "skill", value, source: text.slice(start, end), start, end, }); } for (const token of options.preserveTrailingFrom ?? []) { if ( token.end === text.length && text.slice(token.start, token.end) === token.source && !matches.some( (match) => match.type === token.type && match.start === token.start && match.end === token.end, ) ) { matches.push(token); } } return [...matches].sort((left, right) => left.start - right.start); }