Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: refactor all regular expressions into super-expressive #63

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"backport": "deno run --no-prompt --allow-read=. --allow-write=. https://deno.land/x/deno2node@v1.14.0/src/cli.ts",
"check": "deno lint && deno fmt --check && deno check --allow-import src/mod.ts",
"fix": "deno lint --fix && deno fmt",
"test": "deno test --allow-import --seed=123456 --parallel ./test/",
"test": "deno test --allow-import --seed=123456 --parallel ./test/ --hide-stacktraces",
"coverage": "rm -rf ./test/cov_profile && deno task test --coverage=./test/cov_profile && deno coverage --lcov --output=./coverage.lcov ./test/cov_profile",
"hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts"
},
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"peerDependencies": {
"grammy": "^1.17.1"
},
"dependencies": {
"super-expressive": "^2.0.0"
},
"devDependencies": {
"deno2node": "^1.14.0",
"typescript": "^5.6.3"
Expand Down
9 changes: 3 additions & 6 deletions src/command-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,8 @@ import {
Middleware,
} from "./deps.deno.ts";
import type { CommandElementals, CommandOptions } from "./types.ts";
import {
ensureArray,
getCommandsRegex,
type MaybeArray,
} from "./utils/array.ts";
import { ensureArray, type MaybeArray } from "./utils/array.ts";
import { getCommandsLikeRegex } from "./utils/regex.ts";
import {
setBotCommands,
SetBotCommandsOptions,
Expand Down Expand Up @@ -417,7 +414,7 @@ function containsCommands<
}

for (const prefix of allPrefixes) {
const regex = getCommandsRegex(prefix);
const regex = getCommandsLikeRegex(prefix);
if (ctx.hasText(regex)) return true;
}
return false;
Expand Down
21 changes: 13 additions & 8 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
matchesPattern,
} from "./utils/checks.ts";
import { InvalidScopeError } from "./utils/errors.ts";
import {
DISALLOWED_SPECIAL_CHARACTERS,
escapeEspecial,
NO_PREFIX_COMMAND_MATCHER,
} from "./utils/regex.ts";

type BotCommandGroupsScope =
| BotCommandScopeAllGroupChats
Expand All @@ -47,8 +52,6 @@ export interface CommandMatch {
match?: RegExpExecArray | null;
}

const NOCASE_COMMAND_NAME_REGEX = /^[0-9a-z_]+$/i;

/**
* Class that represents a single command and allows you to configure it.
*/
Expand Down Expand Up @@ -194,11 +197,11 @@ export class Command<C extends Context = Context> implements MiddlewareObj<C> {
);
}

if (!NOCASE_COMMAND_NAME_REGEX.test(name)) {
if (DISALLOWED_SPECIAL_CHARACTERS.test(name)) {
problems.push(
`Command name has special characters (${
name.replace(/[0-9a-z_]/ig, "")
}). Only letters, digits and _ are allowed`,
`Command name contains the following disallowed special characters: ${
name.match(DISALLOWED_SPECIAL_CHARACTERS)?.join("")
}\nOnly letters, digits and _ are allowed`,
);
}
}
Expand Down Expand Up @@ -371,9 +374,11 @@ export class Command<C extends Context = Context> implements MiddlewareObj<C> {
}

const commandNames = ensureArray(command);
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

const escapedPrefix = escapeEspecial(prefix);

const commandRegex = new RegExp(
`${escapedPrefix}(?<command>[^@ ]+)(?:@(?<username>[^\\s]*))?(?<rest>.*)`,
escapedPrefix + NO_PREFIX_COMMAND_MATCHER.source,
"g",
);

