From f4cc4f249fd18d3dff83a64c95c875ec788662ee Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Fri, 20 Dec 2024 20:42:55 +0800 Subject: [PATCH 1/4] feat(text/unstable): add `strip` function (#6265) --- text/deno.json | 1 + text/unstable_strip.ts | 102 ++++++++++++++++++++++++++++++++++++ text/unstable_strip_test.ts | 31 +++++++++++ 3 files changed, 134 insertions(+) create mode 100644 text/unstable_strip.ts create mode 100644 text/unstable_strip_test.ts diff --git a/text/deno.json b/text/deno.json index 9c475257d44d..2da7549271d6 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-strip": "./unstable_strip.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_strip.ts b/text/unstable_strip.ts new file mode 100644 index 000000000000..8c012322d99c --- /dev/null +++ b/text/unstable_strip.ts @@ -0,0 +1,102 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { escape } from "@std/regexp"; + +export type StripOptions = { + /** + * If `true`, all occurrences will be stripped from the start of the string. + * If a number, the specified number of occurrences will be stripped from the start. + * + * @default {true} // if `end` option is omitted + * @default {false} // if `end` option is specified + */ + start?: boolean | number; + /** + * If `true`, all occurrences will be stripped from the end of the string. + * If a number, the specified number of occurrences will be stripped from the end. + * + * @default {true} // if `start` option is omitted + * @default {false} // if `start` option is specified + */ + end?: boolean | number; +}; + +/** + * Strips the specified pattern from the start and/or end of the string. + * + * @param str The string to strip from. + * @param pattern The pattern to strip from the string. + * @param options An object containing options for the strip operation. + * + * @example Strip both start and end + * ```ts + * import { strip } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(strip("---x---", "-"), "x"); + * ``` + * + * @example Strip start only + * ```ts + * import { strip } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(strip("---x---", "-", { start: true }), "x---"); + * ``` + * + * @example Strip end only + * ```ts + * import { strip } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(strip("---x---", "-", { end: true }), "---x"); + * ``` + * + * @example Strip a given number of occurrences + * ```ts + * import { strip } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(strip("---x---", "-", { start: 2, end: 1 }), "-x--"); + * ``` + * + * @example Strip using a regexp + * ```ts + * import { strip } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(strip("-_-x-_-", /[-_]/), "x"); + * ``` + */ +export function strip( + str: string, + pattern: string | RegExp, + options?: StripOptions, +): string { + const source = typeof pattern === "string" ? escape(pattern) : pattern.source; + const flags = typeof pattern === "string" ? "" : getFlags(pattern); + + const start = options?.start ?? (options?.end == null ? true : false); + const end = options?.end ?? (options?.start == null ? true : false); + + let prev = str; + + for ( + const [option, regex] of [ + [start, new RegExp(`^${source}`, flags)], + [end, new RegExp(`${source}$`, flags)], + ] as const + ) { + const count = typeof option === "number" ? option : option ? Infinity : 0; + + for (let i = 0; i < count; ++i) { + str = str.replace(regex, ""); + if (str === prev) break; + prev = str; + } + } + + return str; +} + +function getFlags(re: RegExp): string { + const flags = new Set(re.flags); + flags.delete("g"); + flags.delete("y"); + + return [...flags.keys()].join(""); +} diff --git a/text/unstable_strip_test.ts b/text/unstable_strip_test.ts new file mode 100644 index 000000000000..18d454fb1c09 --- /dev/null +++ b/text/unstable_strip_test.ts @@ -0,0 +1,31 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { assertEquals } from "@std/assert"; +import { strip } from "./unstable_strip.ts"; + +Deno.test("strip()", async (t) => { + await t.step("strips single-char prefixes/suffixes", () => { + assertEquals(strip("..x.x..", "."), "x.x"); + + assertEquals(strip("..x.x..", ".", { start: true }), "x.x.."); + assertEquals(strip("..x.x..", ".", { end: true }), "..x.x"); + + assertEquals(strip("..x.x..", ".", { end: 0 }), "..x.x.."); + assertEquals(strip("..x.x..", ".", { end: 1 }), "..x.x."); + assertEquals(strip("..x.x..", ".", { end: 2 }), "..x.x"); + assertEquals(strip("..x.x..", ".", { end: 3 }), "..x.x"); + + assertEquals(strip("..x.x..", ".", { start: 0 }), "..x.x.."); + assertEquals(strip("..x.x..", ".", { start: 1 }), ".x.x.."); + assertEquals(strip("..x.x..", ".", { start: 2 }), "x.x.."); + assertEquals(strip("..x.x..", ".", { start: 3 }), "x.x.."); + }); + + await t.step("strips mult-char prefixes/suffixes", () => { + assertEquals(strip("._.._._.x.x._._.._.", "._."), "_.x.x._"); + }); + + await t.step("strips prefixes/suffixes by regex pattern", () => { + assertEquals(strip("!@#$%x.x!@#$%", /./), ""); + assertEquals(strip("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), "x.x"); + }); +}); From de060b4e1746cf8bb49e7a33aa6a2d95a1cb803f Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Sat, 21 Dec 2024 00:06:08 +0800 Subject: [PATCH 2/4] Separate stripStart and stripEnd functions --- text/unstable_strip.ts | 159 ++++++++++++++++++++++++------------ text/unstable_strip_test.ts | 73 +++++++++++++---- 2 files changed, 166 insertions(+), 66 deletions(-) diff --git a/text/unstable_strip.ts b/text/unstable_strip.ts index 8c012322d99c..c733a6927627 100644 --- a/text/unstable_strip.ts +++ b/text/unstable_strip.ts @@ -3,100 +3,159 @@ import { escape } from "@std/regexp"; export type StripOptions = { /** - * If `true`, all occurrences will be stripped from the start of the string. - * If a number, the specified number of occurrences will be stripped from the start. - * - * @default {true} // if `end` option is omitted - * @default {false} // if `end` option is specified + * The number of occurrences to strip. + * @default {Infinity} */ - start?: boolean | number; - /** - * If `true`, all occurrences will be stripped from the end of the string. - * If a number, the specified number of occurrences will be stripped from the end. - * - * @default {true} // if `start` option is omitted - * @default {false} // if `start` option is specified - */ - end?: boolean | number; + count?: number; }; /** - * Strips the specified pattern from the start and/or end of the string. + * Strips the specified pattern from the start and end of the string. * - * @param str The string to strip from. - * @param pattern The pattern to strip from the string. - * @param options An object containing options for the strip operation. + * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @example Strip both start and end + * @param str The input string + * @param pattern The pattern to strip from the input + * @param options Options for the strip operation + * + * @example Strip using a string * ```ts * import { strip } from "@std/text/unstable-strip"; * import { assertEquals } from "@std/assert"; * assertEquals(strip("---x---", "-"), "x"); * ``` * - * @example Strip start only + * @example Strip using a regexp * ```ts * import { strip } from "@std/text/unstable-strip"; * import { assertEquals } from "@std/assert"; - * assertEquals(strip("---x---", "-", { start: true }), "x---"); + * assertEquals(strip("-_-x-_-", /[-_]/), "x"); * ``` * - * @example Strip end only + * @example Strip a specified count * ```ts * import { strip } from "@std/text/unstable-strip"; * import { assertEquals } from "@std/assert"; - * assertEquals(strip("---x---", "-", { end: true }), "---x"); + * assertEquals(strip("---x---", "-", { count: 1 }), "--x--"); * ``` + */ +export function strip( + str: string, + pattern: string | RegExp, + options?: StripOptions, +): string { + const { source, flags } = cloneAsStatelessRegExp(pattern); + return stripByRegExp( + stripByRegExp(str, new RegExp(`^${source}`, flags), options), + new RegExp(`${source}$`, flags), + options, + ); +} + +/** + * Strips the specified pattern from the start of the string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @example Strip a given number of occurrences + * @param str The input string + * @param pattern The pattern to strip from the input + * @param options Options for the strip operation + * + * @example Strip using a string * ```ts - * import { strip } from "@std/text/unstable-strip"; + * import { stripStart } from "@std/text/unstable-strip"; * import { assertEquals } from "@std/assert"; - * assertEquals(strip("---x---", "-", { start: 2, end: 1 }), "-x--"); + * assertEquals(stripStart("---x---", "-"), "x---"); * ``` * * @example Strip using a regexp * ```ts - * import { strip } from "@std/text/unstable-strip"; + * import { stripStart } from "@std/text/unstable-strip"; * import { assertEquals } from "@std/assert"; - * assertEquals(strip("-_-x-_-", /[-_]/), "x"); + * assertEquals(stripStart("-_-x-_-", /[-_]/), "x-_-"); + * ``` + * + * @example Strip a specified count + * ```ts + * import { stripStart } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(stripStart("---x---", "-", { count: 1 }), "--x---"); * ``` */ -export function strip( +export function stripStart( str: string, pattern: string | RegExp, options?: StripOptions, ): string { - const source = typeof pattern === "string" ? escape(pattern) : pattern.source; - const flags = typeof pattern === "string" ? "" : getFlags(pattern); + const { source, flags } = cloneAsStatelessRegExp(pattern); + return stripByRegExp(str, new RegExp(`^${source}`, flags), options); +} - const start = options?.start ?? (options?.end == null ? true : false); - const end = options?.end ?? (options?.start == null ? true : false); +/** +/** + * Strips the specified pattern from the start of the string. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param str The input string + * @param pattern The pattern to strip from the input + * @param options Options for the strip operation + * + * @example Strip using a string + * ```ts + * import { stripEnd } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(stripEnd("---x---", "-"), "---x"); + * ``` + * + * @example Strip using a regexp + * ```ts + * import { stripEnd } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(stripEnd("-_-x-_-", /[-_]/), "-_-x"); + * ``` + * + * @example Strip a specified count + * ```ts + * import { stripEnd } from "@std/text/unstable-strip"; + * import { assertEquals } from "@std/assert"; + * assertEquals(stripEnd("---x---", "-", { count: 1 }), "---x--"); + * ``` + */ +export function stripEnd( + str: string, + pattern: string | RegExp, + options?: StripOptions, +): string { + const { source, flags } = cloneAsStatelessRegExp(pattern); + return stripByRegExp(str, new RegExp(`${source}$`, flags), options); +} +function stripByRegExp( + str: string, + regExp: RegExp, + options?: StripOptions, +): string { let prev = str; - for ( - const [option, regex] of [ - [start, new RegExp(`^${source}`, flags)], - [end, new RegExp(`${source}$`, flags)], - ] as const - ) { - const count = typeof option === "number" ? option : option ? Infinity : 0; + const count = options?.count ?? Infinity; - for (let i = 0; i < count; ++i) { - str = str.replace(regex, ""); - if (str === prev) break; - prev = str; - } + for (let i = 0; i < count; ++i) { + str = str.replace(regExp, ""); + if (str === prev) break; + prev = str; } return str; } -function getFlags(re: RegExp): string { - const flags = new Set(re.flags); - flags.delete("g"); - flags.delete("y"); +function cloneAsStatelessRegExp(pattern: string | RegExp) { + return { + source: typeof pattern === "string" ? escape(pattern) : pattern.source, + flags: typeof pattern === "string" ? "" : getStatelessFlags(pattern), + }; +} - return [...flags.keys()].join(""); +function getStatelessFlags(re: RegExp): string { + return re.flags.replaceAll(/[gy]/g, ""); } diff --git a/text/unstable_strip_test.ts b/text/unstable_strip_test.ts index 18d454fb1c09..0d56881fa20e 100644 --- a/text/unstable_strip_test.ts +++ b/text/unstable_strip_test.ts @@ -1,30 +1,71 @@ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. import { assertEquals } from "@std/assert"; -import { strip } from "./unstable_strip.ts"; +import { strip, stripEnd, stripStart } from "./unstable_strip.ts"; -Deno.test("strip()", async (t) => { - await t.step("strips single-char prefixes/suffixes", () => { - assertEquals(strip("..x.x..", "."), "x.x"); +Deno.test("stripStart()", async (t) => { + await t.step("strips prefixes", () => { + assertEquals(stripStart("..x.x..", "."), "x.x.."); + }); - assertEquals(strip("..x.x..", ".", { start: true }), "x.x.."); - assertEquals(strip("..x.x..", ".", { end: true }), "..x.x"); + await t.step("strips prefixes with count", () => { + assertEquals(stripStart("..x.x..", ".", { count: 0 }), "..x.x.."); + assertEquals(stripStart("..x.x..", ".", { count: 1 }), ".x.x.."); + assertEquals(stripStart("..x.x..", ".", { count: 2 }), "x.x.."); + assertEquals(stripStart("..x.x..", ".", { count: 3 }), "x.x.."); + }); + + await t.step("strips multi-char prefixes", () => { + assertEquals(stripStart("._.._._.x.x._._.._.", "._."), "_.x.x._._.._."); + }); + + await t.step("strips prefixes by regex pattern", () => { + assertEquals(stripStart("!@#$%x.x!@#$%", /./), ""); + assertEquals( + stripStart("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), + "x.x!@#$%", + ); + }); +}); + +Deno.test("stripEnd()", async (t) => { + await t.step("strips suffixes", () => { + assertEquals(stripEnd("..x.x..", "."), "..x.x"); + }); - assertEquals(strip("..x.x..", ".", { end: 0 }), "..x.x.."); - assertEquals(strip("..x.x..", ".", { end: 1 }), "..x.x."); - assertEquals(strip("..x.x..", ".", { end: 2 }), "..x.x"); - assertEquals(strip("..x.x..", ".", { end: 3 }), "..x.x"); + await t.step("strips suffixes with a specific count", () => { + assertEquals(stripEnd("..x.x..", ".", { count: 0 }), "..x.x.."); + assertEquals(stripEnd("..x.x..", ".", { count: 1 }), "..x.x."); + assertEquals(stripEnd("..x.x..", ".", { count: 2 }), "..x.x"); + assertEquals(stripEnd("..x.x..", ".", { count: 3 }), "..x.x"); + }); + + await t.step("strips multi-char suffixes", () => { + assertEquals(stripEnd("._.._._.x.x._._.._.", "._."), "._.._._.x.x._"); + }); + + await t.step("strips suffixes by regex pattern", () => { + assertEquals(stripEnd("!@#$%x.x!@#$%", /./), ""); + assertEquals(stripEnd("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), "!@#$%x.x"); + }); +}); + +Deno.test("strip()", async (t) => { + await t.step("strips prefixes and suffixes", () => { + assertEquals(strip("..x.x..", "."), "x.x"); + }); - assertEquals(strip("..x.x..", ".", { start: 0 }), "..x.x.."); - assertEquals(strip("..x.x..", ".", { start: 1 }), ".x.x.."); - assertEquals(strip("..x.x..", ".", { start: 2 }), "x.x.."); - assertEquals(strip("..x.x..", ".", { start: 3 }), "x.x.."); + await t.step("strips prefixes and suffixes with a specific count", () => { + assertEquals(strip("..x.x..", ".", { count: 0 }), "..x.x.."); + assertEquals(strip("..x.x..", ".", { count: 1 }), ".x.x."); + assertEquals(strip("..x.x..", ".", { count: 2 }), "x.x"); + assertEquals(strip("..x.x..", ".", { count: 3 }), "x.x"); }); - await t.step("strips mult-char prefixes/suffixes", () => { + await t.step("strips multi-char prefixes and suffixes", () => { assertEquals(strip("._.._._.x.x._._.._.", "._."), "_.x.x._"); }); - await t.step("strips prefixes/suffixes by regex pattern", () => { + await t.step("strips prefixes and suffixes by regex pattern", () => { assertEquals(strip("!@#$%x.x!@#$%", /./), ""); assertEquals(strip("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), "x.x"); }); From 8e48f0b7ea309b6e47c4f4341b02c8c3c22e6f4c Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Sat, 21 Dec 2024 15:21:01 +0800 Subject: [PATCH 3/4] Fix bug with supplying non-atomic regexes --- text/unstable_strip.ts | 8 ++++---- text/unstable_strip_test.ts | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/text/unstable_strip.ts b/text/unstable_strip.ts index c733a6927627..0675a6c17568 100644 --- a/text/unstable_strip.ts +++ b/text/unstable_strip.ts @@ -46,8 +46,8 @@ export function strip( ): string { const { source, flags } = cloneAsStatelessRegExp(pattern); return stripByRegExp( - stripByRegExp(str, new RegExp(`^${source}`, flags), options), - new RegExp(`${source}$`, flags), + stripByRegExp(str, new RegExp(`^(?:${source})`, flags), options), + new RegExp(`(?:${source})$`, flags), options, ); } @@ -88,7 +88,7 @@ export function stripStart( options?: StripOptions, ): string { const { source, flags } = cloneAsStatelessRegExp(pattern); - return stripByRegExp(str, new RegExp(`^${source}`, flags), options); + return stripByRegExp(str, new RegExp(`^(?:${source})`, flags), options); } /** @@ -128,7 +128,7 @@ export function stripEnd( options?: StripOptions, ): string { const { source, flags } = cloneAsStatelessRegExp(pattern); - return stripByRegExp(str, new RegExp(`${source}$`, flags), options); + return stripByRegExp(str, new RegExp(`(?:${source})$`, flags), options); } function stripByRegExp( diff --git a/text/unstable_strip_test.ts b/text/unstable_strip_test.ts index 0d56881fa20e..a261af0a9c36 100644 --- a/text/unstable_strip_test.ts +++ b/text/unstable_strip_test.ts @@ -20,6 +20,7 @@ Deno.test("stripStart()", async (t) => { await t.step("strips prefixes by regex pattern", () => { assertEquals(stripStart("!@#$%x.x!@#$%", /./), ""); + assertEquals(stripStart("ab_ab_ab", /a|b/g), "_ab_ab"); assertEquals( stripStart("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), "x.x!@#$%", @@ -45,6 +46,7 @@ Deno.test("stripEnd()", async (t) => { await t.step("strips suffixes by regex pattern", () => { assertEquals(stripEnd("!@#$%x.x!@#$%", /./), ""); + assertEquals(stripEnd("ab_ab_ab", /a|b/g), "ab_ab_"); assertEquals(stripEnd("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), "!@#$%x.x"); }); }); @@ -67,6 +69,7 @@ Deno.test("strip()", async (t) => { await t.step("strips prefixes and suffixes by regex pattern", () => { assertEquals(strip("!@#$%x.x!@#$%", /./), ""); + assertEquals(strip("ab_ab_ab", /a|b/g), "_ab_"); assertEquals(strip("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), "x.x"); }); }); From ad6098a207fe4155bb2323dbdcf8c04c41d7482d Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Thu, 26 Dec 2024 11:10:02 +0800 Subject: [PATCH 4/4] strip -> replace --- text/deno.json | 2 +- text/unstable_replace.ts | 127 +++++++++++++++++++++++++ text/unstable_replace_test.ts | 170 ++++++++++++++++++++++++++++++++++ text/unstable_strip.ts | 161 -------------------------------- text/unstable_strip_test.ts | 75 --------------- 5 files changed, 298 insertions(+), 237 deletions(-) create mode 100644 text/unstable_replace.ts create mode 100644 text/unstable_replace_test.ts delete mode 100644 text/unstable_strip.ts delete mode 100644 text/unstable_strip_test.ts diff --git a/text/deno.json b/text/deno.json index 2da7549271d6..c661ac5bebd4 100644 --- a/text/deno.json +++ b/text/deno.json @@ -7,7 +7,7 @@ "./compare-similarity": "./compare_similarity.ts", "./levenshtein-distance": "./levenshtein_distance.ts", "./unstable-slugify": "./unstable_slugify.ts", - "./unstable-strip": "./unstable_strip.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", + ); + }); +}); diff --git a/text/unstable_strip.ts b/text/unstable_strip.ts deleted file mode 100644 index 0675a6c17568..000000000000 --- a/text/unstable_strip.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -import { escape } from "@std/regexp"; - -export type StripOptions = { - /** - * The number of occurrences to strip. - * @default {Infinity} - */ - count?: number; -}; - -/** - * Strips the specified pattern from 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 strip from the input - * @param options Options for the strip operation - * - * @example Strip using a string - * ```ts - * import { strip } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(strip("---x---", "-"), "x"); - * ``` - * - * @example Strip using a regexp - * ```ts - * import { strip } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(strip("-_-x-_-", /[-_]/), "x"); - * ``` - * - * @example Strip a specified count - * ```ts - * import { strip } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(strip("---x---", "-", { count: 1 }), "--x--"); - * ``` - */ -export function strip( - str: string, - pattern: string | RegExp, - options?: StripOptions, -): string { - const { source, flags } = cloneAsStatelessRegExp(pattern); - return stripByRegExp( - stripByRegExp(str, new RegExp(`^(?:${source})`, flags), options), - new RegExp(`(?:${source})$`, flags), - options, - ); -} - -/** - * Strips the specified pattern from the start of the string. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @param str The input string - * @param pattern The pattern to strip from the input - * @param options Options for the strip operation - * - * @example Strip using a string - * ```ts - * import { stripStart } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(stripStart("---x---", "-"), "x---"); - * ``` - * - * @example Strip using a regexp - * ```ts - * import { stripStart } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(stripStart("-_-x-_-", /[-_]/), "x-_-"); - * ``` - * - * @example Strip a specified count - * ```ts - * import { stripStart } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(stripStart("---x---", "-", { count: 1 }), "--x---"); - * ``` - */ -export function stripStart( - str: string, - pattern: string | RegExp, - options?: StripOptions, -): string { - const { source, flags } = cloneAsStatelessRegExp(pattern); - return stripByRegExp(str, new RegExp(`^(?:${source})`, flags), options); -} - -/** -/** - * Strips the specified pattern from the start of the string. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @param str The input string - * @param pattern The pattern to strip from the input - * @param options Options for the strip operation - * - * @example Strip using a string - * ```ts - * import { stripEnd } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(stripEnd("---x---", "-"), "---x"); - * ``` - * - * @example Strip using a regexp - * ```ts - * import { stripEnd } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(stripEnd("-_-x-_-", /[-_]/), "-_-x"); - * ``` - * - * @example Strip a specified count - * ```ts - * import { stripEnd } from "@std/text/unstable-strip"; - * import { assertEquals } from "@std/assert"; - * assertEquals(stripEnd("---x---", "-", { count: 1 }), "---x--"); - * ``` - */ -export function stripEnd( - str: string, - pattern: string | RegExp, - options?: StripOptions, -): string { - const { source, flags } = cloneAsStatelessRegExp(pattern); - return stripByRegExp(str, new RegExp(`(?:${source})$`, flags), options); -} - -function stripByRegExp( - str: string, - regExp: RegExp, - options?: StripOptions, -): string { - let prev = str; - - const count = options?.count ?? Infinity; - - for (let i = 0; i < count; ++i) { - str = str.replace(regExp, ""); - if (str === prev) break; - prev = str; - } - - return str; -} - -function cloneAsStatelessRegExp(pattern: string | RegExp) { - return { - source: typeof pattern === "string" ? escape(pattern) : pattern.source, - flags: typeof pattern === "string" ? "" : getStatelessFlags(pattern), - }; -} - -function getStatelessFlags(re: RegExp): string { - return re.flags.replaceAll(/[gy]/g, ""); -} diff --git a/text/unstable_strip_test.ts b/text/unstable_strip_test.ts deleted file mode 100644 index a261af0a9c36..000000000000 --- a/text/unstable_strip_test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. -import { assertEquals } from "@std/assert"; -import { strip, stripEnd, stripStart } from "./unstable_strip.ts"; - -Deno.test("stripStart()", async (t) => { - await t.step("strips prefixes", () => { - assertEquals(stripStart("..x.x..", "."), "x.x.."); - }); - - await t.step("strips prefixes with count", () => { - assertEquals(stripStart("..x.x..", ".", { count: 0 }), "..x.x.."); - assertEquals(stripStart("..x.x..", ".", { count: 1 }), ".x.x.."); - assertEquals(stripStart("..x.x..", ".", { count: 2 }), "x.x.."); - assertEquals(stripStart("..x.x..", ".", { count: 3 }), "x.x.."); - }); - - await t.step("strips multi-char prefixes", () => { - assertEquals(stripStart("._.._._.x.x._._.._.", "._."), "_.x.x._._.._."); - }); - - await t.step("strips prefixes by regex pattern", () => { - assertEquals(stripStart("!@#$%x.x!@#$%", /./), ""); - assertEquals(stripStart("ab_ab_ab", /a|b/g), "_ab_ab"); - assertEquals( - stripStart("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), - "x.x!@#$%", - ); - }); -}); - -Deno.test("stripEnd()", async (t) => { - await t.step("strips suffixes", () => { - assertEquals(stripEnd("..x.x..", "."), "..x.x"); - }); - - await t.step("strips suffixes with a specific count", () => { - assertEquals(stripEnd("..x.x..", ".", { count: 0 }), "..x.x.."); - assertEquals(stripEnd("..x.x..", ".", { count: 1 }), "..x.x."); - assertEquals(stripEnd("..x.x..", ".", { count: 2 }), "..x.x"); - assertEquals(stripEnd("..x.x..", ".", { count: 3 }), "..x.x"); - }); - - await t.step("strips multi-char suffixes", () => { - assertEquals(stripEnd("._.._._.x.x._._.._.", "._."), "._.._._.x.x._"); - }); - - await t.step("strips suffixes by regex pattern", () => { - assertEquals(stripEnd("!@#$%x.x!@#$%", /./), ""); - assertEquals(stripEnd("ab_ab_ab", /a|b/g), "ab_ab_"); - assertEquals(stripEnd("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), "!@#$%x.x"); - }); -}); - -Deno.test("strip()", async (t) => { - await t.step("strips prefixes and suffixes", () => { - assertEquals(strip("..x.x..", "."), "x.x"); - }); - - await t.step("strips prefixes and suffixes with a specific count", () => { - assertEquals(strip("..x.x..", ".", { count: 0 }), "..x.x.."); - assertEquals(strip("..x.x..", ".", { count: 1 }), ".x.x."); - assertEquals(strip("..x.x..", ".", { count: 2 }), "x.x"); - assertEquals(strip("..x.x..", ".", { count: 3 }), "x.x"); - }); - - await t.step("strips multi-char prefixes and suffixes", () => { - assertEquals(strip("._.._._.x.x._._.._.", "._."), "_.x.x._"); - }); - - await t.step("strips prefixes and suffixes by regex pattern", () => { - assertEquals(strip("!@#$%x.x!@#$%", /./), ""); - assertEquals(strip("ab_ab_ab", /a|b/g), "_ab_"); - assertEquals(strip("!@#$%x.x!@#$%", /[^\p{L}\p{M}\p{N}]+/u), "x.x"); - }); -});