Skip to content
30 linesCodeBlameRaw
1/** The most suggestions shown at once. */
2export const MAX_SUGGESTIONS = 5;
3
4/** What could complete the `@…` before the caret: where it starts, and the handles that fit, best first. */
5export type MentionMatch = { start: number; options: string[] };
6
7/**
8 * The `@…` being typed just before the caret and what could complete it:
9 * the `handles` given (such as `@acme/backend`) whose name, or a team's
10 * own part after the `/`, starts with what is typed. At most
11 * `MAX_SUGGESTIONS`; nothing once one is typed in full. Null when the
12 * caret is not in a mention or nothing fits.
13 */
14export function mentionSuggestions(text: string, caret: number, handles: readonly string[] = []): MentionMatch | null {
15 const match = /(?:^|[\s(])@([\w./-]{0,100})$/.exec(text.slice(0, caret));
16 if (!match) return null;
17 const typed = match[1].toLowerCase();
18 const start = caret - match[1].length - 1;
19 const names = [...new Set(handles.map((handle) => (handle.startsWith("@") ? handle : `@${handle}`)))];
20 if (names.some((name) => name.slice(1).toLowerCase() === typed)) return null;
21 const whole: string[] = [];
22 const part: string[] = [];
23 for (const name of names) {
24 const bare = name.slice(1).toLowerCase();
25 if (bare.startsWith(typed)) whole.push(name);
26 else if (typed && !typed.includes("/") && bare.split("/")[1]?.startsWith(typed)) part.push(name);
27 }
28 const options = [...whole, ...part].slice(0, MAX_SUGGESTIONS);
29 return options.length > 0 ? { start, options } : null;
30}