Expand Down
5 changes: 3 additions & 2 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { CommandMatch } from "./command.ts";
import { BotCommandScopeChat, Context, NextFunction } from "./deps.deno.ts";
import { SetMyCommandsParams } from "./mod.ts";
import { BotCommandEntity } from "./types.ts";
import { ensureArray, getCommandsRegex } from "./utils/array.ts";
import { ensureArray } from "./utils/array.ts";
import { getCommandsLikeRegex } from "./utils/regex.ts";
import { fuzzyMatch, JaroWinklerOptions } from "./utils/jaro-winkler.ts";
import {
setBotCommands,
Expand Down Expand Up @@ -141,7 +142,7 @@ export function commands<C extends Context>() {
if (!prefixes.length) return [];

const regexes = prefixes.map(
(prefix) => getCommandsRegex(prefix),
(prefix) => getCommandsLikeRegex(prefix),
);
const entities = regexes.flatMap((regex) => {
let match: RegExpExecArray | null;
Expand Down
2 changes: 2 additions & 0 deletions src/deps.deno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,7 @@ export type {
LanguageCode,
MessageEntity,
} from "https://lib.deno.dev/x/grammy@1/types.ts";
import SuperExpressive from "npm:super-expressive";
export { SuperExpressive };
// TODO: bring this back once the types are available on the "web" runtimes
// export { LanguageCodes } from "https://lib.deno.dev/x/grammy@1/types.ts";
2 changes: 2 additions & 0 deletions src/deps.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,7 @@ export type {
LanguageCode,
MessageEntity
} from "grammy/types";
import SuperExpressive from "npm:super-expressive";
export { SuperExpressive };
// TODO: bring this back once the types are available on the "web" runtimes
// export { LanguageCodes } from "grammy/types";
18 changes: 0 additions & 18 deletions src/utils/array.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,3 @@
export type MaybeArray<T> = T | T[];
export const ensureArray = <T>(value: MaybeArray<T>): T[] =>
Array.isArray(value) ? value : [value];

const specialChars = "\\.^$|?*+()[]{}-".split("");

const replaceAll = (s: string, find: string, replace: string) =>
s.replace(new RegExp(`\\${find}`, "g"), replace);

export function escapeSpecial(str: string) {
return specialChars.reduce(
(acc, char) => replaceAll(acc, char, `\\${char}`),
str,
);
}
export function getCommandsRegex(prefix: string) {
return new RegExp(
`(\?\<\!\\S)(\?<prefix>${escapeSpecial(prefix)})\\S+(\\s|$)`,
"g",
);
}
65 changes: 65 additions & 0 deletions src/utils/regex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { SuperExpressive } from "../deps.deno.ts";

export const escapeEspecial = (prefix: string) =>
SuperExpressive().namedCapture("prefix")
.string(`${prefix}`)
.end()
.toRegex()
.source;

const notBehindWhitespace = SuperExpressive()
.assertNotBehind
.nonWhitespaceChar
.end()
.toRegex()
.source;

const hasCharsAndDetectEnd = SuperExpressive()
.oneOrMore
.nonWhitespaceChar
.anyOf
.whitespaceChar
.endOfInput
.end()
.toRegex()
.source;

export function getCommandsLikeRegex(prefix: string) {
return new RegExp(
notBehindWhitespace + escapeEspecial(prefix) + hasCharsAndDetectEnd,
"g",
);
}

export const DISALLOWED_SPECIAL_CHARACTERS = SuperExpressive()
.caseInsensitive
.allowMultipleMatches
.anythingBut
.range("0", "9")
.range("a", "z")
.char("_")
.end()
.toRegex();

export const NO_PREFIX_COMMAND_MATCHER = SuperExpressive()
.namedCapture("command")
.oneOrMore
.anythingButChars("@ ")
.end()
.optional.group
.char("@")
.subexpression(
SuperExpressive()
.namedCapture("username")
.zeroOrMore
.nonWhitespaceChar
.end(),
)
.end()
.subexpression(
SuperExpressive()
.namedCapture("rest")
.zeroOrMore
.anyChar
.end(),
).toRegex();
14 changes: 7 additions & 7 deletions test/command-group.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CommandGroup } from "../src/command-group.ts";
import { MyCommandParams } from "../src/mod.ts";
import { dummyCtx } from "./context.test.ts";
import { getDummyCtx } from "./utils.ts";
import {
assert,
assertEquals,
Expand Down Expand Up @@ -54,7 +54,7 @@ describe("CommandGroup", () => {
});
describe("setMyCommands", () => {
it("should throw if the update has no chat property", () => {
const ctx = dummyCtx({ noMessage: true });
const ctx = getDummyCtx({ noMessage: true });
const a = new CommandGroup();
assertRejects(() => ctx.setMyCommands(a));
});
Expand Down Expand Up @@ -294,7 +294,7 @@ describe("CommandGroup", () => {

it("should only consider as entities prefixes registered in the command instance", () => {
const text = "/papi hola papacito como estamos /papi /ecco";
let ctx = dummyCtx({
let ctx = getDummyCtx({
userInput: text,
});
const entities = ctx.getCommandEntities(a);
Expand All @@ -310,7 +310,7 @@ describe("CommandGroup", () => {
}
});
it("should get command entities for custom prefixes", () => {
let ctx = dummyCtx({
let ctx = getDummyCtx({
userInput: "/hi ?momi abcdfghi",
});
const entities = ctx.getCommandEntities(a);
Expand Down Expand Up @@ -339,15 +339,15 @@ describe("CommandGroup", () => {
]);
});
it("should throw if you call getCommandEntities on an update with no text", () => {
const ctx = dummyCtx({});
const ctx = getDummyCtx({});
assertThrows(() => ctx.getCommandEntities([a, b, c]));
});
it("should return an empty array if the Commands classes to check against do not have any command register", () => {
const ctx = dummyCtx({ userInput: "/papi" });
const ctx = getDummyCtx({ userInput: "/papi" });
assertEquals(ctx.getCommandEntities(c), []);
});
it("should work across multiple Commands instances", () => {
const ctx = dummyCtx({ userInput: "/papi superprefixmami" });
const ctx = getDummyCtx({ userInput: "/papi superprefixmami" });
assertEquals(
ctx.getCommandEntities([a, b]).map((entity) => entity.prefix),
["/", "superprefix"],
Expand Down
4 changes: 2 additions & 2 deletions test/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ describe("Command", () => {
const command = new Command("*test!", "_");
assertEquals(command.isApiCompliant(), [
false,
"Command name has special characters (*!). Only letters, digits and _ are allowed",
"Command name contains the following disallowed special characters: *!\nOnly letters, digits and _ are allowed",
]);
});

Expand All @@ -617,7 +617,7 @@ describe("Command", () => {
assertEquals(command.isApiCompliant(), [
false,
"Command name has uppercase characters",
"Command name has special characters ($). Only letters, digits and _ are allowed",
"Command name contains the following disallowed special characters: $\nOnly letters, digits and _ are allowed",
]);
});
});
Expand Down
52 changes: 6 additions & 46 deletions test/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
import {
resolvesNext,
spy,
} from "https://deno.land/std@0.203.0/testing/mock.ts";
import { commands, type CommandsFlavor } from "../src/mod.ts";
import {
Api,
assert,
assertRejects,
Chat,
Context,
describe,
it,
Message,
Update,
User,
UserFromGetMe,
} from "./deps.test.ts";
import { commands } from "../src/mod.ts";
import { assert, assertRejects, describe, it } from "./deps.test.ts";
import { getDummyCtx } from "./utils.ts";

describe("commands", () => {
it("should install the setMyCommands method on the context", () => {
const context = dummyCtx({});
const context = getDummyCtx({});

const middleware = commands();
middleware(context, async () => {});

assert(context.setMyCommands);
});
it("should install the getNearestCommand method on the context", () => {
const context = dummyCtx({});
const context = getDummyCtx({});

const middleware = commands();
middleware(context, async () => {});
Expand All @@ -37,7 +22,7 @@ describe("commands", () => {

describe("setMyCommands", () => {
it("should throw an error if there is no chat", async () => {
const context = dummyCtx({ noMessage: true });
const context = getDummyCtx({ noMessage: true });

const middleware = commands();
middleware(context, async () => {});
Expand All @@ -50,28 +35,3 @@ describe("commands", () => {
});
});
});

export function dummyCtx({ userInput, language, noMessage }: {
userInput?: string;
language?: string;
noMessage?: boolean;
}) {
const u = { id: 42, first_name: "yo", language_code: language } as User;
const c = { id: 100, type: "private" } as Chat;
const m = noMessage ? undefined : ({
text: userInput,
from: u,
chat: c,
} as Message);
const update = {
message: m,
} as Update;
const api = {
raw: { setMyCommands: spy(resolvesNext([true] as const)) },
} as unknown as Api;
const me = { id: 42, username: "bot" } as UserFromGetMe;
const ctx = new Context(update, api, me) as CommandsFlavor<Context>;
const middleware = commands();
middleware(ctx, async () => {});
return ctx;
}
Loading
Loading