refactor: cut cyclomatic complexity of five hot spots and pin their behavior with tests
Risk labels, menu callback parsing, settings callbacks, table cell width and markdown link targets now use tables and small helpers (CC <= 10).
This commit is contained in:
1 parent
bf4a2021e8
commit
6a6e75fe32
8 files changed
+648
-294
No files matched your search
+110
-66
@@ -223,76 +223,120 @@ function hasSecretTouchingText(parts: string[]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandTemplateRiskContext {
|
||||
command: string;
|
||||
args: string[];
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
const COMMAND_TEMPLATE_NETWORK_EXECUTABLES = [
|
||||
"curl",
|
||||
"wget",
|
||||
"ssh",
|
||||
"scp",
|
||||
"sftp",
|
||||
"rsync",
|
||||
"nc",
|
||||
"ncat",
|
||||
"telnet",
|
||||
"ftp",
|
||||
];
|
||||
|
||||
const COMMAND_TEMPLATE_PLATFORM_EXECUTABLES = [
|
||||
"systemctl",
|
||||
"launchctl",
|
||||
"osascript",
|
||||
"open",
|
||||
"xdg-open",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
"cmd.exe",
|
||||
"apt",
|
||||
"apt-get",
|
||||
"dnf",
|
||||
"yum",
|
||||
"brew",
|
||||
"pacman",
|
||||
"apk",
|
||||
"xclip",
|
||||
"wl-copy",
|
||||
];
|
||||
|
||||
const COMMAND_TEMPLATE_RISK_RULES: Array<
|
||||
[CommandTemplateRiskLabel, (context: CommandTemplateRiskContext) => boolean]
|
||||
> = [
|
||||
["risk.shell", ({ command }) => ["bash", "sh", "zsh", "fish"].includes(command)],
|
||||
[
|
||||
"risk.eval",
|
||||
({ command, args }) =>
|
||||
(["bash", "sh", "zsh", "fish"].includes(command) &&
|
||||
hasAnyFlag(args, ["-c"])) ||
|
||||
(["node", "deno", "bun"].includes(command) &&
|
||||
hasAnyFlag(args, ["-e", "--eval"])) ||
|
||||
(["python", "python3", "perl", "ruby"].includes(command) &&
|
||||
hasAnyFlag(args, ["-c", "-e"])),
|
||||
],
|
||||
[
|
||||
"risk.destructive_fs",
|
||||
({ command, args }) =>
|
||||
command === "rm" &&
|
||||
(args.some((arg) => /^-[^-]*[rf]/.test(arg)) || hasRiskyPathArg(args)),
|
||||
],
|
||||
[
|
||||
"risk.broad_fs_write",
|
||||
({ command, args }) =>
|
||||
["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args),
|
||||
],
|
||||
[
|
||||
"risk.network",
|
||||
({ command, args }) =>
|
||||
COMMAND_TEMPLATE_NETWORK_EXECUTABLES.includes(command) ||
|
||||
(command === "git" &&
|
||||
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
|
||||
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command),
|
||||
],
|
||||
[
|
||||
"risk.external_side_effect",
|
||||
({ command, args }) =>
|
||||
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
|
||||
(command === "git" && hasAnyArg(args, ["push"])) ||
|
||||
(["npm", "pnpm", "yarn"].includes(command) &&
|
||||
hasAnyArg(args, ["publish", "login", "logout", "deprecate"])),
|
||||
],
|
||||
[
|
||||
"risk.long_running",
|
||||
({ command, args }) =>
|
||||
command === "sleep" ||
|
||||
command === "watch" ||
|
||||
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
|
||||
hasAnyArg(args, ["--watch", "--serve", "serve"]),
|
||||
],
|
||||
[
|
||||
"risk.platform_specific",
|
||||
({ command }) => COMMAND_TEMPLATE_PLATFORM_EXECUTABLES.includes(command),
|
||||
],
|
||||
[
|
||||
"risk.secret_touching",
|
||||
({ command, parts }) =>
|
||||
["pass", "gpg", "ssh-add"].includes(command) ||
|
||||
hasSecretTouchingText(parts),
|
||||
],
|
||||
];
|
||||
|
||||
function getLeafCommandTemplateRiskLabels(
|
||||
config: CommandTemplateLeafConfig,
|
||||
): CommandTemplateRiskLabel[] {
|
||||
const parts = splitCommandTemplate(config.template);
|
||||
const command = getExecutableName(parts[0]);
|
||||
const args = parts.slice(1);
|
||||
const labels = new Set<CommandTemplateRiskLabel>();
|
||||
if (["bash", "sh", "zsh", "fish"].includes(command)) {
|
||||
labels.add("risk.shell");
|
||||
if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
|
||||
}
|
||||
if (
|
||||
["node", "deno", "bun"].includes(command) &&
|
||||
hasAnyFlag(args, ["-e", "--eval"])
|
||||
) {
|
||||
labels.add("risk.eval");
|
||||
}
|
||||
if (
|
||||
["python", "python3", "perl", "ruby"].includes(command) &&
|
||||
hasAnyFlag(args, ["-c", "-e"])
|
||||
) {
|
||||
labels.add("risk.eval");
|
||||
}
|
||||
if (
|
||||
command === "rm" &&
|
||||
(args.some((arg) => /^-[^-]*r/.test(arg) || /^-[^-]*f/.test(arg)) ||
|
||||
hasRiskyPathArg(args))
|
||||
) {
|
||||
labels.add("risk.destructive_fs");
|
||||
}
|
||||
if (["mv", "cp", "rsync"].includes(command) && hasRiskyPathArg(args)) {
|
||||
labels.add("risk.broad_fs_write");
|
||||
}
|
||||
if (
|
||||
["curl", "wget", "ssh", "scp", "sftp", "rsync", "nc", "ncat", "telnet", "ftp"].includes(
|
||||
command,
|
||||
) ||
|
||||
(command === "git" &&
|
||||
hasAnyArg(args, ["clone", "fetch", "pull", "push", "ls-remote"])) ||
|
||||
["npm", "pnpm", "yarn", "pip", "cargo"].includes(command)
|
||||
) {
|
||||
labels.add("risk.network");
|
||||
}
|
||||
if (
|
||||
["gh", "glab", "hub", "kubectl", "terraform"].includes(command) ||
|
||||
(command === "git" && hasAnyArg(args, ["push"])) ||
|
||||
(["npm", "pnpm", "yarn"].includes(command) &&
|
||||
hasAnyArg(args, ["publish", "login", "logout", "deprecate"]))
|
||||
) {
|
||||
labels.add("risk.external_side_effect");
|
||||
}
|
||||
if (
|
||||
command === "sleep" ||
|
||||
command === "watch" ||
|
||||
(command === "tail" && hasAnyFlag(args, ["-f"])) ||
|
||||
hasAnyArg(args, ["--watch", "--serve", "serve"])
|
||||
) {
|
||||
labels.add("risk.long_running");
|
||||
}
|
||||
if (
|
||||
["systemctl", "launchctl", "osascript", "open", "xdg-open", "powershell", "pwsh", "cmd.exe", "apt", "apt-get", "dnf", "yum", "brew", "pacman", "apk", "xclip", "wl-copy"].includes(
|
||||
command,
|
||||
)
|
||||
) {
|
||||
labels.add("risk.platform_specific");
|
||||
}
|
||||
if (["pass", "gpg", "ssh-add"].includes(command) || hasSecretTouchingText(parts)) {
|
||||
labels.add("risk.secret_touching");
|
||||
}
|
||||
return sortRiskLabels(labels);
|
||||
const context: CommandTemplateRiskContext = {
|
||||
command: getExecutableName(parts[0]),
|
||||
args: parts.slice(1),
|
||||
parts,
|
||||
};
|
||||
return sortRiskLabels(
|
||||
COMMAND_TEMPLATE_RISK_RULES.filter(([, matches]) => matches(context)).map(
|
||||
([label]) => label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getLeafCommandTemplateWarnings(
|
||||
|
||||
+115
-107
@@ -492,122 +492,130 @@ export async function updateVoiceReplyModeSettingsMessage(
|
||||
);
|
||||
}
|
||||
|
||||
type TelegramSettingsPageUpdater = (
|
||||
deps: TelegramSettingsMenuCallbackDeps,
|
||||
) => Promise<void>;
|
||||
|
||||
const TELEGRAM_SETTINGS_PAGE_UPDATERS = new Map<
|
||||
string,
|
||||
TelegramSettingsPageUpdater
|
||||
>([
|
||||
[
|
||||
"settings:list",
|
||||
(deps) => updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry),
|
||||
],
|
||||
["settings:open:proactive", updateProactivePushSettingsMessage],
|
||||
["settings:open:draft-previews", updateDraftPreviewsSettingsMessage],
|
||||
["settings:open:rich-drafts", updateDraftPreviewsSettingsMessage],
|
||||
["settings:open:assistant-rendering", updateAssistantRenderingSettingsMessage],
|
||||
["settings:open:voice-reply", updateVoiceReplyModeSettingsMessage],
|
||||
["settings:open:time-injection", updateTimeInjectionModeSettingsMessage],
|
||||
["settings:open:time", updateTimeInjectionModeSettingsMessage],
|
||||
]);
|
||||
|
||||
/** Applies one setting value; resolves the toast text, or undefined when the value is invalid */
|
||||
type TelegramSettingsValueApplier = (
|
||||
value: string,
|
||||
deps: TelegramSettingsMenuCallbackDeps,
|
||||
) => Promise<string | undefined>;
|
||||
|
||||
async function applyVoiceReplyModeSetting(
|
||||
mode: string,
|
||||
deps: TelegramSettingsMenuCallbackDeps,
|
||||
): Promise<string | undefined> {
|
||||
if (
|
||||
mode !== "hidden" &&
|
||||
mode !== "manual" &&
|
||||
mode !== "mirror" &&
|
||||
mode !== "always"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
|
||||
await updateVoiceReplyModeSettingsMessage(deps);
|
||||
return `Voice reply mode: ${mode}`;
|
||||
}
|
||||
|
||||
async function applyTimeInjectionModeSetting(
|
||||
mode: string,
|
||||
deps: TelegramSettingsMenuCallbackDeps,
|
||||
): Promise<string | undefined> {
|
||||
const normalizedMode = mode === "off" ? "hidden" : mode;
|
||||
if (
|
||||
normalizedMode !== "hidden" &&
|
||||
normalizedMode !== "always" &&
|
||||
normalizedMode !== "interval"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
await deps.setTimeInjectionMode(normalizedMode);
|
||||
await updateTimeInjectionModeSettingsMessage(deps);
|
||||
return `Time injection: ${normalizedMode}`;
|
||||
}
|
||||
|
||||
async function applyDraftPreviewsSetting(
|
||||
value: string,
|
||||
deps: TelegramSettingsMenuCallbackDeps,
|
||||
): Promise<string | undefined> {
|
||||
if (value !== "on" && value !== "off") return undefined;
|
||||
const enabled = value === "on";
|
||||
await deps.setDraftPreviewsEnabled(enabled);
|
||||
await updateDraftPreviewsSettingsMessage(deps);
|
||||
return `Draft previews ${enabled ? "enabled" : "disabled"}`;
|
||||
}
|
||||
|
||||
async function applyAssistantRenderingSetting(
|
||||
mode: string,
|
||||
deps: TelegramSettingsMenuCallbackDeps,
|
||||
): Promise<string | undefined> {
|
||||
if (mode !== "rich" && mode !== "html") return undefined;
|
||||
await deps.setAssistantRenderingMode(mode);
|
||||
await updateAssistantRenderingSettingsMessage(deps);
|
||||
return `Rendering: ${mode}`;
|
||||
}
|
||||
|
||||
async function applyProactivePushSetting(
|
||||
value: string,
|
||||
deps: TelegramSettingsMenuCallbackDeps,
|
||||
): Promise<string | undefined> {
|
||||
if (value !== "on" && value !== "off") return undefined;
|
||||
const enabled = value === "on";
|
||||
await deps.setProactivePushEnabled(enabled);
|
||||
await updateProactivePushSettingsMessage(deps);
|
||||
return `Proactive push ${enabled ? "enabled" : "disabled"}`;
|
||||
}
|
||||
|
||||
const TELEGRAM_SETTINGS_VALUE_APPLIERS: Array<
|
||||
[prefix: string, apply: TelegramSettingsValueApplier]
|
||||
> = [
|
||||
["settings:set:voice-reply:", applyVoiceReplyModeSetting],
|
||||
["settings:set:time-injection:", applyTimeInjectionModeSetting],
|
||||
["settings:set:time:", applyTimeInjectionModeSetting],
|
||||
["settings:set:draft-previews:", applyDraftPreviewsSetting],
|
||||
["settings:set:rich-drafts:", applyDraftPreviewsSetting],
|
||||
["settings:set:assistant-rendering:", applyAssistantRenderingSetting],
|
||||
["settings:set:proactive:", applyProactivePushSetting],
|
||||
];
|
||||
|
||||
export async function handleTelegramSettingsMenuCallbackAction(
|
||||
callbackQueryId: string,
|
||||
data: string | undefined,
|
||||
deps: TelegramSettingsMenuCallbackDeps,
|
||||
): Promise<boolean> {
|
||||
if (!data?.startsWith("settings:")) return false;
|
||||
if (data === "settings:list") {
|
||||
await updateTelegramSettingsMenuMessage(deps, deps.sectionRegistry);
|
||||
const updatePage = TELEGRAM_SETTINGS_PAGE_UPDATERS.get(data);
|
||||
if (updatePage) {
|
||||
await updatePage(deps);
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return true;
|
||||
}
|
||||
if (data === "settings:open:proactive") {
|
||||
await updateProactivePushSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return true;
|
||||
}
|
||||
if (data === "settings:open:draft-previews" || data === "settings:open:rich-drafts") {
|
||||
await updateDraftPreviewsSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return true;
|
||||
}
|
||||
if (data === "settings:open:assistant-rendering") {
|
||||
await updateAssistantRenderingSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return true;
|
||||
}
|
||||
if (data === "settings:open:voice-reply") {
|
||||
await updateVoiceReplyModeSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
data === "settings:open:time-injection" ||
|
||||
data === "settings:open:time"
|
||||
) {
|
||||
await updateTimeInjectionModeSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
return true;
|
||||
}
|
||||
if (data.startsWith("settings:set:voice-reply:")) {
|
||||
const mode = data.slice("settings:set:voice-reply:".length);
|
||||
if (
|
||||
mode === "hidden" ||
|
||||
mode === "manual" ||
|
||||
mode === "mirror" ||
|
||||
mode === "always"
|
||||
) {
|
||||
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
|
||||
await updateVoiceReplyModeSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
`Voice reply mode: ${mode}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
data.startsWith("settings:set:time-injection:") ||
|
||||
data.startsWith("settings:set:time:")
|
||||
) {
|
||||
const mode = data.startsWith("settings:set:time-injection:")
|
||||
? data.slice("settings:set:time-injection:".length)
|
||||
: data.slice("settings:set:time:".length);
|
||||
const normalizedMode = mode === "off" ? "hidden" : mode;
|
||||
if (
|
||||
normalizedMode === "hidden" ||
|
||||
normalizedMode === "always" ||
|
||||
normalizedMode === "interval"
|
||||
) {
|
||||
await deps.setTimeInjectionMode(normalizedMode);
|
||||
await updateTimeInjectionModeSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
`Time injection: ${normalizedMode}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
data === "settings:set:draft-previews:on" ||
|
||||
data === "settings:set:draft-previews:off" ||
|
||||
data === "settings:set:rich-drafts:on" ||
|
||||
data === "settings:set:rich-drafts:off"
|
||||
) {
|
||||
const enabled = data.endsWith(":on");
|
||||
await deps.setDraftPreviewsEnabled(enabled);
|
||||
await updateDraftPreviewsSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
`Draft previews ${enabled ? "enabled" : "disabled"}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (data.startsWith("settings:set:assistant-rendering:")) {
|
||||
const mode = data.slice("settings:set:assistant-rendering:".length);
|
||||
if (mode === "rich" || mode === "html") {
|
||||
await deps.setAssistantRenderingMode(mode);
|
||||
await updateAssistantRenderingSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(callbackQueryId, `Rendering: ${mode}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
data === "settings:set:proactive:on" ||
|
||||
data === "settings:set:proactive:off"
|
||||
) {
|
||||
const enabled = data.endsWith(":on");
|
||||
await deps.setProactivePushEnabled(enabled);
|
||||
await updateProactivePushSettingsMessage(deps);
|
||||
await deps.answerCallbackQuery(
|
||||
callbackQueryId,
|
||||
`Proactive push ${enabled ? "enabled" : "disabled"}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
await deps.answerCallbackQuery(callbackQueryId);
|
||||
const applier = TELEGRAM_SETTINGS_VALUE_APPLIERS.find(([prefix]) =>
|
||||
data.startsWith(prefix),
|
||||
);
|
||||
const toast = applier
|
||||
? await applier[1](data.slice(applier[0].length), deps)
|
||||
: undefined;
|
||||
await deps.answerCallbackQuery(callbackQueryId, toast);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+39
-31
@@ -334,45 +334,53 @@ export type TelegramMenuCallbackAction =
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const TELEGRAM_MENU_STATUS_ACTIONS = [
|
||||
"model",
|
||||
"thinking",
|
||||
"queue",
|
||||
"settings",
|
||||
] as const;
|
||||
|
||||
const TELEGRAM_MENU_MODEL_ACTIONS = [
|
||||
"noop",
|
||||
"scope",
|
||||
"page",
|
||||
"pages",
|
||||
"open",
|
||||
"pick",
|
||||
"pick-selected",
|
||||
"scope-enable",
|
||||
"scope-disable",
|
||||
"scope-toggle",
|
||||
] as const;
|
||||
|
||||
type TelegramMenuModelAction = Extract<
|
||||
TelegramMenuCallbackAction,
|
||||
{ kind: "model" }
|
||||
>["action"];
|
||||
|
||||
export function parseTelegramMenuCallbackAction(
|
||||
data: string | undefined,
|
||||
): TelegramMenuCallbackAction {
|
||||
if (data === "menu:model" || data === "status:model") {
|
||||
return { kind: "status", action: "model" };
|
||||
}
|
||||
if (data === "menu:thinking" || data === "status:thinking") {
|
||||
return { kind: "status", action: "thinking" };
|
||||
}
|
||||
if (data === "menu:queue" || data === "status:queue") {
|
||||
return { kind: "status", action: "queue" };
|
||||
}
|
||||
if (data === "menu:settings" || data === "status:settings") {
|
||||
return { kind: "status", action: "settings" };
|
||||
}
|
||||
if (data?.startsWith("thinking:set:")) {
|
||||
if (!data) return { kind: "ignore" };
|
||||
const statusAction = TELEGRAM_MENU_STATUS_ACTIONS.find(
|
||||
(action) => data === `menu:${action}` || data === `status:${action}`,
|
||||
);
|
||||
if (statusAction) return { kind: "status", action: statusAction };
|
||||
if (data.startsWith("thinking:set:")) {
|
||||
return {
|
||||
kind: "thinking:set",
|
||||
level: data.slice("thinking:set:".length),
|
||||
};
|
||||
}
|
||||
if (data?.startsWith("model:")) {
|
||||
const [, action, value] = data.split(":");
|
||||
if (
|
||||
action === "noop" ||
|
||||
action === "scope" ||
|
||||
action === "page" ||
|
||||
action === "pages" ||
|
||||
action === "open" ||
|
||||
action === "pick" ||
|
||||
action === "pick-selected" ||
|
||||
action === "scope-enable" ||
|
||||
action === "scope-disable" ||
|
||||
action === "scope-toggle"
|
||||
) {
|
||||
return { kind: "model", action, value };
|
||||
}
|
||||
}
|
||||
return { kind: "ignore" };
|
||||
if (!data.startsWith("model:")) return { kind: "ignore" };
|
||||
const [, action, value] = data.split(":");
|
||||
const modelAction = TELEGRAM_MENU_MODEL_ACTIONS.find(
|
||||
(candidate): candidate is TelegramMenuModelAction => candidate === action,
|
||||
);
|
||||
return modelAction
|
||||
? { kind: "model", action: modelAction, value }
|
||||
: { kind: "ignore" };
|
||||
}
|
||||
|
||||
export async function handleTelegramMenuCallbackEntry(
|
||||
|
||||
+116
-90
@@ -191,81 +191,94 @@ function findMarkdownClosingBracket(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function skipMarkdownWhitespace(text: string, index: number): number {
|
||||
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Index of the first unescaped `closing` character at or after `index`, or text length */
|
||||
function findUnescapedMarkdownCharacter(
|
||||
text: string,
|
||||
index: number,
|
||||
closing: string,
|
||||
): number {
|
||||
while (
|
||||
index < text.length &&
|
||||
(isEscapedMarkdownCharacter(text, index) || text[index] !== closing)
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function parseMarkdownAngleDestination(
|
||||
text: string,
|
||||
openIndex: number,
|
||||
): { destination: string; nextIndex: number } | undefined {
|
||||
const closeIndex = findUnescapedMarkdownCharacter(text, openIndex + 1, ">");
|
||||
if (closeIndex >= text.length) return undefined;
|
||||
return {
|
||||
destination: text.slice(openIndex + 1, closeIndex).trim(),
|
||||
nextIndex: closeIndex + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMarkdownBareDestination(
|
||||
text: string,
|
||||
startIndex: number,
|
||||
): { destination: string; nextIndex: number } {
|
||||
let index = startIndex;
|
||||
let parenDepth = 0;
|
||||
while (index < text.length) {
|
||||
if (isEscapedMarkdownCharacter(text, index)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const char = text[index] ?? "";
|
||||
if (parenDepth === 0 && (char === ")" || /\s/.test(char))) break;
|
||||
if (char === "(") parenDepth += 1;
|
||||
if (char === ")") parenDepth -= 1;
|
||||
index += 1;
|
||||
}
|
||||
return { destination: text.slice(startIndex, index).trim(), nextIndex: index };
|
||||
}
|
||||
|
||||
/** Skips an optional quoted or parenthesized title; undefined when it is unterminated */
|
||||
function skipMarkdownLinkTitle(
|
||||
text: string,
|
||||
index: number,
|
||||
): number | undefined {
|
||||
const titleDelimiter = text[index];
|
||||
if (titleDelimiter !== '"' && titleDelimiter !== "'" && titleDelimiter !== "(") {
|
||||
return index;
|
||||
}
|
||||
const closingDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
|
||||
const closeIndex = findUnescapedMarkdownCharacter(
|
||||
text,
|
||||
index + 1,
|
||||
closingDelimiter,
|
||||
);
|
||||
if (closeIndex >= text.length) return undefined;
|
||||
return skipMarkdownWhitespace(text, closeIndex + 1);
|
||||
}
|
||||
|
||||
function parseMarkdownLinkTarget(
|
||||
text: string,
|
||||
openParenIndex: number,
|
||||
): { destination: string; endIndex: number } | undefined {
|
||||
let index = openParenIndex + 1;
|
||||
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
if (index >= text.length) return undefined;
|
||||
let destination = "";
|
||||
if (text[index] === "<") {
|
||||
const destinationStart = index + 1;
|
||||
index += 1;
|
||||
while (index < text.length) {
|
||||
if (!isEscapedMarkdownCharacter(text, index) && text[index] === ">") {
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if (index >= text.length) return undefined;
|
||||
destination = text.slice(destinationStart, index).trim();
|
||||
index += 1;
|
||||
} else {
|
||||
const destinationStart = index;
|
||||
let parenDepth = 0;
|
||||
while (index < text.length) {
|
||||
if (isEscapedMarkdownCharacter(text, index)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const char = text[index] ?? "";
|
||||
if (/\s/.test(char) && parenDepth === 0) break;
|
||||
if (char === "(") {
|
||||
parenDepth += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ")") {
|
||||
if (parenDepth === 0) break;
|
||||
parenDepth -= 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
destination = text.slice(destinationStart, index).trim();
|
||||
}
|
||||
if (!destination) return undefined;
|
||||
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
if (
|
||||
index < text.length &&
|
||||
(text[index] === '"' || text[index] === "'" || text[index] === "(")
|
||||
) {
|
||||
const titleDelimiter = text[index] ?? '"';
|
||||
const closingTitleDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
|
||||
index += 1;
|
||||
while (index < text.length) {
|
||||
if (
|
||||
!isEscapedMarkdownCharacter(text, index) &&
|
||||
text[index] === closingTitleDelimiter
|
||||
) {
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if (index >= text.length) return undefined;
|
||||
index += 1;
|
||||
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (text[index] !== ")") return undefined;
|
||||
return { destination, endIndex: index };
|
||||
const destinationIndex = skipMarkdownWhitespace(text, openParenIndex + 1);
|
||||
if (destinationIndex >= text.length) return undefined;
|
||||
const parsed =
|
||||
text[destinationIndex] === "<"
|
||||
? parseMarkdownAngleDestination(text, destinationIndex)
|
||||
: parseMarkdownBareDestination(text, destinationIndex);
|
||||
if (!parsed?.destination) return undefined;
|
||||
const titleIndex = skipMarkdownWhitespace(text, parsed.nextIndex);
|
||||
const endIndex = skipMarkdownLinkTitle(text, titleIndex);
|
||||
if (endIndex === undefined || text[endIndex] !== ")") return undefined;
|
||||
return { destination: parsed.destination, endIndex };
|
||||
}
|
||||
|
||||
function isSupportedMarkdownLinkDestination(destination: string): boolean {
|
||||
@@ -715,27 +728,40 @@ function isTelegramTableEmojiGrapheme(grapheme: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Inclusive code point ranges that occupy two terminal columns */
|
||||
const TELEGRAM_TABLE_WIDE_CODE_POINT_RANGES: ReadonlyArray<
|
||||
readonly [number, number]
|
||||
> = [
|
||||
[0x1100, 0x115f],
|
||||
[0x2329, 0x232a],
|
||||
[0x2e80, 0xa4cf],
|
||||
[0xac00, 0xd7a3],
|
||||
[0xf900, 0xfaff],
|
||||
[0xfe10, 0xfe19],
|
||||
[0xfe30, 0xfe6f],
|
||||
[0xff00, 0xff60],
|
||||
[0xffe0, 0xffe6],
|
||||
];
|
||||
|
||||
function isTelegramTableZeroWidthCodePoint(
|
||||
codePoint: number,
|
||||
char: string,
|
||||
): boolean {
|
||||
return (
|
||||
codePoint < 32 ||
|
||||
codePoint === 0x200d ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
||||
/\p{Mark}/u.test(char)
|
||||
);
|
||||
}
|
||||
|
||||
function getTelegramTableCodePointWidth(char: string): number {
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
if (codePoint === 0 || codePoint < 32) return 0;
|
||||
if (/\p{Mark}/u.test(char)) return 0;
|
||||
if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) || codePoint === 0x200d)
|
||||
return 0;
|
||||
if (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
||||
codePoint === 0x2329 ||
|
||||
codePoint === 0x232a ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
||||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
if (isTelegramTableZeroWidthCodePoint(codePoint, char)) return 0;
|
||||
const isWide = TELEGRAM_TABLE_WIDE_CODE_POINT_RANGES.some(
|
||||
([start, end]) => codePoint >= start && codePoint <= end,
|
||||
);
|
||||
return isWide ? 2 : 1;
|
||||
}
|
||||
|
||||
function getTelegramTableGraphemes(text: string): string[] {
|
||||
|
||||
Reference in new issue
Block a user