diff --git a/.size-limit.json b/.size-limit.json index bcc1ea5a..22df70bf 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,28 +2,28 @@ { "name": "es5-full", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "37.75 kb", + "limit": "39 kb", "brotli": false, "running": false }, { "name": "es6-full", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "36.5 kb", + "limit": "37.5 kb", "brotli": false, "running": false }, { "name": "es5-full-brotli", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "13 kb", + "limit": "13.25 kb", "brotli": true, "running": false }, { "name": "es6-full-brotli", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "12.75 kb", + "limit": "13.25 kb", "brotli": true, "running": false }, @@ -37,7 +37,7 @@ { "name": "es6-zip", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "14 Kb", + "limit": "14.5 Kb", "gzip": true, "running": false }, @@ -107,7 +107,7 @@ { "name": "es5-scheduleMicrotask", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "1.75 kb", + "limit": "2 kb", "import": "{ scheduleMicrotask }", "gzip": true, "running": false @@ -115,7 +115,7 @@ { "name": "es5-scheduleNextTick", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "2 kb", + "limit": "2.25 kb", "import": "{ scheduleNextTick }", "gzip": true, "running": false @@ -123,7 +123,7 @@ { "name": "es5-scheduleNextTickAndMicrotask", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "2 kb", + "limit": "2.25 kb", "import": "{ scheduleNextTick, scheduleMicrotask }", "gzip": true, "running": false diff --git a/CHANGELOG.md b/CHANGELOG.md index bef6fd67..638591b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,22 @@ - `polyObjKeys` no longer invokes property getters (so it no longer throws for an object with a throwing getter), matching the native `Object.keys` - `polyObjEntries` and `polyObjValues` still invoke each getter once and propagate any exception it throws, matching the native `Object.entries` and `Object.values` +- [#634](https://github.com/nevware21/ts-utils/issues/634) Case helpers (`strCamelCase`, `strKebabCase`, `strSnakeCase`, `strLetterCase`, `strCapitalizeWords`): inconsistent word boundaries and non-ASCII letters dropped + - **Output change:** callers that depend on the previous output for the inputs below will see different results + - `strKebabCase` and `strSnakeCase` now also split words at the end of an uppercase run before a capitalized word and where a number is followed by an uppercase letter, eg. `"XMLHttpRequest"` -> `"xml-http-request"` (was `"xmlhttp-request"`), `"ABCDef"` -> `"abc-def"` (was `"abcdef"`), `"fooBar2Baz"` -> `"foo-bar2-baz"` (was `"foo-bar2baz"`) and `"hELLo"` -> `"h-el-lo"` (was `"h-ello"`). This also makes more camelCase values reversible, eg. `"fooBar2Baz"` -> `"foo-bar2-baz"` -> `"fooBar2Baz"` (was `"fooBar2baz"`) + - Non-ASCII letters, numbers and combining marks are now part of words rather than separators (only whitespace, punctuation and symbols are separators), eg. `"café crème"` -> `"caféCrème"` / `"café-crème"` (was `"cafCrMe"` / `"caf-cr-me"`) and `strCapitalizeWords("café crème")` -> `"Café Crème"` (was `"Café CrèMe"`). The first letter of a word is case converted as a complete character, including supplementary plane (surrogate pair) letters, eg. `strLetterCase("𐐨")` -> `"𐐀"` + - Uppercase letters (word boundaries) are detected for the Latin, Latin-1, Greek (including the accented capitals, eg. `"ώραΈναρξης"` -> `"ώρα-έναρξης"`) and Cyrillic ranges in both their precomposed and decomposed (NFC / NFD) forms, other cased letters (eg. `"Ł"`, `"Č"`) are retained and case converted but don't start a new word (`"łódźŁódź"` -> `"łódźłódź"`), while a decomposed letter whose base letter is within those ranges does (`"C"` followed by a combining caron), so normalize values that may contain both forms first. The same fixed ranges are used on every runtime (no Unicode property escapes, which require ES2018), so the output does not depend on the runtime + - `strLetterCase` and `strCapitalizeWords` now also capitalize a word that follows repeated `_` characters, eg. `"__init__"` -> `"__Init__"` (was `"__init__"`), consistent with `"_init"` -> `"_Init"` + - Unchanged, so the conversions remain reversible: `strCamelCase` only changes the first letter of each word and retains the case of the other letters (`"XMLHttpRequest"` -> `"xMLHttpRequest"`, `strCamelCase("XMLHttpRequest", true)` -> `"XMLHttpRequest"`), `strKebabCase` and `strSnakeCase` remove leading and trailing whitespace but retain any other leading or trailing separators as a single separator (`"_hello_darkness"` <-> `"-hello-darkness"`), and `strCapitalizeWords` only splits words at separators (`"fooBarBaz"` -> `"Foobarbaz"`, now documented) + +### Performance Improvements + +- Case helpers (`strCamelCase`, `strKebabCase`, `strSnakeCase`, `strLetterCase`, `strCapitalizeWords`) now detect and convert every word in a single `strReplace()` regular expression pass, with no `strTrim()` or whole result `strLower()` / `strUpper()` passes, intermediate word arrays or per-character iteration, and call the native case conversion on each word directly (via minifiable method name constants). Compared to v0.16.0 for short identifiers / a 100KB string: `strCamelCase` ~2.4x / ~1.8x, `strKebabCase` ~1.3x / ~1.4x, `strSnakeCase` ~1.6x / ~1.3x, `strLetterCase` ~1.8x / ~2.2x and `strCapitalizeWords` ~2.1x / ~2.1x faster +- `strUpper()`, `strLower()`, `strSubstring()`, `strIndexOf()`, `strReplace()`, `strPadStart()`, `strTrim()`, `arrIndexOf()`, `arrMap()` and the other helpers that wrap a native prototype function (`_unwrapFunctionWithPoly()` / `_unwrapFunctionNoInstWithPoly()`) now forward their arguments with the intrinsic `Function.prototype.call` (`FuncCall.apply(theFunc, arguments)`) rather than slicing them into a new array on every call, making each call ~23-51% faster. As the intrinsic is used they also no longer depend on the native function's own `call` / `apply` properties +- `fnCall()` is now an alias of the intrinsic `Function.prototype.call` bound to itself (`FuncCall.bind(FuncCall)`, so `fnCall(fn, thisArg, ...args)` is `fn.call(thisArg, ...args)`) rather than a wrapper function that sliced the arguments into a new array and called `fn.apply()`. This is ~1.8x (polymorphic call sites) to ~30x (monomorphic) faster per call in V8 micro-benchmarks and smaller, which also benefits the helpers that call it per item (eg. `arrDropWhile()`, `arrFlatMap()` and the iterator helpers). It also no longer depends on the function's own (overridable) `call` / `apply` properties, and as it's now a bound function `fnCall.name` is `"bound call"` and `fnCall.length` is `1` +- `fnApply()` is now an alias of the intrinsic `Function.prototype.call` bound to the intrinsic `Function.prototype.apply` (`FuncCall.bind(FuncApply)`, so `fnApply(fn, thisArg, argArray)` is `fn.apply(thisArg, argArray)`) rather than a wrapper function calling `fn.apply()`, which is smaller with the same or slightly better performance (within ~10% in V8 micro-benchmarks), used by the timer, cache, lazy, `arrAppend()`, `createFnDeferredProxy()` and custom error helpers. It also no longer depends on the function's own (overridable) `call` / `apply` properties, and `fnApply.name` is `"bound call"` and `fnApply.length` is `1` +- `fnBind()` now forwards the arguments object to the intrinsic `Function.prototype.bind` (`FuncCall.apply(FuncBind, arguments)`, which is `fn.bind(thisArg, ...args)`) rather than slicing the arguments into a new array and calling `fn.bind.apply()`, ~60-69% faster per call in V8 micro-benchmarks. It remains a function so the existing overloads are unchanged, and it no longer depends on the function's own (overridable) `bind` / `call` / `apply` properties + # v0.16.0 July 18th, 2026 ## Changelog diff --git a/lib/src/funcs/funcs.ts b/lib/src/funcs/funcs.ts index 62c7b903..23eac469 100644 --- a/lib/src/funcs/funcs.ts +++ b/lib/src/funcs/funcs.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import { ArrSlice, CALL } from "../internal/constants"; +import { FuncApply, FuncBind, FuncCall } from "../internal/constants"; import { BoundFunction } from "./types"; /** @@ -23,6 +23,11 @@ import { BoundFunction } from "./types"; * For example, you could use a NodeList, or a custom object like `{ 'length': 2, '0': 'eat', '1': 'bananas' }`. * You can also use `arguments`. * + * `fnApply` is the intrinsic `Function.prototype.call` bound to the intrinsic `Function.prototype.apply`, so + * `fnApply(fn, thisArg, argArray)` is `fn.apply(thisArg, argArray)` without any wrapper function, and it doesn't + * depend on the `call` or `apply` properties of `fn` (which may have been overridden). + * + * @function * @since 0.9.8 * @group Function * @@ -67,13 +72,11 @@ import { BoundFunction } from "./types"; * fnApply(module1.log, module2, [ "friend" ]); // my friend : 42 * ``` */ -export function fnApply any, T>(fn: F, thisArg: T, argArray?: ArrayLike): ReturnType { - return fn.apply(thisArg, argArray); -} +export const fnApply: any, T>(fn: F, thisArg: T, argArray?: ArrayLike) => ReturnType = (/*#__PURE__*/FuncCall.bind(FuncApply)); /** * The `fnCall` function calls the function with the given `thisArg` as the `this` value and with - * al of the `_args` provided as it's `arguments`. + * all of the `args` provided as it's `arguments`. * * Note: This is almost identical to `fnApply`, except that the function arguments are passed to `fnCall` * individually as a list, while with `fnApply` that are combined into a single array argument. @@ -83,13 +86,18 @@ export function fnApply any, T>(fn: F, thisArg: T, a * existing function, without first attaching the function to the object as a property. This allows you * to use methods of one object as generic utility functions. * + * `fnCall` is the intrinsic `Function.prototype.call` bound to itself, so `fnCall(fn, thisArg, ...args)` is + * `fn.call(thisArg, ...args)` without any wrapper function or copying of the arguments, and it doesn't depend + * on the `call` or `apply` properties of `fn` (which may have been overridden). + * + * @function * @since 0.9.8 * @group Function * * @param fn - The function to be called * @param thisArg - The value of `this` provided for the call to `fn`. If the function is not in strict mode, * `null` and `undefined` will be replaced with the global object, and primitive values will be converted to objects. - * @param _args - The zero or more arguments to be passed to the `fn` function. + * @param args - The zero or more arguments to be passed to the `fn` function. * @returns The result of calling the function with the specified `this` value and arguments. * @example * ```ts @@ -126,59 +134,7 @@ export function fnApply any, T>(fn: F, thisArg: T, a * fnCall(module1.log, module2, "friend"); // my friend : 42 * ``` */ -export function fnCall any, T>(fn: F, thisArg: T, ...args: Parameters): ReturnType; - -/** - * The `fnCall` function calls the function with the given `thisArg` as the `this` value and with - * al of the `_args` provided as it's `arguments. - * - * > This is almost identical to `fnApply`, except that the function arguments are passed to `fnCall` - * individually as a list, while with `fnApply` that are combined into a single array argument. - * - * Normally, when calling a function, the value of `this` inside the function is the object that the - * function was accessed on. With `fnCall()`, you can pass an arbitrary value as the `this` when calling an - * existing function, without first attaching the function to the object as a property. This allows you - * to use methods of one object as generic utility functions. - * - * @since 0.9.8 - * @group Function - * - * @param fn - The function to be called - * @param thisArg - The value of `this` provided for the call to `fn`. If the function is not in strict mode, - * `null` and `undefined` will be replaced with the global object, and primitive values will be converted to objects. - * @param args - The zero or more arguments to be passed to the `fn` function. - * @returns The result of calling the function with the specified `this` value and arguments. - * @example - * ```ts - * const module1 = { - * prefix: "Hello", - * x: 21, - * getX() { - * return this.x; - * }, - * log(value: string) { - * return this.prefix + " " + value + " : " + this.x - * } - * }; - * - * // The 'this' parameter of 'getX' is bound to 'module'. - * module1.getX(); // 21 - * module1.log("Darkness"); // Hello Darkness : 21 - * - * // Create a new function 'boundGetX' with the 'this' parameter bound to 'module'. - * let module2 = { - * prefix: "my", - * x: 42 - * }; - * - * // Call the function of module1 with module2 as it's this - * fnCall(module1.getX, module2); // 42 - * fnCall(module1.log, module2, "friend"); // my friend : 42 - * ``` - */ -export function fnCall any, T>(fn: F, thisArg: T): ReturnType { - return fn.apply(thisArg, ArrSlice[CALL](arguments, 2)); -} +export const fnCall: any, T>(fn: F, thisArg: T, ...args: Parameters) => ReturnType = (/*#__PURE__*/FuncCall.bind(FuncCall)); /** * Creates a new function that when called will set the value of `thisArg` as the `this` keyword @@ -283,5 +239,10 @@ export function fnBind(fn: F, thisArg: T, ...argArray: an /*#__NO_SIDE_EFFECTS__*/ export function fnBind any, T>(fn: F, thisArg: T): any { - return fn.bind.apply(fn, ArrSlice[CALL](arguments, 1) as any); + // The arguments are [fn, thisArg, ...args], so this invokes the intrinsic Function.prototype.bind with fn + // as its `this`, which is fn.bind(thisArg, ...args). Passing the arguments object directly to apply() + // avoids slicing them into a new array, and using the intrinsic means this doesn't depend on the fn's own + // (overridable) bind / call / apply properties. This remains a function (rather than an alias like fnCall) + // to retain the overloads, and no other code runs before arguments is read. + return FuncCall.apply(FuncBind, arguments); } diff --git a/lib/src/internal/constants.ts b/lib/src/internal/constants.ts index bb546958..fce46e22 100644 --- a/lib/src/internal/constants.ts +++ b/lib/src/internal/constants.ts @@ -26,6 +26,9 @@ export const LENGTH = "length"; export const NAME = "name"; export const CALL = "call"; export const TO_STRING = "toString"; +export const TO_UPPER_CASE = "toUpperCase"; +export const TO_LOWER_CASE = "toLowerCase"; +export const SUBSTRING = "substring"; export const GET_OWN_PROPERTY_DESCRIPTOR = "getOwnPropertyDescriptor"; export const GET_OWN_PROPERTY_NAMES = "getOwnPropertyNames"; @@ -92,6 +95,29 @@ export const FuncCls = (/*#__PURE__*/_pureAssign(Function)) as FunctionConstruct */ export const FuncProto = (/*#__PURE__*/_pureRef(FuncCls, PROTOTYPE)) as Function; +/** + * @ignore + * The intrinsic `Function.prototype.call`, `FuncCall.apply(fn, [thisArg, ...args])` is the same as + * `fn.call(thisArg, ...args)` without depending on the `call` / `apply` properties of `fn` (which may be + * overridden) and when passed an `arguments` object it forwards them without allocating a new array. + */ +export const FuncCall = (/*#__PURE__*/_pureRef(FuncProto, CALL)) as Function; + +/** + * @ignore + * The intrinsic `Function.prototype.apply`, `FuncCall.bind(FuncApply)` is a function where + * `(fn, thisArg, argArray)` is the same as `fn.apply(thisArg, argArray)` without depending on the + * `call` / `apply` properties of `fn` (which may be overridden). + */ +export const FuncApply = (/*#__PURE__*/_pureRef(FuncProto, "apply")) as Function; + +/** + * @ignore + * The intrinsic `Function.prototype.bind`, `FuncCall.apply(FuncBind, [fn, thisArg, ...args])` is the same + * as `fn.bind(thisArg, ...args)` without depending on the `bind` / `call` / `apply` properties of `fn`. + */ +export const FuncBind = (/*#__PURE__*/_pureRef(FuncProto, "bind")) as Function; + /** * @ignore */ diff --git a/lib/src/internal/unwrapFunction.ts b/lib/src/internal/unwrapFunction.ts index 8ad5ab8a..ce0121b6 100644 --- a/lib/src/internal/unwrapFunction.ts +++ b/lib/src/internal/unwrapFunction.ts @@ -9,7 +9,7 @@ import { dumpObj } from "../helpers/diagnostics"; import { throwTypeError } from "../helpers/throw"; import { asString } from "../string/as_string"; -import { ArrSlice, CALL, NULL_VALUE } from "./constants"; +import { FuncCall, LENGTH, NULL_VALUE } from "./constants"; /** * @function @@ -49,13 +49,20 @@ export function _unwrapFunctionWithPoly any>(func let clsFn = clsProto ? clsProto[funcName] : NULL_VALUE; return function(thisArg: any): ReturnType

{ + // Capture this invocations arguments first (supports all ES5 runtimes). This function MUST also work + // in non-strict mode where the arguments object is mapped to the named parameters, so thisArg must + // never be assigned as that would also change theArgs[0]. + // theArgs is also referenced by the error message, so minifiers don't collapse it back into `arguments`. + let theArgs = arguments; let theFunc = (thisArg ? thisArg[funcName] : NULL_VALUE) || clsFn; if (theFunc || polyFunc) { - let theArgs = arguments; - return ((theFunc || polyFunc) as Function).apply(thisArg, theFunc ? ArrSlice[CALL](theArgs, 1) : theArgs); + // theFunc.call(thisArg, ...rest) or polyFunc(thisArg, ...rest), passing the arguments object directly + // to apply() (rather than slicing it) avoids an array allocation on every call. The intrinsic + // Function.prototype.call is used so this doesn't depend on theFunc's own (overridable) call property + return ((theFunc ? FuncCall : polyFunc) as Function).apply(theFunc || thisArg, theArgs); } - throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg)); + throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg) + " with " + theArgs[LENGTH] + " args"); }; } @@ -76,13 +83,16 @@ export function _unwrapFunctionNoInstWithPoly any let clsFn = clsProto ? clsProto[funcName] : NULL_VALUE; return function(thisArg: any): ReturnType

{ + // See _unwrapFunctionWithPoly(), capture the arguments first (also referenced by the error message so + // minifiers keep it) and never assign thisArg (non-strict mode) + let theArgs = arguments; let theFunc = clsFn; if (theFunc || polyFunc) { - let theArgs = arguments; - return ((theFunc || polyFunc) as Function).apply(thisArg, theFunc ? ArrSlice[CALL](theArgs, 1) : theArgs); + // Forwards the arguments without slicing them, using the intrinsic Function.prototype.call + return ((theFunc ? FuncCall : polyFunc) as Function).apply(theFunc || thisArg, theArgs); } - throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg)); + throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg) + " with " + theArgs[LENGTH] + " args"); }; } diff --git a/lib/src/string/conversion.ts b/lib/src/string/conversion.ts index f2be6f79..856c5519 100644 --- a/lib/src/string/conversion.ts +++ b/lib/src/string/conversion.ts @@ -6,36 +6,157 @@ * Licensed under the MIT license. */ -import { EMPTY } from "../internal/constants"; +import { EMPTY, SUBSTRING, TO_LOWER_CASE, TO_UPPER_CASE } from "../internal/constants"; import { asString } from "./as_string"; import { strReplace } from "./replace"; -import { strTrim } from "./trim"; -import { strLower, strUpper } from "./upper_lower"; /** * @internal * @ignore - * Internal function to escape and convert the case + * Word tokenizer used by {@link strCamelCase}, {@link strKebabCase} and {@link strSnakeCase}, every + * match is `whitespace* (separators*) (word)` or only the trailing separators (with an empty word). As the + * matches are contiguous the replacer can use the match offset to identify the first word (any leading + * separators are part of its match), and the leading `(?!$)` stops an empty match at the end of the string. + * The greedy `\s*` means the captured separators are only empty when the separators are all whitespace, + * which is used to trim leading / trailing whitespace while retaining any other leading / trailing separator. + * + * Words are either: + * - An uppercase run followed by a capitalized word, eg. `"XML"` from `"XMLHttp"`; or + * - An optional uppercase run followed by any other word characters, eg. `"Http"`, `"bar2"`, `"ABC123"`, `"ABC"` + * + * So words are split at separators, where a lowercase letter or number is followed by an uppercase letter + * (`"fooBar"`, `"bar2Baz"`) and before the last letter of an uppercase run followed by a lowercase letter. + * + * The character classes are fixed code unit ranges so the result is the same on every runtime, Unicode + * property escapes (`\p{L}`) require ES2018 and a `u` regular expression literal is a SyntaxError on + * older runtimes (even within a try / catch), they are: + * - Separators: whitespace and the punctuation and symbols (not the letters, numbers or marks) of ASCII, + * Latin-1 (so not `ª²³µ¹º¼½¾`), General Punctuation, Currency Symbols, Arrows to Miscellaneous Symbols and + * Arrows (not Enclosed Alphanumerics, eg. `①Ⓐⓐ`, or the Dingbat numbers `❶➀➊`) and CJK Symbols and + * Punctuation (not `々〆〇〡〪〱〸〻〼` etc) + * `\s\0-\x2F\x3A-\x40\x5B-\x60\x7B-\xA9\xAB-\xB1\xB4\xB6-\xB8\xBB\xBF\xD7\xF7\u2000-\u206F\u20A0-\u20CF\u2190-\u245F\u2500-\u2775\u2794-\u2BFF\u3000-\u3004\u3008-\u3020\u3030\u3036\u3037\u303D-\u303F` + * - Uppercase: Basic Latin, Latin-1, Greek and Cyrillic `A-Z\xC0-\xD6\xD8-\xDE\u0386\u0388-\u038F\u0391-\u03AB\u0400-\u042F`, + * each followed by any combining diacritical marks `\u0300-\u036F` (so decomposed letters are not split) + * - Lowercase (only used to detect the end of an uppercase run): `a-z\xDF-\xF6\xF8-\xFF\u0390\u03AC-\u03CE\u0430-\u045F` + * - Other word characters: every other character (lowercase and caseless letters, numbers, marks, etc) + * + * So the precomposed and decomposed (NFC / NFD) forms of the letters within these ranges produce the same + * words, the letters of the other blocks that alternate between upper and lower case (Latin Extended, + * Cyrillic Supplement, Greek Extended, etc) are not listed as that would require each letter individually, + * so a precomposed letter from those blocks (eg. `Č`) doesn't start a new word while its decomposed form + * (`C` followed by a combining caron) does. + */ +// Each uppercase unit must start with an uppercase letter (marks are never uppercase) so there is only one +// way to split a run, and the last alternative always matches, so this can't backtrack catastrophically +// eslint-disable-next-line security/detect-unsafe-regex +const CASE_WORDS = /(?!$)\s*([\s\0-\x2F\x3A-\x40\x5B-\x60\x7B-\xA9\xAB-\xB1\xB4\xB6-\xB8\xBB\xBF\xD7\xF7\u2000-\u206F\u20A0-\u20CF\u2190-\u245F\u2500-\u2775\u2794-\u2BFF\u3000-\u3004\u3008-\u3020\u3030\u3036\u3037\u303D-\u303F]*)((?:[A-Z\xC0-\xD6\xD8-\xDE\u0386\u0388-\u038F\u0391-\u03AB\u0400-\u042F][\u0300-\u036F]*)+(?=[A-Z\xC0-\xD6\xD8-\xDE\u0386\u0388-\u038F\u0391-\u03AB\u0400-\u042F][\u0300-\u036F]*[a-z\xDF-\xF6\xF8-\xFF\u0390\u03AC-\u03CE\u0430-\u045F])|(?:[A-Z\xC0-\xD6\xD8-\xDE\u0386\u0388-\u038F\u0391-\u03AB\u0400-\u042F][\u0300-\u036F]*)*[^\s\0-\x2F\x3A-\x40\x5B-\x60\x7B-\xA9\xAB-\xB1\xB4\xB6-\xB8\xBB\xBF\xD7\xF7\u2000-\u206F\u20A0-\u20CF\u2190-\u245F\u2500-\u2775\u2794-\u2BFF\u3000-\u3004\u3008-\u3020\u3030\u3036\u3037\u303D-\u303FA-Z\xC0-\xD6\xD8-\xDE\u0386\u0388-\u038F\u0391-\u03AB\u0400-\u042F]*)/g; + +/** + * @internal + * @ignore + * Each run of word characters (anything that is not a separator, see {@link CASE_WORDS}) used by + * {@link strLetterCase} and {@link strCapitalizeWords} + */ +const WORD_RUNS = /[^\s\0-\x2F\x3A-\x40\x5B-\x60\x7B-\xA9\xAB-\xB1\xB4\xB6-\xB8\xBB\xBF\xD7\xF7\u2000-\u206F\u20A0-\u20CF\u2190-\u245F\u2500-\u2775\u2794-\u2BFF\u3000-\u3004\u3008-\u3020\u3030\u3036\u3037\u303D-\u303F]+/g; + +/** + * @internal + * @ignore + * Lowercase the provided word. The words are always non-empty strings, so the native methods are + * called directly via the (minifiable) name constants, as the per-call overhead of the strLower() + * style helpers (argument slicing and apply) is significant in the per-word replacer callbacks. + */ +/*#__NO_SIDE_EFFECTS__*/ +function _lowerWord(word: string): string { + return word[TO_LOWER_CASE](); +} + +/** + * @internal + * @ignore + * Uppercase the provided word + */ +/*#__NO_SIDE_EFFECTS__*/ +function _upperWord(word: string): string { + return word[TO_UPPER_CASE](); +} + +/** + * @internal + * @ignore + * Returns the number of code units of the first character of the (non-empty) word, this is 2 for a + * supplementary plane character (a high surrogate `0xD800 - 0xDBFF` followed by a low surrogate + * `0xDC00 - 0xDFFF`) so the complete character is case converted, otherwise 1. The low surrogate is + * only checked after a high surrogate, so this is a single `charCodeAt()` for all other characters. + */ +/*#__NO_SIDE_EFFECTS__*/ +function _firstLen(word: string): number { + return ((word.charCodeAt(0) & 0xFC00) === 0xD800 && (word.charCodeAt(1) & 0xFC00) === 0xDC00) ? 2 : 1; +} + +/** + * @internal + * @ignore + * Uppercase the first character of the word and retain the case of the remaining characters + */ +/*#__NO_SIDE_EFFECTS__*/ +function _letterWord(word: string): string { + let len = _firstLen(word); + return word[SUBSTRING](0, len)[TO_UPPER_CASE]() + word[SUBSTRING](len); +} + +/** + * @internal + * @ignore + * Lowercase the first character of the word and retain the case of the remaining characters + */ +/*#__NO_SIDE_EFFECTS__*/ +function _lowerFirst(word: string): string { + let len = _firstLen(word); + return word[SUBSTRING](0, len)[TO_LOWER_CASE]() + word[SUBSTRING](len); +} + +/** + * @internal + * @ignore + * Uppercase the first character of the word and lowercase the remaining characters. The whole word is + * lowercased first as some lowercase mappings depend on the preceding letters, eg. the Greek final sigma + * (`"ΟΣ"` becomes `"Ος"` not `"Οσ"`) + */ +/*#__NO_SIDE_EFFECTS__*/ +function _capitalWord(word: string): string { + return _letterWord(word[TO_LOWER_CASE]()); +} + +/** + * @internal + * @ignore + * Internal function to split the value into words and convert the case of each word in a single pass. + * Each run of separators between words is replaced with a single `newPrefix`, leading and trailing + * whitespace is removed and any other leading / trailing separators are also replaced with a single + * `newPrefix`, so the conversions remain reversible (eg. `"_a_b_"` to `"-a-b-"` and back to `"_a_b_"`). * @param value - The value to be converted - * @param newPrefix - The new prefix to add between matches - * @param upperWord - Identify whether to uppercase the match + * @param newPrefix - The separator to add between words + * @param firstFn - The function used to convert the case of the first word + * @param wordFn - The function used to convert the case of the remaining words * @returns The converted string */ /*#__NO_SIDE_EFFECTS__*/ -function _convertCase(value: T, newPrefix: string, upperWord?: boolean): string { - return strReplace(strTrim(asString(value)), /((_|\W)+(\w){0,1}|([a-z])([A-Z]))/g, - (_match, _g1, _g2, wordStart, upperPrefix, upperLetter) => { - let convertMatch = wordStart || upperLetter|| EMPTY; - if (upperWord) { - convertMatch = strUpper(convertMatch); - } - return (upperPrefix || EMPTY) + newPrefix + convertMatch; - }); +function _convertCase(value: T, newPrefix: string, firstFn: (word: string) => string, wordFn: (word: string) => string): string { + return strReplace(asString(value), CASE_WORDS, (_match: string, sep: string, word: string, offset: number) => { + // The first word is always at offset 0 (any leading separators are part of its match), a match without + // a word is the trailing separators. The leading whitespace is not part of sep, so sep is only empty when + // the separators are all whitespace (or there are none), which (like trimming) doesn't add a newPrefix + return word ? ((offset || sep) ? newPrefix : EMPTY) + (offset ? wordFn(word) : firstFn(word)) : (sep ? newPrefix : EMPTY); + }); } /** * Convert the provided value to a `Letter Cased` string, where the start of each word is * capitalized, all non-word character and spaces are retained. + * + * A word is a run of any characters other than whitespace, punctuation and symbols (`_` and `-` are + * separators), so non-ASCII letters are part of words. * If the value is not a string it will be converted. * @since 0.9.0 * @group String @@ -49,20 +170,23 @@ function _convertCase(value: T, newPrefix: string, upperWord?: boolean): stri * strLetterCase("hello darkness"); // "Hello Darkness" * strLetterCase("hello_darkness"); // "Hello_Darkness" * strLetterCase("_hello_darkness"); // "_Hello_Darkness" - * strLetterCase("hello darkness, my old friend."); // "Hello Darkness; // My Old Friend." + * strLetterCase("hello darkness, my old friend."); // "Hello Darkness, My Old Friend." + * strLetterCase("café crème"); // "Café Crème" * ``` */ /*#__NO_SIDE_EFFECTS__*/ export function strLetterCase(value: T): string { - return strReplace(asString(value), /(_|\b)\w/g, strUpper); + return strReplace(asString(value), WORD_RUNS, _letterWord); } /** * Convert the provided value to `Capitalized Words`, where each detected word starts with an * uppercase character and the remaining characters of that word are lowercased. * - * Word boundaries are detected using standard `\b` boundaries and `_` separators, so existing - * separators (spaces, punctuation, `_`, and `-`) are retained. + * A word is a run of any characters other than whitespace, punctuation and symbols (including non-ASCII + * letters), so existing separators (spaces, punctuation, `_`, and `-`) are retained. Words are only split at separators, + * so camelCase boundaries are not retained (`"fooBarBaz"` becomes `"Foobarbaz"`), use + * {@link strCamelCase} or {@link strKebabCase} with {@link strLetterCase} to retain them. * If the value is not a string it will be converted. * @since 0.14.0 * @group String @@ -78,17 +202,26 @@ export function strLetterCase(value: T): string { * strCapitalizeWords("hello_darkness"); // "Hello_Darkness" * strCapitalizeWords("hello-darkness"); // "Hello-Darkness" * strCapitalizeWords("hello darkness, my old friend."); // "Hello Darkness, My Old Friend." + * strCapitalizeWords("café crème"); // "Café Crème" + * strCapitalizeWords("fooBarBaz"); // "Foobarbaz" + * strLetterCase(strKebabCase("fooBarBaz")); // "Foo-Bar-Baz" * ``` */ /*#__NO_SIDE_EFFECTS__*/ export function strCapitalizeWords(value: T): string { - return strLetterCase(strLower(asString(value))); + return strReplace(asString(value), WORD_RUNS, _capitalWord); } /** * Convert the provided value to `camelCased` string, you can optionally specify whether the - * first character is upper cased (lowercase by default) from kebab `-` or snake `_` case. - * All whitespace characters are removed + * first character is upper cased (lowercase by default). + * + * All separators (whitespace, punctuation and symbols, eg. spaces, `_` and `-`) are removed and the first + * letter of each following word is uppercased, the first letter of the result is lowercased (or uppercased + * when `upperFirst` is `true`). The case of all other letters is retained, so the conversion is reversible + * with {@link strKebabCase} / {@link strSnakeCase} (eg. `"helloDarkness"` to `"hello-darkness"` and back to `"helloDarkness"`) + * and an existing camelCase (or PascalCase with `upperFirst`) value is returned unchanged. + * Non-ASCII letters are retained, using the same fixed character ranges on every runtime. * If the value is not a string it will be converted. * @since 0.9.0 * @group String @@ -107,6 +240,16 @@ export function strCapitalizeWords(value: T): string { * strCamelCase("hello-darkness"); // "helloDarkness" * strCamelCase("-hello-darkness"); // "helloDarkness" * strCamelCase("hello darkness, my old friend."); // "helloDarknessMyOldFriend" + * strCamelCase("helloDarkness"); // "helloDarkness" + * strCamelCase("HelloDarkness"); // "helloDarkness" + * strCamelCase("café crème"); // "caféCrème" + * + * // The case of the other letters is retained, so normalize the value first when required + * strCamelCase("HELLO WORLD"); // "hELLOWORLD" + * strCamelCase(strLower("HELLO WORLD")); // "helloWorld" + * strCamelCase("XMLHttpRequest"); // "xMLHttpRequest" + * strKebabCase("XMLHttpRequest"); // "xml-http-request" + * strCamelCase(strKebabCase("XMLHttpRequest")); // "xmlHttpRequest" * * // Uppercase first character * strCamelCase("hello darkness", true); // "HelloDarkness" @@ -115,19 +258,32 @@ export function strCapitalizeWords(value: T): string { * strCamelCase("hello-darkness", true); // "HelloDarkness" * strCamelCase("-hello-darkness", true); // "HelloDarkness" * strCamelCase("hello darkness, my old friend.", true); // "HelloDarknessMyOldFriend" + * strCamelCase("XMLHttpRequest", true); // "XMLHttpRequest" + * strCamelCase(strLower("HELLO WORLD"), true); // "HelloWorld" + * strCamelCase(strKebabCase("XMLHttpRequest"), true); // "XmlHttpRequest" * ``` */ /*#__NO_SIDE_EFFECTS__*/ export function strCamelCase(value: T, upperFirst?: boolean): string { - let result = _convertCase(value, "", true); - - return strReplace(result, /^\w/, upperFirst ? strUpper : strLower); + return _convertCase(value, EMPTY, upperFirst ? _letterWord : _lowerFirst, _letterWord); } /** * Convert the provided value to `kebab-cased` string, you can optionally specify whther the result * is all uppercased by passing `true` as the optional `scream` argument, otherwise the entire result * will be lowercased. + * + * Words are split at any separator (whitespace, punctuation and symbols, eg. spaces, `_` and `-`), where a + * lowercase letter or number is followed by an uppercase letter (`"fooBar"`, `"bar2Baz"`) and before the last + * letter of an uppercase run that is followed by a lowercase letter (`"XMLHttp"`), then joined with a single + * `-`. Leading and trailing whitespace is removed, any other leading or trailing separators are replaced with + * a single `-` so the conversion is reversible with {@link strSnakeCase} (eg. `"_a_b_"` to `"-a-b-"` and back to `"_a_b_"`). + * Non-ASCII letters are retained, uppercase letters (word boundaries) are detected for the Latin, Latin-1, + * Greek and Cyrillic ranges (in both precomposed and decomposed forms, eg. `"Έ"` and `"Ε"` followed by a combining + * acute accent), other cased letters (eg. `"Ł"` and `"Č"`) are case converted but don't start a new word, while + * a decomposed letter whose base letter is within those ranges (eg. `"C"` followed by a combining caron) does, so + * normalize the value first (eg. with `String.prototype.normalize()` where supported) when it may contain both forms. + * The same fixed character ranges are used on every runtime. * If the value is not a string it will be converted. * @since 0.9.0 * @group String @@ -143,6 +299,10 @@ export function strCamelCase(value: T, upperFirst?: boolean): string { * strKebabCase("hello_darkness"); // "hello-darkness" * strKebabCase("_hello_darkness"); // "-hello-darkness" * strKebabCase("hello darkness, my old friend."); // "hello-darkness-my-old-friend-" + * strKebabCase("helloDarkness"); // "hello-darkness" + * strKebabCase("XMLHttpRequest"); // "xml-http-request" + * strKebabCase("fooBar2Baz"); // "foo-bar2-baz" + * strKebabCase("café crème"); // "café-crème" * * // Add optional SCREAM flag * strKebabCase("hello darkness", true); // "HELLO-DARKNESS" @@ -153,14 +313,18 @@ export function strCamelCase(value: T, upperFirst?: boolean): string { */ /*#__NO_SIDE_EFFECTS__*/ export function strKebabCase(value: T, scream?: boolean): string { - let result = _convertCase(value, "-"); - return (scream ? strUpper : strLower)(result); + let caseFn = scream ? _upperWord : _lowerWord; + return _convertCase(value, "-", caseFn, caseFn); } /** * Convert the provided value to `snake_cased` string, you can optionally specify whther the result * is all uppercased by passing `true` as the optional `scream` argument, otherwise the entire result * will be lowercased. + * + * Words are detected using the same rules as {@link strKebabCase}, and are joined with a single `_`. + * Leading and trailing whitespace is removed, any other leading or trailing separators are replaced with + * a single `_` so the conversion is reversible with {@link strKebabCase} (eg. `"-a-b-"` to `"_a_b_"` and back to `"-a-b-"`). * If the value is not a string it will be converted. * @since 0.9.0 * @group String @@ -178,6 +342,8 @@ export function strKebabCase(value: T, scream?: boolean): string { * strSnakeCase("hello-darkness"); // "hello_darkness" * strSnakeCase("-hello-darkness"); // "_hello_darkness" * strSnakeCase("hello darkness, my old friend."); // "hello_darkness_my_old_friend_" + * strSnakeCase("XMLHttpRequest"); // "xml_http_request" + * strSnakeCase("O'Brien & Sons, Ltd."); // "o_brien_sons_ltd_" * * // Use optional Scream flag * strSnakeCase("hello darkness", true); // "HELLO_DARKNESS" @@ -186,10 +352,11 @@ export function strKebabCase(value: T, scream?: boolean): string { * strSnakeCase("hello-darkness", true); // "HELLO_DARKNESS" * strSnakeCase("-hello-darkness", true); // "_HELLO_DARKNESS" * strSnakeCase("hello darkness, my old friend.", true); // "HELLO_DARKNESS_MY_OLD_FRIEND_" + * strSnakeCase("maxSafeInteger", true); // "MAX_SAFE_INTEGER" * ``` */ /*#__NO_SIDE_EFFECTS__*/ export function strSnakeCase(value: T, scream?: boolean): string { - let result = _convertCase(value, "_"); - return (scream ? strUpper : strLower)(result); + let caseFn = scream ? _upperWord : _lowerWord; + return _convertCase(value, "_", caseFn, caseFn); } diff --git a/lib/src/string/substring.ts b/lib/src/string/substring.ts index 0bdbbd96..b53da34f 100644 --- a/lib/src/string/substring.ts +++ b/lib/src/string/substring.ts @@ -7,7 +7,7 @@ */ import { isUndefined } from "../helpers/base"; -import { EMPTY, LENGTH, StrProto } from "../internal/constants"; +import { EMPTY, LENGTH, StrProto, SUBSTRING } from "../internal/constants"; import { _throwIfNullOrUndefined } from "../internal/throwIf"; import { _unwrapFunction, _unwrapFunctionWithPoly } from "../internal/unwrapFunction"; import { mathMax } from "../math/min_max"; @@ -56,7 +56,7 @@ import { strSlice } from "./slice"; * console.log(strSubstring(anyString, 0, 10)); * ``` */ -export const strSubstring: (value: string, indexStart: number, indexEnd?: number) => string = (/*#__PURE__*/_unwrapFunction("substring", StrProto)); +export const strSubstring: (value: string, indexStart: number, indexEnd?: number) => string = (/*#__PURE__*/_unwrapFunction(SUBSTRING, StrProto)); /** * The strSubstr() method returns a portion of the string, starting at the specified index and extending for a given diff --git a/lib/src/string/upper_lower.ts b/lib/src/string/upper_lower.ts index 9ae15399..8844d3d6 100644 --- a/lib/src/string/upper_lower.ts +++ b/lib/src/string/upper_lower.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import { StrProto } from "../internal/constants"; +import { StrProto, TO_LOWER_CASE, TO_UPPER_CASE } from "../internal/constants"; import { _unwrapFunction } from "../internal/unwrapFunction"; /** @@ -29,7 +29,7 @@ import { _unwrapFunction } from "../internal/unwrapFunction"; * strUpper(undefined); // Throws TypeError * ``` */ -export const strUpper: (value: T) => string = (/*#__PURE__*/_unwrapFunction("toUpperCase", StrProto)); +export const strUpper: (value: T) => string = (/*#__PURE__*/_unwrapFunction(TO_UPPER_CASE, StrProto)); /** * The strLower() method returns the value of the string converted to lower case. @@ -50,4 +50,4 @@ export const strUpper: (value: T) => string = (/*#__PURE__*/_unwrapFunction(" * strLower(undefined); // Throws TypeError * ``` */ -export const strLower: (value: T) => string = (/*#__PURE__*/_unwrapFunction("toLowerCase", StrProto)); +export const strLower: (value: T) => string = (/*#__PURE__*/_unwrapFunction(TO_LOWER_CASE, StrProto)); diff --git a/lib/test/bundle-size-check.js b/lib/test/bundle-size-check.js index 0d47b09f..fdb72c2c 100644 --- a/lib/test/bundle-size-check.js +++ b/lib/test/bundle-size-check.js @@ -7,25 +7,25 @@ const configs = [ { name: "es5-min-full", path: "../bundle/es5/umd/ts-utils.min.js", - limit: 41.5 * 1024, // 41.5 kb in bytes + limit: 42.5 * 1024, // 42.5 kb in bytes compress: false }, { name: "es6-min-full", path: "../bundle/es6/umd/ts-utils.min.js", - limit: 40.5 * 1024, // 40.5 kb in bytes + limit: 41.5 * 1024, // 41.5 kb in bytes compress: false }, { name: "es5-min-zip", path: "../bundle/es5/umd/ts-utils.min.js", - limit: 15.5 * 1024, // 15.5 kb in bytes + limit: 16 * 1024, // 16 kb in bytes compress: true }, { name: "es6-min-zip", path: "../bundle/es6/umd/ts-utils.min.js", - limit: 15.75 * 1024, // 15.75 kb in bytes + limit: 16 * 1024, // 16 kb in bytes compress: true }, { diff --git a/lib/test/src/common/funcs/funcs.test.ts b/lib/test/src/common/funcs/funcs.test.ts index 7d852eb9..88e6c8d0 100644 --- a/lib/test/src/common/funcs/funcs.test.ts +++ b/lib/test/src/common/funcs/funcs.test.ts @@ -51,6 +51,47 @@ describe("function helpers", () => { const bound = fnBind(module1.log, module2, "friend"); assert.equal(bound("!"), "my friend!", "pre-bound arguments should be preserved"); }); + + it("passes the this and all of the pre-bound and call arguments", () => { + let theThis = { x: 21 }; + function getArgs(this: any, ..._args: any[]) { + return [ this ].concat(Array.prototype.slice.call(arguments)); + } + + assert.deepEqual(fnBind(getArgs, theThis)(), [ theThis ]); + assert.deepEqual(fnBind(getArgs, theThis)(1, 2), [ theThis, 1, 2 ]); + assert.deepEqual(fnBind(getArgs, theThis, undefined)(), [ theThis, undefined ]); + assert.deepEqual(fnBind(getArgs, theThis, 1, "two", null)(3), [ theThis, 1, "two", null, 3 ]); + + // A bound function can be bound again, the original this is retained + assert.deepEqual(fnBind(fnBind(getArgs, theThis, 1), { x: 42 }, 2)(3), [ theThis, 1, 2, 3 ]); + }); + + it("does not depend on the function's own bind, call or apply properties", () => { + function getX(this: any, value?: number) { + return this.x + (value || 0) + arguments.length; + } + + (getX as any).bind = () => "wrong"; + (getX as any).call = null; + (getX as any).apply = null; + assert.equal(fnBind(getX, { x: 20 })(), 20); + assert.equal(fnBind(getX, { x: 20 }, 1)(), 22); + }); + + it("throws a TypeError when fn is not a function", () => { + let invalid: any[] = [null, undefined, {}, "fn", 42]; + for (let lp = 0; lp < invalid.length; lp++) { + let caught: any = null; + try { + fnBind(invalid[lp], null); + } catch (e) { + caught = e; + } + + assert.ok(caught instanceof TypeError, "Expected a TypeError for " + String(invalid[lp]) + " - " + String(caught)); + } + }); }); describe("fnApply", () => { @@ -83,6 +124,58 @@ describe("function helpers", () => { assert.equal(fnApply(module1.getX, module2), 42); assert.equal(fnApply(module1.log, module2, [ "friend" ]), "my friend : 42"); }); + + it("passes the this and the array-like arguments", () => { + let theThis = { x: 21 }; + function argCount(..._args: any[]) { + return arguments.length; + } + + function getArgs(..._args: any[]) { + return Array.prototype.slice.call(arguments); + } + + function passArguments(..._args: any[]) { + return fnApply(getArgs, null, arguments); + } + + assert.equal(fnApply(function(this: any) { + return this; + }, theThis), theThis); + assert.equal(fnApply(argCount, null), 0); + assert.equal(fnApply(argCount, null, undefined), 0); + assert.equal(fnApply(argCount, null, null as any), 0); + assert.equal(fnApply(argCount, null, []), 0); + assert.equal(fnApply(argCount, null, [ undefined ]), 1); + assert.deepEqual(fnApply(getArgs, null, [ 1, "two", null, undefined ]), [ 1, "two", null, undefined ]); + assert.deepEqual(fnApply(getArgs, null, { length: 2, 0: "eat", 1: "bananas" }), [ "eat", "bananas" ]); + assert.deepEqual(passArguments(1, "two", 3), [ 1, "two", 3 ]); + }); + + it("does not depend on the function's own call or apply properties", () => { + function getX(this: any, value?: number) { + return this.x + (value || 0) + arguments.length; + } + + (getX as any).call = null; + (getX as any).apply = () => "wrong"; + assert.equal(fnApply(getX, { x: 20 }), 20); + assert.equal(fnApply(getX, { x: 20 }, [ 1 ]), 22); + }); + + it("throws a TypeError when fn is not a function", () => { + let invalid: any[] = [null, undefined, {}, "fn", 42]; + for (let lp = 0; lp < invalid.length; lp++) { + let caught: any = null; + try { + fnApply(invalid[lp], null, [ 1 ]); + } catch (e) { + caught = e; + } + + assert.ok(caught instanceof TypeError, "Expected a TypeError for " + String(invalid[lp]) + " - " + String(caught)); + } + }); }); describe("fnBindArgs", () => { @@ -135,5 +228,47 @@ describe("function helpers", () => { assert.equal(fnCall(module1.getX, module2), 42); assert.equal(fnCall(module1.log, module2, "friend"), "my friend : 42"); }); + + it("passes the this and all of the arguments", () => { + let theThis = { x: 21 }; + function argCount(..._args: any[]) { + return arguments.length; + } + + assert.equal(fnCall(function(this: any) { + return this; + }, theThis), theThis); + assert.equal(fnCall(argCount, null), 0); + assert.equal(fnCall(argCount, null, undefined), 1); + assert.equal(fnCall(argCount, null, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10), 10); + assert.deepEqual(fnCall(function(..._args: any[]) { + return Array.prototype.slice.call(arguments); + }, null, 1, "two", null, undefined), [1, "two", null, undefined]); + }); + + it("does not depend on the function's own call or apply properties", () => { + function getX(this: any, value?: number) { + return this.x + (value || 0) + arguments.length; + } + + (getX as any).call = null; + (getX as any).apply = () => "wrong"; + assert.equal(fnCall(getX, { x: 20 }), 20); + assert.equal(fnCall(getX, { x: 20 }, 1), 22); + }); + + it("throws a TypeError when fn is not a function", () => { + let invalid: any[] = [null, undefined, {}, "fn", 42]; + for (let lp = 0; lp < invalid.length; lp++) { + let caught: any = null; + try { + fnCall(invalid[lp], null); + } catch (e) { + caught = e; + } + + assert.ok(caught instanceof TypeError, "Expected a TypeError for " + String(invalid[lp]) + " - " + String(caught)); + } + }); }); }); diff --git a/lib/test/src/common/internal/unwrapFunction.test.ts b/lib/test/src/common/internal/unwrapFunction.test.ts index 72d25032..71cf2c46 100644 --- a/lib/test/src/common/internal/unwrapFunction.test.ts +++ b/lib/test/src/common/internal/unwrapFunction.test.ts @@ -7,7 +7,7 @@ */ import { assert } from "@nevware21/tripwire-chai"; -import { _unwrapFunction, _unwrapFunctionWithPoly, _unwrapInstFunction } from "../../../../src/internal/unwrapFunction"; +import { _unwrapFunction, _unwrapFunctionNoInstWithPoly, _unwrapFunctionWithPoly, _unwrapInstFunction } from "../../../../src/internal/unwrapFunction"; import { strIncludes } from "../../../../src/string/includes"; import { dumpObj } from "../../../../src/helpers/diagnostics"; @@ -372,6 +372,40 @@ describe("_unwrapFunctionWithPoly", () => { wrappedFn({}); }, "not defined for")); }); + + it("error message includes the number of arguments passed", () => { + let wrappedFn = _unwrapFunctionWithPoly("test", null as any, null as any); + + assert.ok(_expectThrow(() => { + wrappedFn(null); + }, "\"test\" not defined for [object Null]: null with 1 args")); + + assert.ok(_expectThrow(() => { + (wrappedFn as any)({}, 1, "two"); + }, " with 3 args")); + }); + + it("does not depend on the function's own call or apply properties", () => { + function test(this: any, value: number) { + return this.x + value + arguments.length; + } + + (test as any).call = null; + let wrappedFn = _unwrapFunctionWithPoly("test" as any, null as any, null as any); + assert.equal((wrappedFn as any)({ x: 20, test: test }, 1), 22); + + (test as any).call = () => "wrong"; + (test as any).apply = () => "wrong"; + assert.equal((wrappedFn as any)({ x: 20, test: test }, 1), 22); + + // Using the class / prototype fallback + let fallbackFn = _unwrapFunctionWithPoly("test" as any, { test: test } as any, null as any); + assert.equal((fallbackFn as any)({ x: 10 }, 1), 12); + + // Only the class / prototype function is used + let noInstFn = _unwrapFunctionNoInstWithPoly("test" as any, { test: test } as any, null as any); + assert.equal((noInstFn as any)({ x: 10, test: () => "instance" }, 1), 12); + }); }); describe("unwrapInstFunction", () => { diff --git a/lib/test/src/common/string/conversion.test.ts b/lib/test/src/common/string/conversion.test.ts index 98d537b9..4e9e8539 100644 --- a/lib/test/src/common/string/conversion.test.ts +++ b/lib/test/src/common/string/conversion.test.ts @@ -8,6 +8,7 @@ import { assert } from "@nevware21/tripwire-chai"; import { strCamelCase, strCapitalizeWords, strKebabCase, strLetterCase, strSnakeCase } from "../../../../src/string/conversion"; +import { strLower } from "../../../../src/string/upper_lower"; describe("strLetterCase", () => { it("null/undefined", () => { @@ -16,10 +17,19 @@ describe("strLetterCase", () => { }); it("Basic", () => { + assert.equal(strLetterCase(""), ""); assert.equal(strLetterCase("hello darkness"), "Hello Darkness"); assert.equal(strLetterCase("hello_darkness"), "Hello_Darkness"); assert.equal(strLetterCase("_hello_darkness"), "_Hello_Darkness"); assert.equal(strLetterCase("hello darkness, my old friend."), "Hello Darkness, My Old Friend."); + assert.equal(strLetterCase("1st place"), "1st Place"); + }); + + it("Non-ASCII letters", () => { + assert.equal(strLetterCase("café crème"), "Café Crème"); + assert.equal(strLetterCase("über straße"), "Über Straße"); + assert.equal(strLetterCase("cafe\u0301 cre\u0300me"), "Cafe\u0301 Cre\u0300me"); + assert.equal(strLetterCase("привет мир"), "Привет Мир"); }); it("Reverse kebabcase Basic", () => { @@ -27,6 +37,7 @@ describe("strLetterCase", () => { assert.equal(strLetterCase(strKebabCase("hello_darkness")), "Hello-Darkness"); assert.equal(strLetterCase(strKebabCase("_hello_darkness")), "-Hello-Darkness"); assert.equal(strLetterCase(strKebabCase("hello darkness, my old friend.")), "Hello-Darkness-My-Old-Friend-"); + assert.equal(strLetterCase(strKebabCase("fooBarBaz")), "Foo-Bar-Baz"); }); it("Reverse snakecase Basic", () => { @@ -51,12 +62,26 @@ describe("strCapitalizeWords", () => { }); it("Basic", () => { + assert.equal(strCapitalizeWords(""), ""); assert.equal(strCapitalizeWords("hello darkness"), "Hello Darkness"); assert.equal(strCapitalizeWords("hELLo dARKness"), "Hello Darkness"); assert.equal(strCapitalizeWords("hello_darkness"), "Hello_Darkness"); assert.equal(strCapitalizeWords("_hello_darkness"), "_Hello_Darkness"); assert.equal(strCapitalizeWords("hello-darkness"), "Hello-Darkness"); assert.equal(strCapitalizeWords("hello darkness, my old friend."), "Hello Darkness, My Old Friend."); + assert.equal(strCapitalizeWords(" HELLO WORLD "), " Hello World "); + }); + + it("Non-ASCII letters", () => { + assert.equal(strCapitalizeWords("café crème"), "Café Crème"); + assert.equal(strCapitalizeWords("CAFÉ CRÈME"), "Café Crème"); + assert.equal(strCapitalizeWords("über STRASSE"), "Über Strasse"); + assert.equal(strCapitalizeWords("cafe\u0301 cre\u0300me"), "Cafe\u0301 Cre\u0300me"); + }); + + it("Only splits words at separators", () => { + assert.equal(strCapitalizeWords("fooBarBaz"), "Foobarbaz"); + assert.equal(strCapitalizeWords("foo_bar-baz qux"), "Foo_Bar-Baz Qux"); }); it("Reverse kebabcase Basic", () => { @@ -91,12 +116,58 @@ describe("strCamelCase", () => { }); it("Basic", () => { + assert.equal(strCamelCase(""), ""); + assert.equal(strCamelCase(" -_. "), ""); assert.equal(strCamelCase("hello darkness"), "helloDarkness"); assert.equal(strCamelCase("hello_darkness"), "helloDarkness"); assert.equal(strCamelCase("_hello_darkness"), "helloDarkness"); assert.equal(strCamelCase("hello-darkness"), "helloDarkness"); assert.equal(strCamelCase("-hello-darkness"), "helloDarkness"); assert.equal(strCamelCase("hello darkness, my old friend."), "helloDarknessMyOldFriend"); + assert.equal(strCamelCase("helloDarkness"), "helloDarkness"); + assert.equal(strCamelCase("HelloDarkness"), "helloDarkness"); + }); + + it("Retains the case within each word", () => { + assert.equal(strCamelCase("ABC def"), "aBCDef"); + assert.equal(strCamelCase("HELLO WORLD"), "hELLOWORLD"); + assert.equal(strCamelCase("EFTPOS PURCHASE 1234"), "eFTPOSPURCHASE1234"); + assert.equal(strCamelCase("MAX_SAFE_INTEGER"), "mAXSAFEINTEGER"); + assert.equal(strCamelCase("hello DARKNESS"), "helloDARKNESS"); + }); + + it("Normalizing the value first", () => { + assert.equal(strCamelCase(strLower("HELLO WORLD")), "helloWorld"); + assert.equal(strCamelCase(strLower("HELLO WORLD"), true), "HelloWorld"); + assert.equal(strKebabCase("XMLHttpRequest"), "xml-http-request"); + assert.equal(strCamelCase(strKebabCase("XMLHttpRequest")), "xmlHttpRequest"); + assert.equal(strCamelCase(strKebabCase("XMLHttpRequest"), true), "XmlHttpRequest"); + }); + + it("Only the first letter of each word is changed", () => { + assert.equal(strCamelCase("XMLHttpRequest"), "xMLHttpRequest"); + assert.equal(strCamelCase("ABCDef"), "aBCDef"); + assert.equal(strCamelCase("fooBar2Baz"), "fooBar2Baz"); + assert.equal(strCamelCase("ABC123"), "aBC123"); + assert.equal(strCamelCase("xPosition"), "xPosition"); + assert.equal(strCamelCase("hELLo dARKness-my old_friend"), "hELLoDARKnessMyOldFriend"); + }); + + it("Non-ASCII letters", () => { + assert.equal(strCamelCase("café crème"), "caféCrème"); + assert.equal(strCamelCase("über straße"), "überStraße"); + assert.equal(strCamelCase("ÉCOLE normale"), "éCOLENormale"); + assert.equal(strCamelCase("E\u0301COLE normale"), "e\u0301COLENormale"); + assert.equal(strCamelCase("cafe\u0301 cre\u0300me"), "cafe\u0301Cre\u0300me"); + assert.equal(strCamelCase("привет мир"), "приветМир"); + assert.equal(strCamelCase("приветМир"), "приветМир"); + assert.equal(strCamelCase("5µm"), "5µm"); + }); + + it("Leading and trailing separators are removed", () => { + assert.equal(strCamelCase("__init__"), "init"); + assert.equal(strCamelCase("--foo--"), "foo"); + assert.equal(strCamelCase("O'Brien & Sons, Ltd."), "oBrienSonsLtd"); }); it("Pascal Case Basic", () => { @@ -106,6 +177,9 @@ describe("strCamelCase", () => { assert.equal(strCamelCase("hello-darkness", true), "HelloDarkness"); assert.equal(strCamelCase("-hello-darkness", true), "HelloDarkness"); assert.equal(strCamelCase("hello darkness, my old friend.", true), "HelloDarknessMyOldFriend"); + assert.equal(strCamelCase("HELLO WORLD", true), "HELLOWORLD"); + assert.equal(strCamelCase("XMLHttpRequest", true), "XMLHttpRequest"); + assert.equal(strCamelCase("über straße", true), "ÜberStraße"); }); it("Reverse Snake", () => { @@ -124,6 +198,7 @@ describe("strCamelCase", () => { assert.equal(strCamelCase(strKebabCase("hello-darkness")), "helloDarkness"); assert.equal(strCamelCase(strKebabCase("-hello-darkness")), "helloDarkness"); assert.equal(strCamelCase(strKebabCase("hello darkness, my old friend.")), "helloDarknessMyOldFriend"); + assert.equal(strCamelCase(strKebabCase("XMLHttpRequest")), "xmlHttpRequest"); }); }); @@ -134,10 +209,52 @@ describe("strKebabCase", () => { }); it("Basic", () => { + assert.equal(strKebabCase(""), ""); + assert.equal(strKebabCase(" -_. "), "-"); assert.equal(strKebabCase("hello darkness"), "hello-darkness"); assert.equal(strKebabCase("hello_darkness"), "hello-darkness"); assert.equal(strKebabCase("_hello_darkness"), "-hello-darkness"); assert.equal(strKebabCase("hello darkness, my old friend."), "hello-darkness-my-old-friend-"); + assert.equal(strKebabCase("hello -- darkness"), "hello-darkness"); + }); + + it("Word boundaries", () => { + assert.equal(strKebabCase("helloDarkness"), "hello-darkness"); + assert.equal(strKebabCase("HelloDarkness"), "hello-darkness"); + assert.equal(strKebabCase("ABCDef"), "abc-def"); + assert.equal(strKebabCase("ABCdef"), "ab-cdef"); + assert.equal(strKebabCase("XMLHttpRequest"), "xml-http-request"); + assert.equal(strKebabCase("fooBar2Baz"), "foo-bar2-baz"); + assert.equal(strKebabCase("abc123Def"), "abc123-def"); + assert.equal(strKebabCase("ABC123"), "abc123"); + assert.equal(strKebabCase("abc_123"), "abc-123"); + assert.equal(strKebabCase("HELLO WORLD"), "hello-world"); + assert.equal(strKebabCase("xPosition"), "x-position"); + assert.equal(strKebabCase("iOSApp"), "i-os-app"); + assert.equal(strKebabCase("hELLo dARKness-my old_friend"), "h-el-lo-d-ar-kness-my-old-friend"); + }); + + it("Non-ASCII letters", () => { + assert.equal(strKebabCase("café crème"), "café-crème"); + assert.equal(strKebabCase("über straße"), "über-straße"); + assert.equal(strKebabCase("ÉCOLENormale"), "école-normale"); + assert.equal(strKebabCase("E\u0301COLENormale"), "e\u0301cole-normale"); + assert.equal(strKebabCase("cafe\u0301 Cre\u0300me"), "cafe\u0301-cre\u0300me"); + assert.equal(strKebabCase("приветМир"), "привет-мир"); + assert.equal(strKebabCase("αβγΔέλτα"), "αβγ-δέλτα"); + assert.equal(strKebabCase("日本 Tokyo"), "日本-tokyo"); + }); + + it("Leading and trailing whitespace is removed, other separators are retained", () => { + assert.equal(strKebabCase("__init__"), "-init-"); + assert.equal(strKebabCase("--foo--"), "-foo-"); + assert.equal(strKebabCase(" foo bar "), "foo-bar"); + assert.equal(strKebabCase(" _foo_ "), "-foo-"); + assert.equal(strKebabCase(" _ foo _ "), "-foo-"); + assert.equal(strKebabCase("foo , "), "foo-"); + assert.equal(strKebabCase("_"), "-"); + assert.equal(strKebabCase(" "), ""); + assert.equal(strKebabCase("O'Brien & Sons, Ltd."), "o-brien-sons-ltd-"); }); it("Scream Basic", () => { @@ -145,6 +262,8 @@ describe("strKebabCase", () => { assert.equal(strKebabCase("hello_darkness", true), "HELLO-DARKNESS"); assert.equal(strKebabCase("_hello_darkness", true), "-HELLO-DARKNESS"); assert.equal(strKebabCase("hello darkness, my old friend.", true), "HELLO-DARKNESS-MY-OLD-FRIEND-"); + assert.equal(strKebabCase("XMLHttpRequest", true), "XML-HTTP-REQUEST"); + assert.equal(strKebabCase("café crème", true), "CAFÉ-CRÈME"); }); it("Reverse Camelcase Basic", () => { @@ -152,6 +271,7 @@ describe("strKebabCase", () => { assert.equal(strKebabCase(strCamelCase("hello_darkness")), "hello-darkness"); assert.equal(strKebabCase(strCamelCase("_hello_darkness")), "hello-darkness"); assert.equal(strKebabCase(strCamelCase("hello darkness, my old friend.")), "hello-darkness-my-old-friend"); + assert.equal(strKebabCase(strCamelCase("café crème")), "café-crème"); }); it("Reverse Snakecase Basic", () => { @@ -169,6 +289,7 @@ describe("strSnakeCase", () => { }); it("Basic", () => { + assert.equal(strSnakeCase(""), ""); assert.equal(strSnakeCase("hello darkness"), "hello_darkness"); assert.equal(strSnakeCase("hello_darkness"), "hello_darkness"); assert.equal(strSnakeCase("_hello_darkness"), "_hello_darkness"); @@ -177,6 +298,24 @@ describe("strSnakeCase", () => { assert.equal(strSnakeCase("hello darkness, my old friend."), "hello_darkness_my_old_friend_"); }); + it("Word boundaries", () => { + assert.equal(strSnakeCase("XMLHttpRequest"), "xml_http_request"); + assert.equal(strSnakeCase("fooBar2Baz"), "foo_bar2_baz"); + assert.equal(strSnakeCase("maxSafeInteger"), "max_safe_integer"); + }); + + it("Non-ASCII letters", () => { + assert.equal(strSnakeCase("café crème"), "café_crème"); + assert.equal(strSnakeCase("über straße"), "über_straße"); + }); + + it("Leading and trailing whitespace is removed, other separators are retained", () => { + assert.equal(strSnakeCase("__init__"), "_init_"); + assert.equal(strSnakeCase(" -foo- "), "_foo_"); + assert.equal(strSnakeCase(" foo "), "foo"); + assert.equal(strSnakeCase("O'Brien & Sons, Ltd."), "o_brien_sons_ltd_"); + }); + it("Scream Basic", () => { assert.equal(strSnakeCase("hello darkness", true), "HELLO_DARKNESS"); assert.equal(strSnakeCase("hello_darkness", true), "HELLO_DARKNESS"); @@ -184,6 +323,7 @@ describe("strSnakeCase", () => { assert.equal(strSnakeCase("hello-darkness", true), "HELLO_DARKNESS"); assert.equal(strSnakeCase("-hello-darkness", true), "_HELLO_DARKNESS"); assert.equal(strSnakeCase("hello darkness, my old friend.", true), "HELLO_DARKNESS_MY_OLD_FRIEND_"); + assert.equal(strSnakeCase("maxSafeInteger", true), "MAX_SAFE_INTEGER"); }); it("reverse camelcase Basic", () => { @@ -204,3 +344,155 @@ describe("strSnakeCase", () => { assert.equal(strSnakeCase(strKebabCase("hello darkness, my old friend.")), "hello_darkness_my_old_friend_"); }); }); + +describe("case conversion round trips", () => { + // The conversions MUST be reversible, passing a value through the opposite conversion returns the original + function _roundTrip(values: string[], there: (value: string) => string, back: (value: string) => string) { + for (let lp = 0; lp < values.length; lp++) { + assert.equal(back(there(values[lp])), values[lp], JSON.stringify(values[lp]) + " -> " + JSON.stringify(there(values[lp]))); + } + } + + it("snake_case <-> kebab-case", () => { + _roundTrip(["hello_darkness", "_hello_darkness", "_hello_darkness_", "_private", "max_safe_integer", "foo_bar2_baz", "café_crème"], + (v) => strKebabCase(v), (v) => strSnakeCase(v)); + _roundTrip(["hello-darkness", "-hello-darkness", "-hello-darkness-", "hello-darkness-my-old-friend-", "foo-bar2-baz"], + (v) => strSnakeCase(v), (v) => strKebabCase(v)); + _roundTrip(["MAX_SAFE_INTEGER", "_MAX_SAFE_INTEGER_"], (v) => strKebabCase(v, true), (v) => strSnakeCase(v, true)); + }); + + it("camelCase <-> kebab-case / snake_case", () => { + _roundTrip(["helloDarkness", "myOldFriend", "fooBar2Baz", "abc123Def", "caféCrème", "überStraße", "приветМир"], + (v) => strKebabCase(v), (v) => strCamelCase(v)); + _roundTrip(["helloDarkness", "maxSafeInteger", "fooBar2Baz"], (v) => strSnakeCase(v), (v) => strCamelCase(v)); + _roundTrip(["hello-darkness", "foo-bar2-baz", "xml-http-request", "café-crème"], (v) => strCamelCase(v), (v) => strKebabCase(v)); + _roundTrip(["max_safe_integer", "hello_darkness_my_old_friend"], (v) => strCamelCase(v), (v) => strSnakeCase(v)); + }); + + it("PascalCase <-> kebab-case / snake_case", () => { + _roundTrip(["HelloDarkness", "MaxSafeInteger", "FooBar2Baz", "ÜberStraße"], (v) => strSnakeCase(v), (v) => strCamelCase(v, true)); + _roundTrip(["HelloDarkness", "MyOldFriend"], (v) => strKebabCase(v), (v) => strCamelCase(v, true)); + }); + + it("Converting a value that is already in the target case is unchanged", () => { + _roundTrip(["helloDarkness", "xMLHttpRequest", "fooBAR", "hELLo", "caféCrème"], (v) => v, (v) => strCamelCase(v)); + _roundTrip(["HelloDarkness", "XMLHttpRequest", "FooBAR", "ÜberStraße"], (v) => v, (v) => strCamelCase(v, true)); + _roundTrip(["hello-darkness", "-hello-darkness-", "foo-bar2-baz", "café-crème"], (v) => v, (v) => strKebabCase(v)); + _roundTrip(["hello_darkness", "_hello_darkness_", "foo_bar2_baz"], (v) => v, (v) => strSnakeCase(v)); + _roundTrip(["MAX_SAFE_INTEGER", "_MAX_SAFE_INTEGER_"], (v) => v, (v) => strSnakeCase(v, true)); + }); +}); + +describe("case conversion character ranges", () => { + it("Uppercase letters outside of the Latin-1, Greek and Cyrillic ranges", () => { + // Retained and case converted, but not detected as a word boundary + assert.equal(strKebabCase("łódźŁódź"), "łódźłódź"); + assert.equal(strKebabCase("łódź Łódź"), "łódź-łódź"); + assert.equal(strCamelCase("łódź łódź"), "łódźŁódź"); + assert.equal(strKebabCase("łódź", true), "ŁÓDŹ"); + }); + + it("Symbols are separators", () => { + assert.equal(strKebabCase("I \u2764 JS"), "i-js"); + assert.equal(strKebabCase("price \u20AC5"), "price-5"); + assert.equal(strKebabCase("foo\u2192bar"), "foo-bar"); + assert.equal(strKebabCase("\u00A9 2024 Acme"), "-2024-acme"); + }); + + it("Letters, numbers and marks within the punctuation and symbol blocks are word characters", () => { + // Latin-1 + assert.equal(strLetterCase("a²b"), "A²b"); + assert.equal(strCapitalizeWords("1ª VEZ"), "1ª Vez"); + assert.equal(strCamelCase("x² plus y²"), "x²PlusY²"); + assert.equal(strKebabCase("x²Y"), "x²-y"); + assert.equal(strSnakeCase("¼ cup"), "¼_cup"); + // Enclosed Alphanumerics (the circled letters are cased) and the Dingbat numbers + assert.equal(strKebabCase("① first ❷ second"), "①-first-❷-second"); + assert.equal(strCamelCase("step ①"), "step①"); + assert.equal(strLetterCase("ⓐbc ⓓ"), "Ⓐbc Ⓓ"); + assert.equal(strKebabCase("ⒶⒷ"), "ⓐⓑ"); + // CJK Symbols and Punctuation + assert.equal(strCamelCase("佐々木"), "佐々木"); + assert.equal(strCamelCase("佐々木 太郎"), "佐々木太郎"); + assert.equal(strKebabCase("佐々木、太郎"), "佐々木-太郎"); + assert.equal(strLetterCase("〇x"), "〇x"); + assert.equal(strCapitalizeWords("〆X"), "〆x"); + assert.equal(strSnakeCase("a\u302Ab"), "a\u302Ab"); + }); + + it("The punctuation and symbols next to the excluded characters are separators", () => { + assert.equal(strKebabCase("«a±b»"), "-a-b-"); + assert.equal(strKebabCase("a´b¶c¸d"), "a-b-c-d"); + assert.equal(strCamelCase("¿qué?"), "qué"); + assert.equal(strKebabCase("a\u245Fb\u2500c\u2794d"), "a-b-c-d"); + assert.equal(strKebabCase("a\u3004b\u3008c\u3020d\u3030e\u3036f\u303Dg"), "a-b-c-d-e-f-g"); + }); + + it("Greek final sigma is lowercased within the context of the word", () => { + assert.equal(strCapitalizeWords("\u039F\u03A3"), "\u039F\u03C2"); + assert.equal(strCapitalizeWords("A\u03A3"), "A\u03C2"); + assert.equal(strCapitalizeWords("\u03A3\u03A3"), "\u03A3\u03C2"); + assert.equal(strCapitalizeWords("\u039F\u0394\u039F\u03A3 \u0391\u0398\u0397\u039D\u0391"), "\u039F\u03B4\u03BF\u03C2 \u0391\u03B8\u03B7\u03BD\u03B1"); + assert.equal(strKebabCase("\u039F\u0394\u039F\u03A3 \u0391\u0398\u0397\u039D\u0391"), "\u03BF\u03B4\u03BF\u03C2-\u03B1\u03B8\u03B7\u03BD\u03B1"); + assert.equal(strSnakeCase("\u039F\u0394\u039F\u03A3"), "\u03BF\u03B4\u03BF\u03C2"); + }); + + it("Accented Greek capitals start a new word in both the precomposed and decomposed forms", () => { + // [precomposed, base letter, combining mark] + let capitals = [ + ["\u0386", "\u0391", "\u0301"], ["\u0388", "\u0395", "\u0301"], ["\u0389", "\u0397", "\u0301"], + ["\u038A", "\u0399", "\u0301"], ["\u038C", "\u039F", "\u0301"], ["\u038E", "\u03A5", "\u0301"], + ["\u038F", "\u03A9", "\u0301"], ["\u03AA", "\u0399", "\u0308"], ["\u03AB", "\u03A5", "\u0308"] + ]; + for (let lp = 0; lp < capitals.length; lp++) { + let precomposed = capitals[lp][0]; + let decomposed = capitals[lp][1] + capitals[lp][2]; + let lowerPrecomposed = strLower(precomposed); + let lowerDecomposed = strLower(capitals[lp][1]) + capitals[lp][2]; + assert.equal(strKebabCase("a" + precomposed + "b"), "a-" + lowerPrecomposed + "b", precomposed); + assert.equal(strKebabCase("a" + decomposed + "b"), "a-" + lowerDecomposed + "b", decomposed); + assert.equal(strSnakeCase("AB" + precomposed + "c"), "ab_" + lowerPrecomposed + "c", precomposed); + assert.equal(strSnakeCase("AB" + decomposed + "c"), "ab_" + lowerDecomposed + "c", decomposed); + } + + // "ώραΈναρξης" and "ΆλφαΏμέγα" + assert.equal(strKebabCase("\u03CE\u03C1\u03B1\u0388\u03BD\u03B1\u03C1\u03BE\u03B7\u03C2"), "\u03CE\u03C1\u03B1-\u03AD\u03BD\u03B1\u03C1\u03BE\u03B7\u03C2"); + assert.equal(strKebabCase("\u03CE\u03C1\u03B1\u0395\u0301\u03BD\u03B1\u03C1\u03BE\u03B7\u03C2"), "\u03CE\u03C1\u03B1-\u03B5\u0301\u03BD\u03B1\u03C1\u03BE\u03B7\u03C2"); + assert.equal(strSnakeCase("\u0386\u03BB\u03C6\u03B1\u038F\u03BC\u03AD\u03B3\u03B1"), "\u03AC\u03BB\u03C6\u03B1_\u03CE\u03BC\u03AD\u03B3\u03B1"); + assert.equal(strKebabCase(strCamelCase("\u03CE\u03C1\u03B1-\u03AD\u03BD\u03B1\u03C1\u03BE\u03B7\u03C2")), "\u03CE\u03C1\u03B1-\u03AD\u03BD\u03B1\u03C1\u03BE\u03B7\u03C2"); + + // The lowercase ΐ ends an uppercase run like its decomposed form + assert.equal(strKebabCase("\u0391\u0392\u0390"), "\u03B1-\u03B2\u0390"); + assert.equal(strKebabCase("\u0391\u0392\u03B9\u0308\u0301"), "\u03B1-\u03B2\u03B9\u0308\u0301"); + }); + + it("Supplementary plane letters (surrogate pairs) are case converted as a complete character", () => { + // Deseret capital and small long i (U+10400 / U+10428) + let upper = "\uD801\uDC00"; + let lower = "\uD801\uDC28"; + assert.equal(strLetterCase(lower), upper); + assert.equal(strLetterCase(lower + "x " + lower + lower), upper + "x " + upper + lower); + assert.equal(strCapitalizeWords(upper), upper); + assert.equal(strCapitalizeWords(upper + upper + " " + lower + lower), upper + lower + " " + upper + lower); + assert.equal(strCamelCase(upper + "foo"), lower + "foo"); + assert.equal(strCamelCase(lower + "foo", true), upper + "foo"); + assert.equal(strCamelCase("foo " + lower + "bar"), "foo" + upper + "bar"); + assert.equal(strKebabCase(upper + "Foo"), lower + "-foo"); + + // Characters without a case mapping are unchanged + assert.equal(strLetterCase("\uD83D\uDE00x"), "\uD83D\uDE00x"); + assert.equal(strCamelCase("foo \uD83D\uDE00x"), "foo\uD83D\uDE00x"); + + // Unpaired surrogates are a single code unit + assert.equal(strLetterCase("\uD801x"), "\uD801x"); + assert.equal(strLetterCase("\uD801"), "\uD801"); + assert.equal(strLetterCase("\uDC28x"), "\uDC28x"); + assert.equal(strCamelCase("\uD801X"), "\uD801X"); + }); + + it("Characters outside of the ranges are retained as word characters", () => { + assert.equal(strKebabCase("a\uD83D\uDE00b"), "a\uD83D\uDE00b"); + assert.equal(strKebabCase("\u0915\u093E\u0930 \u0915\u0940"), "\u0915\u093E\u0930-\u0915\u0940"); + assert.equal(strCapitalizeWords("\u0301abc"), "\u0301abc"); + }); +});