diff --git a/text/deno.json b/text/deno.json index 9c475257d44d..c661ac5bebd4 100644 --- a/text/deno.json +++ b/text/deno.json @@ -7,6 +7,7 @@ "./compare-similarity": "./compare_similarity.ts", "./levenshtein-distance": "./levenshtein_distance.ts", "./unstable-slugify": "./unstable_slugify.ts", + "./unstable-replace": "./unstable_replace.ts", "./to-camel-case": "./to_camel_case.ts", "./unstable-to-constant-case": "./unstable_to_constant_case.ts", "./to-kebab-case": "./to_kebab_case.ts", diff --git a/text/unstable_replace.ts b/text/unstable_replace.ts new file mode 100644 index 000000000000..893c4b28d971 --- /dev/null +++ b/text/unstable_replace.ts @@ -0,0 +1,127 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { escape } from "@std/regexp/escape"; + +/** + * A string or function that can be used as the second parameter of + * `String.prototype.replace()`. + */ +export type Replacer = + | string + | ((substring: string, ...args: unknown[]) => string); + +/** + * Replaces the specified pattern at the start and end of the string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param str The input string + * @param pattern The pattern to replace + * @param replacer String or function to be used as the replacement + * + * @example Strip non-word characters from start and end of a string + * ```ts + * import { replaceBoth } from "@std/text/unstable-replace"; + * import { assertEquals } from "@std/assert"; + * + * const result = replaceBoth("¡¿Seguro que no?!", /[^\p{L}\p{M}\p{N}]+/u, ""); + * assertEquals(result, "Seguro que no"); + * ``` + */ +export function replaceBoth( + str: string, + pattern: string | RegExp, + replacer: Replacer, +): string { + return replaceStart( + replaceEnd(str, pattern, replacer), + pattern, + replacer, + ); +} + +/** + * Replaces the specified pattern at the start of the string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param str The input string + * @param pattern The pattern to replace + * @param replacer String or function to be used as the replacement + * + * @example Strip byte-order mark + * ```ts + * import { replaceStart } from "@std/text/unstable-replace"; + * import { assertEquals } from "@std/assert"; + * + * const result = replaceStart("\ufeffhello world", "\ufeff", ""); + * assertEquals(result, "hello world"); + * ``` + * + * @example Replace `http:` protocol with `https:` + * ```ts + * import { replaceStart } from "@std/text/unstable-replace"; + * import { assertEquals } from "@std/assert"; + * + * const result = replaceStart("http://example.com", "http:", "https:"); + * assertEquals(result, "https://example.com"); + * ``` + */ +export function replaceStart( + str: string, + pattern: string | RegExp, + replacer: Replacer, +): string { + return str.replace( + cloneAsStatelessRegExp`^${pattern}`, + replacer as string, + ); +} + +/** + * Replaces the specified pattern at the start of the string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param str The input string + * @param pattern The pattern to replace + * @param replacer String or function to be used as the replacement + * + * @example Remove a single trailing newline + * ```ts + * import { replaceEnd } from "@std/text/unstable-replace"; + * import { assertEquals } from "@std/assert"; + * + * const result = replaceEnd("file contents\n", "\n", ""); + * assertEquals(result, "file contents"); + * ``` + * + * @example Ensure pathname ends with a single slash + * ```ts + * import { replaceEnd } from "@std/text/unstable-replace"; + * import { assertEquals } from "@std/assert"; + * + * const result = replaceEnd("/pathname", new RegExp("/*"), "/"); + * assertEquals(result, "/pathname/"); + * ``` + */ +export function replaceEnd( + str: string, + pattern: string | RegExp, + replacement: Replacer, +): string { + return str.replace( + cloneAsStatelessRegExp`${pattern}$`, + replacement as string, + ); +} + +function cloneAsStatelessRegExp( + { raw: [$0, $1] }: TemplateStringsArray, + pattern: string | RegExp, +) { + const { source, flags } = typeof pattern === "string" + ? { source: escape(pattern), flags: "" } + : pattern; + + return new RegExp(`${$0!}(?:${source})${$1!}`, flags.replace(/[gy]+/g, "")); +} diff --git a/text/unstable_replace_test.ts b/text/unstable_replace_test.ts new file mode 100644 index 000000000000..1eb10267cc28 --- /dev/null +++ b/text/unstable_replace_test.ts @@ -0,0 +1,170 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { assertEquals } from "@std/assert"; +import { replaceBoth, replaceEnd, replaceStart } from "./unstable_replace.ts"; + +Deno.test("replaceStart()", async (t) => { + await t.step("strips a prefix", () => { + assertEquals( + replaceStart("https://example.com", "https://", ""), + "example.com", + ); + }); + + await t.step("replaces a prefix", () => { + assertEquals( + replaceStart("http://example.com", "http://", "https://"), + "https://example.com", + ); + }); + + await t.step("no replacement if pattern not found", () => { + assertEquals( + replaceStart("file:///a/b/c", "http://", "https://"), + "file:///a/b/c", + ); + }); + + await t.step("strips prefixes by regex pattern", () => { + assertEquals(replaceStart("abc", /a|b/, ""), "bc"); + assertEquals(replaceStart("xbc", /a|b/, ""), "xbc"); + + assertEquals( + replaceStart("¡¿Seguro que no?!", /[^\p{L}\p{M}\p{N}]+/u, ""), + "Seguro que no?!", + ); + }); + + await t.step("complex replacers", () => { + assertEquals(replaceStart("abca", "a", "$'"), "bcabca"); + assertEquals(replaceStart("xbca", "a", "$'"), "xbca"); + + assertEquals(replaceStart("abcxyz", /[a-c]+/, "<$&>"), "xyz"); + assertEquals(replaceStart("abcxyz", /([a-c]+)/, "<$1>"), "xyz"); + assertEquals( + replaceStart("abcxyz", /(?[a-c]+)/, "<$>"), + "xyz", + ); + + assertEquals(replaceStart("abcxyz", /[a-c]+/, (m) => `<${m}>`), "xyz"); + assertEquals( + replaceStart("abcxyz", /([a-c]+)/, (_, p1) => `<${p1}>`), + "xyz", + ); + assertEquals( + replaceStart("abcxyz", /(?[a-c]+)/, (...args) => + `<${ + (args[ + args.findIndex((x) => typeof x === "number") + 2 + ] as { match: string }).match + }>`), + "xyz", + ); + }); +}); + +Deno.test("replaceEnd()", async (t) => { + await t.step("strips a suffix", () => { + assertEquals(replaceEnd("/pathname/", "/", ""), "/pathname"); + }); + + await t.step("replaces a suffix", () => { + assertEquals(replaceEnd("/pathname/", "/", "/?a=1"), "/pathname/?a=1"); + }); + + await t.step("no replacement if pattern not found", () => { + assertEquals(replaceEnd("/pathname", "/", "/?a=1"), "/pathname"); + }); + + await t.step("strips suffixes by regex pattern", () => { + assertEquals(replaceEnd("abc", /b|c/, ""), "ab"); + assertEquals(replaceEnd("abx", /b|c/, ""), "abx"); + + assertEquals( + replaceEnd("¡¿Seguro que no?!", /[^\p{L}\p{M}\p{N}]+/u, ""), + "¡¿Seguro que no", + ); + }); + + await t.step("complex replacers", () => { + assertEquals(replaceEnd("abca", "a", "$`"), "abcabc"); + assertEquals(replaceEnd("abcx", "a", "$`"), "abcx"); + + assertEquals(replaceEnd("xyzabc", /[a-c]+/, "<$&>"), "xyz"); + assertEquals(replaceEnd("xyzabc", /([a-c]+)/, "<$1>"), "xyz"); + assertEquals( + replaceEnd("xyzabc", /(?[a-c]+)/, "<$>"), + "xyz", + ); + + assertEquals(replaceEnd("xyzabc", /[a-c]+/, (m) => `<${m}>`), "xyz"); + assertEquals( + replaceEnd("xyzabc", /([a-c]+)/, (_, p1) => `<${p1}>`), + "xyz", + ); + assertEquals( + replaceEnd("xyzabc", /(?[a-c]+)/, (...args) => + `<${ + (args[ + args.findIndex((x) => typeof x === "number") + 2 + ] as { match: string }).match + }>`), + "xyz", + ); + }); +}); + +Deno.test("replaceBoth()", async (t) => { + await t.step("strips both prefixes and suffixes", () => { + assertEquals(replaceBoth("/pathname/", "/", ""), "pathname"); + }); + + await t.step("replaces both prefixes and suffixes", () => { + assertEquals(replaceBoth("/pathname/", "/", "!"), "!pathname!"); + assertEquals(replaceBoth("//pathname", /\/+/, "/"), "/pathname"); + assertEquals(replaceBoth("//pathname", /\/*/, "/"), "/pathname/"); + }); + + await t.step("no replacement if pattern not found", () => { + assertEquals(replaceBoth("pathname", "/", "!"), "pathname"); + }); + + await t.step("strips both prefixes and suffixes by regex pattern", () => { + assertEquals(replaceBoth("abc", /a|b|c/, ""), "b"); + assertEquals(replaceBoth("xbx", /a|b|c/, ""), "xbx"); + + assertEquals( + replaceBoth("¡¿Seguro que no?!", /[^\p{L}\p{M}\p{N}]+/u, ""), + "Seguro que no", + ); + }); + + await t.step("complex replacers", () => { + assertEquals(replaceBoth("abca", "a", "$$"), "$bc$"); + assertEquals(replaceBoth("xbcx", "a", "$$"), "xbcx"); + + assertEquals(replaceBoth("abcxyzabc", /[a-c]+/, "<$&>"), "xyz"); + assertEquals(replaceBoth("abcxyzabc", /([a-c]+)/, "<$1>"), "xyz"); + assertEquals( + replaceBoth("abcxyzabc", /(?[a-c]+)/, "<$>"), + "xyz", + ); + + assertEquals( + replaceBoth("abcxyzabc", /[a-c]+/, (m) => `<${m}>`), + "xyz", + ); + assertEquals( + replaceBoth("abcxyzabc", /([a-c]+)/, (_, p1) => `<${p1}>`), + "xyz", + ); + assertEquals( + replaceBoth("abcxyzabc", /(?[a-c]+)/, (...args) => + `<${ + (args[ + args.findIndex((x) => typeof x === "number") + 2 + ] as { match: string }).match + }>`), + "xyz", + ); + }); +});