121 lines · 3.8 KB · TypeScript
RawDownload
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<ComposerInlineToken>;} 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<ComposerInlineToken> {  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);}