From 7000deb52fed225857d83ae28cd623d85f522691 Mon Sep 17 00:00:00 2001 From: Nev Date: Thu, 19 Mar 2026 21:27:54 -0700 Subject: [PATCH 1/2] feat(string): add strTruncate,strCount strAt and strMatchAll helpers, shared literal regex helper, and coverage - **strTruncate**: Truncate strings to maximum length with optional suffix support - Preserves hard max length boundary (suffix included within limit) - Truncates suffix if needed to maintain max length constraint - Type coercion via asString(); throws for null/undefined - **strCount**: Count non-overlapping substring occurrences - Returns 0 for empty search strings (prevents unbounded matches) - Type coercion via asString(); throws for null/undefined - add strAt and polyStrAt - add strMatchAll and polyStrMatchAll - wire String.at and String.matchAll polyfills - add createLiteralRegex and export it - refactor replace_all and match_all to use createLiteralRegex for literal matcher handling - add createIterableIterator and export it - update public exports for new non-polyfill APIs and keep polyStrReplaceAll in the polyfill export section --- .size-limit.json | 14 +- docs/feature-backlog.md | 6 +- lib/src/helpers/regexp.ts | 32 ++ lib/src/index.ts | 7 +- lib/src/iterator/create.ts | 57 ++++ lib/src/polyfills.ts | 6 +- lib/src/string/at.ts | 75 +++++ lib/src/string/match_all.ts | 220 +++++++++++++ lib/src/string/replace_all.ts | 51 ++- lib/test/bundle-size-check.js | 8 +- lib/test/src/common/helpers/regexp.test.ts | 106 +++++- lib/test/src/common/iterator/create.test.ts | 205 +++++++++++- lib/test/src/common/string/at.test.ts | 109 +++++++ lib/test/src/common/string/match_all.test.ts | 327 +++++++++++++++++++ lib/test/src/common/string/truncate.test.ts | 3 +- 15 files changed, 1196 insertions(+), 30 deletions(-) create mode 100644 lib/src/string/at.ts create mode 100644 lib/src/string/match_all.ts create mode 100644 lib/test/src/common/string/at.test.ts create mode 100644 lib/test/src/common/string/match_all.test.ts diff --git a/.size-limit.json b/.size-limit.json index 056023d0..f384c65c 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,42 +2,42 @@ { "name": "es5-full", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "28 kb", + "limit": "28.5 kb", "brotli": false, "running": false }, { "name": "es6-full", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "27 kb", + "limit": "27.5 kb", "brotli": false, "running": false }, { "name": "es5-full-brotli", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "10 kb", + "limit": "10.5 kb", "brotli": true, "running": false }, { "name": "es6-full-brotli", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "9.75 kb", + "limit": "10.5 kb", "brotli": true, "running": false }, { "name": "es5-zip", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "11 Kb", + "limit": "11.5 Kb", "gzip": true, "running": false }, { "name": "es6-zip", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "10.75 Kb", + "limit": "11.5 Kb", "gzip": true, "running": false }, @@ -68,7 +68,7 @@ { "name": "es5-poly", "path": "lib/bundle/es5/ts-polyfills-utils.js", - "limit": "10 kb", + "limit": "11.5 kb", "brotli": false, "running": false }, diff --git a/docs/feature-backlog.md b/docs/feature-backlog.md index 93af06c9..0c950352 100644 --- a/docs/feature-backlog.md +++ b/docs/feature-backlog.md @@ -23,11 +23,9 @@ Identify practical, minification-friendly, cross-environment additions that fit ### Language-Native Suggestions (with ECMAScript Version) #### String Methods (ES6+) -- `strMatchAll` – ES2020 (String.prototype.matchAll for iterator over all regex matches) -- `strAt` – ES2022 (String.prototype.at, supports negative indexing) +(All major String methods currently implemented) #### Array Methods (ES6+) -- `arrWith` – ES2023 (Array.prototype.with for immutable element replacement) - `arrFlatMap` – ES2019 (Array.prototype.flatMap) #### Object Utilities (ES6+) @@ -40,7 +38,7 @@ Identify practical, minification-friendly, cross-environment additions that fit - `mapMerge` – Map concatenation helper #### Type/Value Inspection (ES6+) -- `isGenerator` / `isAsyncIterable` – ES6+ type checks +- `isAsyncIterable` – ES6+ type checks - `isIntegerInRange` – Safe integer range validation Notes: diff --git a/lib/src/helpers/regexp.ts b/lib/src/helpers/regexp.ts index e68ba8bc..3fa063ae 100644 --- a/lib/src/helpers/regexp.ts +++ b/lib/src/helpers/regexp.ts @@ -213,3 +213,35 @@ export function makeGlobRegex(value: string, ignoreCase?: boolean, fullMatch?: b }); }, !!ignoreCase, fullMatch); } + + +/** + * Create a regular expression that escapes all special regex characters in the input string, + * treating it as a literal string to match. + * @since 0.14.0 + * @group RegExp + * @param matcher - The string value to be escaped and converted into a RegExp for literal matching. + * @returns A new Regular Expression that matches the literal string value. + * @example + * ```ts + * let regex = createLiteralRegex("Hello.World"); + * + * let matches = regex.exec("Hello.World"); + * matches[0]; // "Hello.World" + * + * let matches = regex.exec("HelloXWorld"); + * matches; // null - dot does not match as wildcard + * + * let regex = createLiteralRegex("(test)"); + * let matches = regex.exec("(test)"); + * matches[0]; // "(test)" + * + * let matches = regex.exec("test"); + * matches; // null + * ``` + */ +/*#__NO_SIDE_EFFECTS__*/ +export function createLiteralRegex(matcher: string) { + // eslint-disable-next-line security/detect-non-literal-regexp + return new RegExp(strReplace(asString(matcher), /[.*+?^${}()|[\]\\]/g, "\\$&") || "(?:)", "g"); +} \ No newline at end of file diff --git a/lib/src/index.ts b/lib/src/index.ts index 084e397a..33fb73a6 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -74,14 +74,14 @@ export { ILazyValue, getLazy, setBypassLazyCache, getWritableLazy } from "./help export { IGetLength as GetLengthImpl, getLength } from "./helpers/length"; export { getIntValue, isInteger, isFiniteNumber } from "./helpers/number"; export { getPerformance, hasPerformance, elapsedTime, perfNow } from "./helpers/perf"; -export { createFilenameRegex, createWildcardRegex, makeGlobRegex } from "./helpers/regexp"; +export { createFilenameRegex, createLiteralRegex, createWildcardRegex, makeGlobRegex } from "./helpers/regexp"; export { safe, ISafeReturn, SafeReturnType } from "./helpers/safe"; export { safeGet } from "./helpers/safe_get"; export { safeGetLazy, safeGetWritableLazy, safeGetDeferred, safeGetWritableDeferred } from "./helpers/safe_lazy"; export { throwError, throwTypeError, throwRangeError } from "./helpers/throw"; export { hasValue } from "./helpers/value"; export { createArrayIterator } from "./iterator/array"; -export { CreateIteratorContext, createIterator, createIterable, makeIterable } from "./iterator/create"; +export { CreateIteratorContext, createIterator, createIterable, createIterableIterator, makeIterable } from "./iterator/create"; export { iterForOf } from "./iterator/forOf"; export { isIterable, isIterator } from "./iterator/iterator"; export { createRangeIterator } from "./iterator/range"; @@ -124,9 +124,11 @@ export { objSetPrototypeOf } from "./object/set_proto"; export { objIsFrozen, objIsSealed } from "./object/object_state"; export { strCamelCase, strCapitalizeWords, strKebabCase, strLetterCase, strSnakeCase } from "./string/conversion"; export { strCount } from "./string/count"; +export { strAt, polyStrAt } from "./string/at"; export { strEndsWith } from "./string/ends_with"; export { strContains, strIncludes, polyStrIncludes } from "./string/includes"; export { strIndexOf, strLastIndexOf } from "./string/index_of"; +export { strMatchAll, polyStrMatchAll } from "./string/match_all"; export { strIsNullOrWhiteSpace, strIsNullOrEmpty } from "./string/is_null_or"; export { strPadEnd, strPadStart } from "./string/pad"; export { strReplace } from "./string/replace"; @@ -163,4 +165,5 @@ export { polyObjIs } from "./polyfills/object/objIs"; export { polyStrSymSplit } from "./polyfills/split"; export { polyGetKnownSymbol, polyNewSymbol, polySymbolFor, polySymbolKeyFor } from "./polyfills/symbol"; export { polyStrTrim, polyStrTrimEnd, polyStrTrimStart } from "./polyfills/trim"; +export { polyStrReplaceAll } from "./string/replace_all"; export { polyObjHasOwn } from "./object/has_own"; diff --git a/lib/src/iterator/create.ts b/lib/src/iterator/create.ts index 03573a47..596ef532 100644 --- a/lib/src/iterator/create.ts +++ b/lib/src/iterator/create.ts @@ -150,6 +150,63 @@ export function makeIterable(target: T, ctx: CreateIteratorContext): T return target as T & Iterable; } +/** + * Create an iterable iterator which conforms to both the `Iterator` and `Iterable` protocols, + * it uses the provided `ctx` to manage moving to the `next` value and can be used directly in + * `for...of` loops or consumed via `.next()` calls. + * + * The returned object satisfies both protocols: its `[Symbol.iterator]()` method returns itself, + * making it usable anywhere an `Iterable` is expected, while also exposing the `next()` method + * of the `Iterator` protocol. + * + * @see [Iterator protocol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterator_protocol) + * @see [Iterable protocol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterable_protocol) + * @since 0.14.0 + * @group Iterator + * @typeParam T - Identifies the type that will be returned by the iterator + * @param ctx - The context used to manage the iteration over the items. + * @returns A new IterableIterator instance + * @example + * ```ts + * let idx = -1; + * let theValues = [ 5, 10, 15, 20, 25, 30 ]; + * + * let theIterator = createIterableIterator({ + * n: function() { + * idx++; + * let isDone = idx >= theValues.length; + * if (!isDone) { + * this.v = theValues[idx]; + * } + * return isDone; + * } + * }); + * + * // Can be consumed as an iterator + * theIterator.next(); // { value: 5, done: false } + * + * // Or used in a for...of loop (Iterable protocol) + * let values: number[] = []; + * for (const value of theIterator) { + * values.push(value); + * } + * // Values: [10, 15, 20, 25, 30] + * ``` + */ +/*#__NO_SIDE_EFFECTS__*/ +export function createIterableIterator(ctx: CreateIteratorContext): IterableIterator { + let iterator = createIterator(ctx); + let itSymbol = getKnownSymbol(WellKnownSymbols.iterator); + + function _createIterator() { + return iterator; + } + + (iterator as any)[itSymbol] = _createIterator; + + return iterator as IterableIterator; +} + /** * Create an iterator which conforms to the `Iterator` protocol, it uses the provided `ctx` to * managed moving to the `next`. diff --git a/lib/src/polyfills.ts b/lib/src/polyfills.ts index a65483a1..9c031ea4 100644 --- a/lib/src/polyfills.ts +++ b/lib/src/polyfills.ts @@ -29,6 +29,8 @@ import { polyObjHasOwn } from "./object/has_own"; import { polyArrAt } from "./array/at"; import { polyArrFill } from "./array/fill"; import { polyArrWith } from "./array/with"; +import { polyStrAt } from "./string/at"; +import { polyStrMatchAll } from "./string/match_all"; (function () { @@ -60,7 +62,9 @@ import { polyArrWith } from "./array/with"; "trimRight": polyStrTrimEnd, "substr": polyStrSubstr, "includes": polyStrIncludes, - "replaceAll": polyStrReplaceAll + "replaceAll": polyStrReplaceAll, + "at": polyStrAt, + "matchAll": polyStrMatchAll }; const arrayClsPolyfills = { diff --git a/lib/src/string/at.ts b/lib/src/string/at.ts new file mode 100644 index 00000000..25c86069 --- /dev/null +++ b/lib/src/string/at.ts @@ -0,0 +1,75 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { polyArrAt } from "../array/at"; +import { StrProto } from "../internal/constants"; +import { _throwIfNullOrUndefined } from "../internal/throwIf"; +import { _unwrapFunctionWithPoly } from "../internal/unwrapFunction"; +import { mathToInt } from "../math/to_int"; +import { asString } from "./as_string"; + +/** + * The `strAt()` method takes an integer value and returns a new string consisting of the single + * UTF-16 code unit located at the specified offset into the string. It accepts both positive and + * negative integers: negative integers count back from the last string character. + * + * This is the equivalent of `String.prototype.at()` and falls back to {@link polyStrAt} in + * environments where the native method is unavailable. + * + * @function + * @since 0.14.0 + * @group String + * @param value - The string value to retrieve a character from. + * @param index - The zero-based index of the character to return. A negative index counts from + * the end of the string: `-1` returns the last character, `-2` the second-to-last, and so on. + * @returns A single-character string at the given index, or `undefined` if the index is out of + * range. + * @throws `TypeError` if `value` is `null` or `undefined`. + * @example + * ```ts + * strAt("hello", 0); // "h" + * strAt("hello", 1); // "e" + * strAt("hello", -1); // "o" — last character + * strAt("hello", -2); // "l" — second-to-last + * strAt("hello", 99); // undefined — out of range + * strAt("hello", -99); // undefined — out of range + * ``` + */ +export const strAt: (value: string, index: number) => string | undefined = (/*#__PURE__*/_unwrapFunctionWithPoly("at", StrProto as any, polyStrAt)); + +/** + * Polyfill implementation of `String.prototype.at()` that returns the character at the given + * integer index, supporting negative indices which count back from the end of the string. + * + * Delegates index normalisation and bounds checking to {@link polyArrAt} by treating the string + * as an array-like object, matching native `String.prototype.at()` behaviour exactly. + * + * @since 0.14.0 + * @group String + * @group Polyfill + * @param value - The string value to retrieve a character from. + * @param index - The zero-based index of the character to return. Negative values count from the + * end: `-1` is the last character, `-2` is the second-to-last, and so on. + * @returns A single-character string at the normalised index, or `undefined` if out of range. + * @throws `TypeError` if `value` is `null` or `undefined`. + * @example + * ```ts + * polyStrAt("hello", 0); // "h" + * polyStrAt("hello", -1); // "o" + * polyStrAt("hello", -2); // "l" + * polyStrAt("hello", 99); // undefined + * polyStrAt("hello", -99); // undefined + * ``` + */ +/*#__NO_SIDE_EFFECTS__*/ +export function polyStrAt(value: string, index: number): string | undefined { + _throwIfNullOrUndefined(value); + + // Reuse the Array.at polyfill for index normalization and bounds behavior. + return polyArrAt(asString(value), mathToInt(index)); +} diff --git a/lib/src/string/match_all.ts b/lib/src/string/match_all.ts new file mode 100644 index 00000000..6ef5659a --- /dev/null +++ b/lib/src/string/match_all.ts @@ -0,0 +1,220 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { isFunction, isRegExp, isStrictNullOrUndefined } from "../helpers/base"; +import { throwTypeError } from "../helpers/throw"; +import { EMPTY, StrProto } from "../internal/constants"; +import { _throwIfNullOrUndefined } from "../internal/throwIf"; +import { _unwrapFunctionWithPoly } from "../internal/unwrapFunction"; +import { asString } from "./as_string"; +import { mathMax } from "../math/min_max"; +import { getKnownSymbol } from "../symbol/symbol"; +import { WellKnownSymbols } from "../symbol/well_known"; +import { createIterableIterator } from "../iterator/create"; + +/** + * The `strMatchAll()` method returns an iterator of all results matching a string against a + * regular expression, including capturing groups. Unlike {@link strMatch}, it returns every + * match in the string (not just the first) and each result includes full `RegExpExecArray` + * details: matched substrings, capture groups, the match `index`, and the original `input` string. + * + * If `matcher` is a `RegExp` it **must** have the global (`g`) flag set; a non-global `RegExp` + * throws a `TypeError`. If `matcher` is not a `RegExp`, native behavior is followed by creating + * a global `RegExp` from the matcher value (for example, `"."` matches any character and + * `undefined` behaves as an empty pattern). + * + * If `matcher` is an object that implements `[Symbol.matchAll]`, that method is called instead, + * allowing custom matcher objects to control the iteration. + * + * @function + * @since 0.14.0 + * @group String + * @param value - The string value to search. + * @param matcher - A `RegExp` with the global flag set, a matcher value used to create a global + * `RegExp`, or any object with a `[Symbol.matchAll]` method. + * @returns An `IterableIterator` where each `RegExpExecArray` contains: + * - `[0]` — the full matched substring + * - `[1..n]` — captured groups (or `undefined` for unmatched optional groups) + * - `.index` — zero-based index of the match in `value` + * - `.input` — the original string + * - `.groups` — named capture groups object, or `undefined` if no named captures + * @throws `TypeError` if `matcher` is a `RegExp` without the global (`g`) flag. + * @throws `TypeError` if `value` is `null` or `undefined`. + * @example + * ```ts + * // Basic global regex — all matches with capture groups + * const matches = [...strMatchAll("test1 test2 test3", /test(\d)/g)]; + * matches.length; // 3 + * matches[0][0]; // "test1" + * matches[0][1]; // "1" + * matches[0].index; // 0 + * matches[1][0]; // "test2" + * matches[2][1]; // "3" + * ``` + * @example + * ```ts + * // Named capture groups + * const re = /(?\d{4})-(?\d{2})-(?\d{2})/g; + * const dates = [...strMatchAll("2024-01-15 and 2025-06-30", re)]; + * dates[0].groups; // { year: "2024", month: "01", day: "15" } + * dates[1].groups; // { year: "2025", month: "06", day: "30" } + * ``` + * @example + * ```ts + * // String matcher — treated like native RegExp(pattern, "g") + * const hits = [...strMatchAll("banana", "an")]; + * hits.length; // 2 + * hits[0].index; // 1 + * hits[1].index; // 3 + * ``` + * @example + * ```ts + * // Non-global RegExp throws TypeError + * strMatchAll("hello", /l/); // throws TypeError + * ``` + */ +export const strMatchAll: (value: string, matcher: string | RegExp) => IterableIterator = (/*#__PURE__*/_unwrapFunctionWithPoly("matchAll", StrProto as any, polyStrMatchAll)); + +/*#__NO_SIDE_EFFECTS__*/ +function _cloneRegExp(theRegex: RegExp): RegExp { + let flags = (theRegex as any).flags; + if (flags === undefined) { + flags = EMPTY; + theRegex.global && (flags += "g"); + theRegex.ignoreCase && (flags += "i"); + theRegex.multiline && (flags += "m"); + (theRegex as any).dotAll && (flags += "s"); + theRegex.unicode && (flags += "u"); + theRegex.sticky && (flags += "y"); + } + + // eslint-disable-next-line security/detect-non-literal-regexp + let result = new RegExp(theRegex.source, flags); + // Normalize lastIndex using ToLength semantics: integers only, negatives clamped to 0. + result.lastIndex = mathMax(0, theRegex.lastIndex); + + return result; +} + +/*#__NO_SIDE_EFFECTS__*/ +function _advanceStringIndex(value: string, index: number, unicode: boolean): number { + let newIndex = index + 1; + if (unicode) { + if (index < value.length) { + let first = value.charCodeAt(index); + if (first >= 0xD800 && first <= 0xDBFF) { + let second = value.charCodeAt(index + 1); + if (second >= 0xDC00 && second <= 0xDFFF) { + newIndex++; + } + } + } + } + + return newIndex; +} + +/** + * Polyfill implementation of `String.prototype.matchAll()` that returns an iterator of all + * results matching a string against a regular expression, including capturing groups. + * + * Matches the behaviour of the native `String.prototype.matchAll()` method: + * - A `RegExp` `matcher` **must** carry the global (`g`) flag; passing a non-global `RegExp` + * throws a `TypeError`. + * - A non-`RegExp` `matcher` is passed to `new RegExp(matcher, "g")`, mirroring native behavior. + * - If `matcher` exposes a `[Symbol.matchAll]` method, that method is called with the coerced + * string value, mirroring the native delegation behaviour. + * - Zero-length matches advance `lastIndex` using native-style `AdvanceStringIndex` behavior, + * including code-point advancement for unicode regexes. + * + * @since 0.14.0 + * @group String + * @group Polyfill + * @param value - The string value to search. + * @param matcher - A `RegExp` with the global flag set, a matcher value used to create a global + * `RegExp`, or any object with a `[Symbol.matchAll]` method. + * @returns An `IterableIterator` where each `RegExpExecArray` contains: + * - `[0]` — the full matched substring + * - `[1..n]` — captured groups (or `undefined` for unmatched optional groups) + * - `.index` — zero-based index of the match in `value` + * - `.input` — the original string + * - `.groups` — named capture groups object, or `undefined` if no named captures + * @throws `TypeError` if `matcher` is a `RegExp` without the global (`g`) flag. + * @throws `TypeError` if `value` is `null` or `undefined`. + * @example + * ```ts + * // Basic global regex + * const matches = [...polyStrMatchAll("test1 test2", /test(\d)/g)]; + * matches.length; // 2 + * matches[0][0]; // "test1" + * matches[0][1]; // "1" + * matches[0].index; // 0 + * ``` + * @example + * ```ts + * // String matcher (native RegExp(pattern, "g") semantics) + * const hits = [...polyStrMatchAll("banana", "an")]; + * hits.length; // 2 + * hits[0].index; // 1 + * hits[1].index; // 3 + * ``` + * @example + * ```ts + * // Named capture groups + * const re = /(?\w+)/g; + * const words = [...polyStrMatchAll("hello world", re)]; + * words[0].groups; // { word: "hello" } + * words[1].groups; // { word: "world" } + * ``` + */ +/*#__NO_SIDE_EFFECTS__*/ +export function polyStrMatchAll(value: string, matcher: string | RegExp): IterableIterator { + _throwIfNullOrUndefined(value); + + let result: IterableIterator; + + // Mirror native IsRegExp semantics: a RegExp whose @@match property is explicitly set + // to false must not be treated as a regular expression (per ECMAScript spec). + let matchSym = getKnownSymbol(WellKnownSymbols.match); + let isMatcherRegExp = isRegExp(matcher) + && (!matcher || isStrictNullOrUndefined((matcher as any)[matchSym]) || (matcher as any)[matchSym] !== false); + + if (isMatcherRegExp && !(matcher as RegExp).global) { + throwTypeError("matcher must be a global regular expression"); + } + + let theValue = asString(value); + let matchAllFn: (regexp: string) => Iterator = matcher && (matcher as any)[getKnownSymbol(WellKnownSymbols.matchAll)]; + if (isFunction(matchAllFn)) { + result = matchAllFn.call(matcher, theValue); + } else { + // eslint-disable-next-line security/detect-non-literal-regexp + let theRegex: RegExp = isMatcherRegExp ? _cloneRegExp(matcher as RegExp) : new RegExp(matcher as any, "g"); + + let ctx = { + n: () => { + let match = theRegex.exec(theValue) as RegExpExecArray; + if (!match) { + return true; + } + + if (match[0] === EMPTY) { + theRegex.lastIndex = _advanceStringIndex(theValue, theRegex.lastIndex, !!(theRegex as any).unicode); + } + + ctx.v = match; + return false; + }, + v: undefined as RegExpExecArray + }; + + result = createIterableIterator(ctx); + } + + return result; +} diff --git a/lib/src/string/replace_all.ts b/lib/src/string/replace_all.ts index 373c31a1..8c899a13 100644 --- a/lib/src/string/replace_all.ts +++ b/lib/src/string/replace_all.ts @@ -8,6 +8,7 @@ import { isFunction, isRegExp, isStrictNullOrUndefined, isString } from "../helpers/base"; import { throwTypeError } from "../helpers/throw"; +import { createLiteralRegex } from "../helpers/regexp"; import { StrProto } from "../internal/constants"; import { _throwIfNullOrUndefined } from "../internal/throwIf"; import { _unwrapFunctionWithPoly } from "../internal/unwrapFunction"; @@ -37,15 +38,52 @@ import { strReplace } from "./replace"; export const strReplaceAll: (value: string, searchValue: string | RegExp, replaceValue: string | ((substring: string, ...args: any[]) => string)) => string = (/*#__PURE__*/_unwrapFunctionWithPoly("replaceAll", StrProto as any, polyStrReplaceAll)); /** - * Polyfill implementation of String.prototype.replaceAll(). + * Polyfill implementation of `String.prototype.replaceAll()` that returns a new string with all + * matches of a pattern replaced by a replacement. + * + * Matches the behaviour of the native `String.prototype.replaceAll()` method: + * - A `RegExp` `searchValue` **must** carry the global (`g`) flag; a non-global `RegExp` throws a + * `TypeError`. + * - A string `searchValue` is treated as a literal pattern (special regex characters are escaped) + * and all occurrences are replaced. + * - If `searchValue` exposes a `[Symbol.replace]` method, that method is called with the coerced + * string value and `replaceValue`, mirroring the native delegation behaviour. + * - If `replaceValue` is a function it is invoked for each match with the matched substring, + * captured groups, the match index, and the original string. + * * @since 0.14.0 * @group String * @group Polyfill * @param value - The string value to search and replace within. - * @param searchValue - The value to search for. Can be a string or a global regular expression. - * @param replaceValue - The replacement string or replacer function. - * @returns A new string with every occurrence of searchValue replaced. - * @throws TypeError if searchValue is a regular expression without the global flag. + * @param searchValue - The value to search for. Can be a string, a global `RegExp`, or any object + * with a `[Symbol.replace]` method. + * @param replaceValue - The replacement string (may use `$&`, `$1`…`$n`, `$\`` and `$'` patterns) + * or a replacer function called once per match. + * @returns A new string with every occurrence of `searchValue` replaced by `replaceValue`. + * @throws `TypeError` if `searchValue` is a `RegExp` without the global (`g`) flag. + * @throws `TypeError` if `value` is `null` or `undefined`. + * @example + * ```ts + * // String literal replacement — replaces every occurrence + * polyStrReplaceAll("aabbcc", "b", "x"); // "aaxxcc" + * polyStrReplaceAll("a-b-a", "a", "x"); // "x-b-x" + * ``` + * @example + * ```ts + * // Global RegExp replacement + * polyStrReplaceAll("abc123abc", /abc/g, "X"); // "X123X" + * ``` + * @example + * ```ts + * // Replacer function + * polyStrReplaceAll("abca", "a", (match, offset) => match.toUpperCase() + offset); + * // "A0bcA3" + * ``` + * @example + * ```ts + * // Special regex characters in search string are escaped + * polyStrReplaceAll("a.b.c", ".", "-"); // "a-b-c" (dot is literal) + * ``` */ /*#__NO_SIDE_EFFECTS__*/ export function polyStrReplaceAll(value: string, searchValue: string | RegExp, replaceValue: string | ((substring: string, ...args: any[]) => string)): string { @@ -77,8 +115,7 @@ export function polyStrReplaceAll(value: string, searchValue: string | RegExp, r let search = isString(searchValue) ? searchValue : asString(searchValue); - // eslint-disable-next-line security/detect-non-literal-regexp - matcher = new RegExp(strReplace(search, /[.*+?^${}()|[\]\\]/g, "\\$&") || "(?:)", "g"); + matcher = createLiteralRegex(search); } return strReplace(theValue, matcher, replaceValue as any); diff --git a/lib/test/bundle-size-check.js b/lib/test/bundle-size-check.js index c177bcd4..1efa066c 100644 --- a/lib/test/bundle-size-check.js +++ b/lib/test/bundle-size-check.js @@ -7,13 +7,13 @@ const configs = [ { name: "es5-min-full", path: "../bundle/es5/umd/ts-utils.min.js", - limit: 31 * 1024, // 31 kb in bytes + limit: 32 * 1024, // 32 kb in bytes compress: false }, { name: "es6-min-full", path: "../bundle/es6/umd/ts-utils.min.js", - limit: 31 * 1024, // 31 kb in bytes + limit: 32 * 1024, // 32 kb in bytes compress: false }, { @@ -25,13 +25,13 @@ const configs = [ { name: "es6-min-zip", path: "../bundle/es6/umd/ts-utils.min.js", - limit: 12.5 * 1024, // 12.5 kb in bytes + limit: 12.75 * 1024, // 12.75 kb in bytes compress: true }, { name: "es5-min-poly", path: "../bundle/es5/ts-polyfills-utils.min.js", - limit: 9.5 * 1024, // 9.5 kb in bytes + limit: 10.5 * 1024, // 10.5 kb in bytes compress: false } ]; diff --git a/lib/test/src/common/helpers/regexp.test.ts b/lib/test/src/common/helpers/regexp.test.ts index c718ceaf..6ce19712 100644 --- a/lib/test/src/common/helpers/regexp.test.ts +++ b/lib/test/src/common/helpers/regexp.test.ts @@ -9,7 +9,7 @@ import { assert } from "@nevware21/tripwire-chai"; import { arrForEach } from "../../../../src/array/forEach"; import { dumpObj } from "../../../../src/helpers/diagnostics"; -import { createFilenameRegex, createWildcardRegex, makeGlobRegex } from "../../../../src/helpers/regexp"; +import { createFilenameRegex, createLiteralRegex, createWildcardRegex, makeGlobRegex } from "../../../../src/helpers/regexp"; import { EMPTY } from "../../../../src/internal/constants"; function _checkResult(theRegEx: RegExp, theValue: any, isMatch: boolean, expected: Array | null) { @@ -439,4 +439,108 @@ describe("makeGlobRegex", () => { regex = makeGlobRegex("**[-+|^$#.?{}()]**\\\\/\"\'*", true, true); assert.equal(regex.source, "^(.*)\\[\\-\\+\\|\\^\\$\\#\\.([^\\\\\\/]{1})\\{\\}\\(\\)\\](.*[\\\\\\/])*[\\\\\\/]{1}[\\\\\\/]{1}\\\"\\'([^\\\\\\/]*)$"); }); +}); + +describe("createLiteralRegex", () => { + it("null/undefined coerced to string", () => { + let regex = createLiteralRegex(null as any); + assert.equal(regex.source, "null", dumpObj(regex)); + assert.equal(regex.global, true, "should be global"); + let result = regex.exec("null"); + assert.notEqual(result, null); + assert.equal(result[0], "null"); + + regex = createLiteralRegex(undefined as any); + assert.equal(regex.source, "undefined", dumpObj(regex)); + result = regex.exec("undefined"); + assert.notEqual(result, null); + assert.equal(result[0], "undefined"); + }); + + it("empty string produces (?:) which matches everywhere", () => { + let regex = createLiteralRegex(EMPTY); + assert.equal(regex.source, "(?:)", dumpObj(regex)); + assert.notEqual(regex.exec(""), null); + + regex = createLiteralRegex(EMPTY); + assert.notEqual(regex.exec("hello"), null); + }); + + it("matches literal dot — not as a wildcard", () => { + let result = createLiteralRegex("Hello.World").exec("Hello.World"); + assert.notEqual(result, null, "should match the literal dot"); + assert.equal(result[0], "Hello.World"); + + assert.equal(createLiteralRegex("Hello.World").exec("HelloXWorld"), null, "dot must NOT match any char"); + }); + + it("matches literal parentheses", () => { + let result = createLiteralRegex("(test)").exec("(test)"); + assert.notEqual(result, null); + assert.equal(result[0], "(test)"); + + assert.equal(createLiteralRegex("(test)").exec("test"), null, "parens are literal, not grouping"); + }); + + it("treats + as a literal character", () => { + let result = createLiteralRegex("a+b").exec("a+b"); + assert.notEqual(result, null); + assert.equal(result[0], "a+b"); + + assert.equal(createLiteralRegex("a+b").exec("ab"), null, "should not match ab"); + assert.equal(createLiteralRegex("a+b").exec("aab"), null, "should not match aab"); + }); + + it("treats ^ and $ as literals, not anchors", () => { + assert.equal(createLiteralRegex("^start").exec("start"), null, "^ is not an anchor"); + + let result = createLiteralRegex("^start").exec("^start"); + assert.notEqual(result, null); + assert.equal(result[0], "^start"); + + assert.equal(createLiteralRegex("end$").exec("end"), null, "$ is not an anchor"); + + result = createLiteralRegex("end$").exec("end$"); + assert.notEqual(result, null); + assert.equal(result[0], "end$"); + }); + + it("is global — exec loop finds all matches", () => { + let regex = createLiteralRegex("an"); + let matches: string[] = []; + let m: RegExpExecArray; + while ((m = regex.exec("banana")) !== null) { + matches.push(m[0]); + } + assert.equal(matches.length, 2, "banana contains 'an' twice"); + assert.equal(matches[0], "an"); + assert.equal(matches[1], "an"); + }); + + it("literal dot matched globally", () => { + let regex = createLiteralRegex("."); + let matches: string[] = []; + let m: RegExpExecArray; + while ((m = regex.exec("a.b.c")) !== null) { + matches.push(m[0]); + } + assert.equal(matches.length, 2, "two literal dots in a.b.c"); + assert.equal(matches[0], "."); + assert.equal(matches[1], "."); + }); + + it("escapes all special regex metacharacters", () => { + // Each character is a regex metachar; createLiteralRegex should match them all literally + let specials = [".", "*", "+", "?", "^", "$", "{", "}", "(", ")", "|", "[", "]", "\\"]; + arrForEach(specials, (ch) => { + let regex = createLiteralRegex(ch); + let result = regex.exec(ch); + assert.notEqual(result, null, "should match literal " + ch); + assert.equal(result[0], ch); + }); + }); + + it("no match returns null", () => { + assert.equal(createLiteralRegex("xyz").exec("hello world"), null); + }); }); \ No newline at end of file diff --git a/lib/test/src/common/iterator/create.test.ts b/lib/test/src/common/iterator/create.test.ts index dfd2a244..79ad7d01 100644 --- a/lib/test/src/common/iterator/create.test.ts +++ b/lib/test/src/common/iterator/create.test.ts @@ -10,8 +10,10 @@ import { assert } from "@nevware21/tripwire-chai"; import { dumpObj } from "../../../../src/helpers/diagnostics"; import { throwError } from "../../../../src/helpers/throw"; import { createArrayIterator } from "../../../../src/iterator/array"; -import { createIterable, createIterator, CreateIteratorContext } from "../../../../src/iterator/create"; import { iterForOf } from "../../../../src/iterator/forOf"; +import { createIterable, createIterableIterator, createIterator, CreateIteratorContext } from "../../../../src/iterator/create"; +import { getKnownSymbol } from "../../../../src/symbol/symbol"; +import { WellKnownSymbols } from "../../../../src/symbol/well_known"; import { objKeys } from "../../../../src/object/object"; @@ -179,8 +181,7 @@ describe("create iterator helpers", () => { iterForOf(createIterable(fibCtx), (value) => { assert.ok(false, "Should not be called"); }); - - assert.equal(done, false, "Check that the return was not called as it doesn't need to be"); + assert.equal(done, false, "Check that the return was called"); }); it("with throw", () => { @@ -235,4 +236,202 @@ describe("create iterator helpers", () => { assert.equal(values[9], 34, "9:" + dumpObj(values)); }); }); + + describe("createIterableIterator", () => { + it("satisfies Iterator protocol via next()", () => { + let theValues = [5, 10, 15]; + let idx = -1; + let theIterator = createIterableIterator({ + n: function() { + idx++; + let isDone = idx >= theValues.length; + if (!isDone) { + this.v = theValues[idx]; + } + return isDone; + } + }); + + let r1 = theIterator.next(); + assert.equal(r1.done, false); + assert.equal(r1.value, 5); + + let r2 = theIterator.next(); + assert.equal(r2.done, false); + assert.equal(r2.value, 10); + + let r3 = theIterator.next(); + assert.equal(r3.done, false); + assert.equal(r3.value, 15); + + let r4 = theIterator.next(); + assert.equal(r4.done, true); + }); + + it("Symbol.iterator() returns itself", () => { + let theIterator = createIterableIterator({ + n: function() { + return true; + } + }); + + let itSymbol = getKnownSymbol(WellKnownSymbols.iterator); + let iterFn = (theIterator as any)[itSymbol]; + assert.ok(typeof iterFn === "function", "[Symbol.iterator] must be a function"); + assert.strictEqual(iterFn.call(theIterator), theIterator, + "[Symbol.iterator]() must return the iterator itself"); + }); + + it("satisfies Iterable protocol via iterForOf", () => { + let theValues = [5, 10, 15, 20, 25, 30]; + let idx = -1; + let theIterator = createIterableIterator({ + n: function() { + idx++; + let isDone = idx >= theValues.length; + if (!isDone) { + this.v = theValues[idx]; + } + return isDone; + } + }); + + let values: number[] = []; + iterForOf(theIterator, (value) => { + values.push(value); + }); + + assert.equal(values.length, 6, "" + dumpObj(values)); + assert.equal(values[0], 5); + assert.equal(values[1], 10); + assert.equal(values[2], 15); + assert.equal(values[3], 20); + assert.equal(values[4], 25); + assert.equal(values[5], 30); + }); + + it("partial next() then iterForOf continues from same position", () => { + // [Symbol.iterator]() returns self, so the iterable and iterator share state. + let theValues = [1, 2, 3, 4, 5]; + let idx = -1; + let theIterator = createIterableIterator({ + n: function() { + idx++; + let isDone = idx >= theValues.length; + if (!isDone) { + this.v = theValues[idx]; + } + return isDone; + } + }); + + // Consume first two elements via direct next() calls. + assert.equal(theIterator.next().value, 1); + assert.equal(theIterator.next().value, 2); + + // iterForOf should see only the remaining three elements. + let remaining: number[] = []; + iterForOf(theIterator, (value) => { + remaining.push(value); + }); + + assert.equal(remaining.length, 3); + assert.equal(remaining[0], 3); + assert.equal(remaining[1], 4); + assert.equal(remaining[2], 5); + }); + + it("return callback is invoked on early iterForOf exit", () => { + let done = false; + let idx = -1; + let theValues = [1, 2, 3, 4, 5]; + let theIterator = createIterableIterator({ + n: function() { + idx++; + let isDone = idx >= theValues.length; + if (!isDone) { + this.v = theValues[idx]; + } + return isDone; + }, + r: function(value) { + done = true; + return value; + } + }); + + let values: number[] = []; + iterForOf(theIterator, (value) => { + values.push(value); + if (values.length === 2) { + return -1; // signal early exit + } + }); + + assert.equal(done, true, "return callback must be called on early exit"); + assert.equal(values.length, 2); + }); + + it("throw callback is invoked on iterForOf error", () => { + let thrown = false; + let idx = -1; + let theValues = [1, 2, 3]; + let theIterator = createIterableIterator({ + n: function() { + idx++; + let isDone = idx >= theValues.length; + if (!isDone) { + this.v = theValues[idx]; + } + return isDone; + }, + t: function(value) { + thrown = true; + return value; + } + }); + + try { + iterForOf(theIterator, (value) => { + if (value === 2) { + throwError("stop!"); + } + }); + assert.ok(false, "exception should have propagated"); + } catch (e) { + assert.ok(true, "expected exception caught"); + } + + assert.equal(thrown, true, "throw callback must be called when iterForOf body throws"); + }); + + it("empty sequence returns done immediately", () => { + let theIterator = createIterableIterator({ + n: function() { + return true; + } + }); + + let values: number[] = []; + iterForOf(theIterator, (value) => { + values.push(value); + }); + + assert.equal(values.length, 0, "no values should have been produced"); + assert.equal(theIterator.next().done, true, "next() must also be done"); + }); + + it("with no next function returns done immediately", () => { + let theIterator = createIterableIterator({ + n: null as any + }); + + let values: number[] = []; + iterForOf(theIterator, (value) => { + values.push(value); + }); + + assert.equal(values.length, 0, "null next should produce no values"); + }); + }); }); \ No newline at end of file diff --git a/lib/test/src/common/string/at.test.ts b/lib/test/src/common/string/at.test.ts new file mode 100644 index 00000000..eac70146 --- /dev/null +++ b/lib/test/src/common/string/at.test.ts @@ -0,0 +1,109 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire-chai"; +import { dumpObj } from "../../../../src/helpers/diagnostics"; +import { polyStrAt, strAt } from "../../../../src/string/at"; + +describe("strAt helper", () => { + it("returns characters for positive and negative indexes", () => { + assert.equal(strAt("hello", 0), "h"); + assert.equal(strAt("hello", 1), "e"); + assert.equal(strAt("hello", -1), "o"); + assert.equal(strAt("hello", -2), "l"); + }); + + it("returns undefined for out of range indexes", () => { + assert.isUndefined(strAt("hello", 10)); + assert.isUndefined(strAt("hello", -10)); + }); + + it("coerces non-string values", () => { + assert.equal(strAt(12345 as any, 2), "3"); + assert.equal(polyStrAt(false as any, 1), "a"); + }); + + it("throws for null and undefined", () => { + _expectThrow(() => { + strAt(null as any, 0); + }); + + _expectThrow(() => { + polyStrAt(undefined as any, 0); + }); + }); +}); + +describe("polyStrAt helper", () => { + it("matches native String.prototype.at behavior", () => { + let testCases: { value: any; index: any }[] = [ + { value: "hello", index: 0 }, + { value: "hello", index: 2 }, + { value: "hello", index: -1 }, + { value: "hello", index: -6 }, + { value: "hello", index: 99 }, + { value: "hello", index: NaN }, + { value: 12345, index: 1 }, + { value: true, index: 2 }, + { value: { toString: () => "xyz" }, index: -1 } + ]; + + for (let lp = 0; lp < testCases.length; lp++) { + let testCase = testCases[lp]; + _checkNativeParity("strAt", (value, index) => { + return strAt(value, index); + }, testCase.value, testCase.index); + + _checkNativeParity("polyStrAt", (value, index) => { + return polyStrAt(value, index); + }, testCase.value, testCase.index); + } + }); +}); + +function _expectThrow(cb: () => void): Error { + try { + cb(); + } catch (e) { + assert.ok(true, "Expected an exception to be thrown"); + return e as Error; + } + + assert.ok(false, "Expected an exception to be thrown"); + return null as any; +} + +function _checkNativeParity(testName: string, testFn: (value: any, index: any) => string | undefined, value: any, index: any) { + let testResult: any; + let nativeResult: any; + let testThrew: any; + let nativeThrew: any; + + try { + testResult = testFn(value, index); + } catch (e) { + testThrew = e; + } + + try { + nativeResult = (String.prototype as any).at.call(value, index); + } catch (e) { + nativeThrew = e; + } + + if (testThrew) { + assert.equal(true, !!nativeThrew, + "Checking whether Native and " + testName + " both threw [" + dumpObj(testThrew) + "] - [" + dumpObj(nativeThrew || nativeResult) + "] for [" + dumpObj(value) + "] [" + dumpObj(index) + "]"); + } else if (nativeThrew) { + assert.ok(false, + "Native threw but " + testName + " did not [" + dumpObj(testResult) + "] - [" + dumpObj(nativeThrew) + "] for [" + dumpObj(value) + "] [" + dumpObj(index) + "]"); + } else { + assert.equal(testResult, nativeResult, + "Checking whether Native and " + testName + " returned the same [" + dumpObj(testResult) + "] - [" + dumpObj(nativeResult) + "] for [" + dumpObj(value) + "] [" + dumpObj(index) + "]"); + } +} diff --git a/lib/test/src/common/string/match_all.test.ts b/lib/test/src/common/string/match_all.test.ts new file mode 100644 index 00000000..b5de9a0d --- /dev/null +++ b/lib/test/src/common/string/match_all.test.ts @@ -0,0 +1,327 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire-chai"; +import { dumpObj } from "../../../../src/helpers/diagnostics"; +import { polyStrMatchAll, strMatchAll } from "../../../../src/string/match_all"; +import { getKnownSymbol } from "../../../../src/symbol/symbol"; +import { WellKnownSymbols } from "../../../../src/symbol/well_known"; +import { iterForOf } from "../../../../src/iterator/forOf"; + +function _toMatchInfo(iterator: Iterator): string[] { + let result: string[] = []; + let next = iterator.next(); + while (!next.done) { + let current = next.value; + let captures = (current as any).slice ? (current as any).slice(1) : []; + result.push((current.index as any) + ":" + current[0] + ":" + JSON.stringify(captures)); + next = iterator.next(); + } + + return result; +} + +function _expectThrow(cb: () => void): Error { + try { + cb(); + } catch (e) { + assert.ok(true, "Expected an exception to be thrown"); + return e as Error; + } + + assert.ok(false, "Expected an exception to be thrown"); + return null as any; +} + +describe("strMatchAll helper", () => { + it("matches with global regex including captures", () => { + let matches = _toMatchInfo(strMatchAll("test1test2", /test(\d)/g)); + assert.deepEqual(matches, [ + "0:test1:[\"1\"]", + "5:test2:[\"2\"]" + ]); + }); + + it("supports string matcher", () => { + let matches = _toMatchInfo(strMatchAll("banana", "an" as any)); + assert.deepEqual(matches, [ + "1:an:[]", + "3:an:[]" + ]); + }); + + it("matches native string-pattern semantics for meta characters", () => { + _checkNativeParity("strMatchAll", (value, matcher) => { + return strMatchAll(value, matcher); + }, "a.b", "."); + + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "a.b", "."); + }); + + it("throws for non-global regex", () => { + _checkNativeParity("strMatchAll", (value, matcher) => { + return strMatchAll(value, matcher); + }, "abc", /a/); + + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "abc", /a/); + }); + + it("RegExp with @@match=false is treated as a non-RegExp pattern", () => { + let matchSym = getKnownSymbol(WellKnownSymbols.match); + // A non-global RegExp normally throws TypeError; setting @@match=false makes native + // treat it as a non-RegExp, so it should be used as a constructor argument instead. + let re = /ab/; + (re as any)[matchSym] = false; + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "xabxabx", re as any); + }); + + it("RegExp with @@match=true still enforces global flag", () => { + let matchSym = getKnownSymbol(WellKnownSymbols.match); + let re = /ab/; + (re as any)[matchSym] = true; + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "xabxabx", re as any); + }); + + it("delegates to @@matchAll when present", () => { + let matchAllSym = getKnownSymbol(WellKnownSymbols.matchAll); + let iteratorSym = getKnownSymbol(WellKnownSymbols.iterator); + let matcher = { + [matchAllSym]: (_value: string) => { + return { + next: () => ({ done: true, value: undefined as any }), + [iteratorSym]: function() { + return this; + } + }; + } + }; + + assert.deepEqual(_toMatchInfo(polyStrMatchAll("abc", matcher as any)), []); + }); + + it("matches native semantics for representative cases", () => { + let testCases: { value: any; matcher: any }[] = [ + { value: "banana", matcher: /an/g }, + { value: "test1test2", matcher: /test(\d)/g }, + { value: "abc", matcher: "" }, + { value: "abc", matcher: "b" }, + { value: 123123, matcher: "23" }, + { value: "a1b2", matcher: /\d/g } + ]; + + for (let lp = 0; lp < testCases.length; lp++) { + let testCase = testCases[lp]; + _checkNativeParity("strMatchAll", (value, matcher) => { + return strMatchAll(value, matcher); + }, testCase.value, testCase.matcher); + + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, testCase.value, testCase.matcher); + } + }); + + it("throws for null and undefined value", () => { + _expectThrow(() => polyStrMatchAll(null as any, /a/g)); + _expectThrow(() => polyStrMatchAll(undefined as any, /a/g)); + }); + + it("null matcher treated as literal string 'null'", () => { + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "null value null", null); + }); + + it("undefined matcher follows native empty-pattern behavior", () => { + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "undefined and undefined", undefined); + }); + + it("zero-length match advances lastIndex to avoid infinite loop", () => { + // /a*/g matches zero-length at positions between non-'a' chars + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "bbb", /a*/g); + + // empty pattern matches at every position + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "ab", /(?:)/g); + }); + + it("zero-length unicode matches advance by code point", () => { + let value = "A\uD83D\uDE00B"; + let matchAllSym = getKnownSymbol(WellKnownSymbols.matchAll); + let polyRegex = /(?:)/gu; + let nativeRegex = /(?:)/gu; + (polyRegex as any)[matchAllSym] = undefined; + + let polyResult = _toMatchInfo(polyStrMatchAll(value, polyRegex)); + let nativeResult = _toMatchInfo((String.prototype as any).matchAll.call(value, nativeRegex)); + assert.deepEqual(polyResult, nativeResult); + }); + + it("clones global regex and preserves lastIndex like native", () => { + // Native String.prototype.matchAll copies lastIndex from the source regex to the clone, + // so iteration starts from the preserved position (matching native parity). + let re = /\d/g; + re.lastIndex = 3; // simulate a regex mid-use + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "a1b2c3", re); + }); + + it("clones regex normalizing negative lastIndex to 0", () => { + let matchAllSym = getKnownSymbol(WellKnownSymbols.matchAll); + let re = /\d/g; + re.lastIndex = -5; + (re as any)[matchAllSym] = undefined; // force polyfill path + + let matches = _toMatchInfo(polyStrMatchAll("a1b2c3", re)); + // Negative lastIndex must be clamped to 0 by ToLength, so all digits are found. + assert.deepEqual(matches, ["1:1:[]", "3:2:[]", "5:3:[]"]); + }); + + it("clones regex preserving ignoreCase and multiline flags", () => { + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "Hello\nhello", /^hello/gim); + }); + + it("clones regex when flags property is unavailable", () => { + let matchAllSym = getKnownSymbol(WellKnownSymbols.matchAll); + let re = new RegExp("a", "gimuy"); + re.lastIndex = 1; + Object.defineProperty(re, "flags", { value: undefined }); + (re as any)[matchAllSym] = undefined; + + let matches = _toMatchInfo(polyStrMatchAll("ba", re)); + assert.deepEqual(matches, ["1:a:[]"]); + }); + + it("clones regex preserving dotAll when flags property is unavailable", () => { + let matchAllSym = getKnownSymbol(WellKnownSymbols.matchAll); + let re = new RegExp(".", "gs"); + Object.defineProperty(re, "flags", { value: undefined }); + (re as any)[matchAllSym] = undefined; + + let iter = polyStrMatchAll("\n", re); + let first = iter.next(); + assert.equal(first.done, false); + assert.equal(first.value[0], "\n"); + assert.equal(iter.next().done, true); + }); + + it("result exposes .index and .input on each match", () => { + let iter = polyStrMatchAll("abc", /b/g); + let first = iter.next(); + assert.equal(first.done, false); + assert.equal(first.value[0], "b"); + assert.equal(first.value.index, 1); + assert.equal(first.value.input, "abc"); + assert.equal(iter.next().done, true); + }); + + it("named capture groups are accessible on each match", () => { + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "2024-01-15 and 2025-06-30", /(?\d{4})-(?\d{2})-(?\d{2})/g); + + let iter = polyStrMatchAll("2024-01-15", /(?\d{4})-(?\d{2})-(?\d{2})/g); + let match = iter.next().value; + assert.equal(match.groups["year"], "2024"); + assert.equal(match.groups["month"], "01"); + assert.equal(match.groups["day"], "15"); + }); + + it("result supports iterable protocol (for...of)", () => { + // In ES5 compiled tests, native for...of doesn't properly call [Symbol.iterator], + // so we verify the iterable protocol using the library's iterForOf helper. + let results: string[] = []; + iterForOf(polyStrMatchAll("a1b2", /\d/g), (match) => { + results.push(match[0]); + }); + assert.deepEqual(results, ["1", "2"]); + }); + + it("result supports iterable consumption", () => { + // In ES5 compiled tests, spread syntax doesn't call [Symbol.iterator] on iterables. + // We verify the same iterable consumption using explicit iteration instead. + let matches: RegExpExecArray[] = []; + iterForOf(polyStrMatchAll("test1test2", /test(\d)/g), (m) => { + matches.push(m); + }); + assert.equal(matches.length, 2); + assert.equal(matches[0][0], "test1"); + assert.equal(matches[0][1], "1"); + assert.equal(matches[1][0], "test2"); + assert.equal(matches[1][1], "2"); + }); + + it("optional capture groups return undefined for unmatched groups", () => { + _checkNativeParity("polyStrMatchAll", (value, matcher) => { + return polyStrMatchAll(value, matcher); + }, "aXb", /(a)(X)?(b)/g); + + let iter = polyStrMatchAll("ab", /(a)(X)?(b)/g); + let match = iter.next().value; + assert.equal(match[1], "a"); + assert.isUndefined(match[2]); + assert.equal(match[3], "b"); + }); + + it("no matches returns empty iterator", () => { + let results: RegExpExecArray[] = []; + iterForOf(polyStrMatchAll("abc", /z/g), (match) => { + results.push(match); + }); + assert.equal(results.length, 0); + + let iter = polyStrMatchAll("abc", /z/g); + assert.equal(iter.next().done, true); + }); +}); + +function _checkNativeParity(testName: string, testFn: (value: any, matcher: any) => Iterator, value: any, matcher: any) { + let testResult: any; + let nativeResult: any; + let testThrew: any; + let nativeThrew: any; + + try { + testResult = _toMatchInfo(testFn(value, matcher)); + } catch (e) { + testThrew = e; + } + + try { + nativeResult = _toMatchInfo((String.prototype as any).matchAll.call(value, matcher)); + } catch (e) { + nativeThrew = e; + } + + if (testThrew) { + assert.equal(true, !!nativeThrew, + "Checking whether Native and " + testName + " both threw [" + dumpObj(testThrew) + "] - [" + dumpObj(nativeThrew || nativeResult) + "] for [" + dumpObj(value) + "] [" + dumpObj(matcher) + "]"); + } else if (nativeThrew) { + assert.ok(false, + "Native threw but " + testName + " did not [" + dumpObj(testResult) + "] - [" + dumpObj(nativeThrew) + "] for [" + dumpObj(value) + "] [" + dumpObj(matcher) + "]"); + } else { + assert.deepEqual(testResult, nativeResult, + "Checking whether Native and " + testName + " returned the same [" + dumpObj(testResult) + "] - [" + dumpObj(nativeResult) + "] for [" + dumpObj(value) + "] [" + dumpObj(matcher) + "]"); + } +} diff --git a/lib/test/src/common/string/truncate.test.ts b/lib/test/src/common/string/truncate.test.ts index 34b46720..fcff11a9 100644 --- a/lib/test/src/common/string/truncate.test.ts +++ b/lib/test/src/common/string/truncate.test.ts @@ -53,11 +53,12 @@ describe("strTruncate helper", () => { const numResult = strTruncate("abcdefghij", 5, 999 as any); assert.equal(numResult.length, 5, "Result length must not exceed maxLength"); assert.equal(numResult, "ab999", "Number suffix coerced correctly"); - + // Test with boolean suffix - must coerce and respect maxLength const boolResult = strTruncate("abcdefghij", 7, false as any); assert.equal(boolResult.length, 7, "Result length must not exceed maxLength"); assert.equal(boolResult, "abfalse", "Boolean suffix coerced correctly"); + // Test with number suffix longer than maxLength - suffix itself truncated const truncSuffixResult = strTruncate("hello", 3, 123456 as any); assert.equal(truncSuffixResult.length, 3, "Result length must not exceed maxLength even when suffix exceeds it"); From 98510893e57b1d587da837db1fdcda0cb5430592 Mon Sep 17 00:00:00 2001 From: nev21 <82737406+nev21@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:58:05 -0700 Subject: [PATCH 2/2] Update lib/test/src/common/iterator/create.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/test/src/common/iterator/create.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/test/src/common/iterator/create.test.ts b/lib/test/src/common/iterator/create.test.ts index 79ad7d01..d1bb0e80 100644 --- a/lib/test/src/common/iterator/create.test.ts +++ b/lib/test/src/common/iterator/create.test.ts @@ -181,7 +181,7 @@ describe("create iterator helpers", () => { iterForOf(createIterable(fibCtx), (value) => { assert.ok(false, "Should not be called"); }); - assert.equal(done, false, "Check that the return was called"); + assert.equal(done, false, "Check that the return was not called"); }); it("with throw", () => {