Compare commits

...

2 Commits

Author SHA1 Message Date
Ciro Spaciari MacBook
a669587119 perf(runtime): rewrite string case-change in C++ to avoid UTF-8 round-trip
Rewrites the 11 case-changing utility methods (camelCase, pascalCase,
snakeCase, etc.) from Zig to C++, eliminating two unnecessary
allocations and transcoding steps.

The Zig implementation converted every JS string to UTF-8 via
bunstr.toUTF8(allocator), processed codepoints, then converted back
via bun.String.cloneUTF8(result_bytes) — two unnecessary allocations
+ transcoding for every call.

Move to C++ and work directly with the JSC string's native encoding
(Latin1 or UTF-16) using StringView, StringBuilder, and ICU — same
pattern as stripANSI.cpp.

- New: CaseChange.cpp + CaseChange.h with the case-change algorithm
  templated on Latin1Character/UChar
- Wiring: 11 functions registered directly in bunObjectTable as C++
  host functions
- Cleanup: Deleted string_case.zig and all Zig/C++ bridge wiring
  (icu_toUpper/icu_toLower wrappers)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 00:57:18 -08:00
Ciro Spaciari MacBook
b6dd0dcee7 feat(runtime): add 11 case-changing utility methods to Bun global (#15087)
Add Bun.camelCase, pascalCase, snakeCase, kebabCase, constantCase,
dotCase, capitalCase, trainCase, pathCase, sentenceCase, and noCase
matching the change-case npm package. Uses ICU for full Unicode support
and bun.strings.UnsignedCodepointIterator for codepoint iteration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 00:31:39 -08:00
5 changed files with 1114 additions and 0 deletions

View File

@@ -610,6 +610,129 @@ declare module "bun" {
*/
function stripANSI(input: string): string;
/**
* Converts a string to camelCase.
*
* @param input The string to convert.
* @returns The camelCase version of the string.
* @example
* ```ts
* Bun.camelCase("foo bar") // "fooBar"
* Bun.camelCase("XMLParser") // "xmlParser"
* ```
*/
function camelCase(input: string): string;
/**
* Converts a string to PascalCase.
*
* @param input The string to convert.
* @returns The PascalCase version of the string.
* @example
* ```ts
* Bun.pascalCase("foo bar") // "FooBar"
* ```
*/
function pascalCase(input: string): string;
/**
* Converts a string to snake_case.
*
* @param input The string to convert.
* @returns The snake_case version of the string.
* @example
* ```ts
* Bun.snakeCase("fooBar") // "foo_bar"
* ```
*/
function snakeCase(input: string): string;
/**
* Converts a string to kebab-case.
*
* @param input The string to convert.
* @returns The kebab-case version of the string.
* @example
* ```ts
* Bun.kebabCase("fooBar") // "foo-bar"
* ```
*/
function kebabCase(input: string): string;
/**
* Converts a string to CONSTANT_CASE.
*
* @param input The string to convert.
* @returns The CONSTANT_CASE version of the string.
* @example
* ```ts
* Bun.constantCase("fooBar") // "FOO_BAR"
* ```
*/
function constantCase(input: string): string;
/**
* Converts a string to dot.case.
*
* @param input The string to convert.
* @returns The dot.case version of the string.
* @example
* ```ts
* Bun.dotCase("fooBar") // "foo.bar"
* ```
*/
function dotCase(input: string): string;
/**
* Converts a string to Capital Case.
*
* @param input The string to convert.
* @returns The Capital Case version of the string.
* @example
* ```ts
* Bun.capitalCase("fooBar") // "Foo Bar"
* ```
*/
function capitalCase(input: string): string;
/**
* Converts a string to Train-Case.
*
* @param input The string to convert.
* @returns The Train-Case version of the string.
* @example
* ```ts
* Bun.trainCase("fooBar") // "Foo-Bar"
* ```
*/
function trainCase(input: string): string;
/**
* Converts a string to path/case.
*
* @param input The string to convert.
* @returns The path/case version of the string.
* @example
* ```ts
* Bun.pathCase("fooBar") // "foo/bar"
* ```
*/
function pathCase(input: string): string;
/**
* Converts a string to Sentence case.
*
* @param input The string to convert.
* @returns The Sentence case version of the string.
* @example
* ```ts
* Bun.sentenceCase("fooBar") // "Foo bar"
* ```
*/
function sentenceCase(input: string): string;
/**
* Converts a string to no case (lowercased words separated by spaces).
*
* @param input The string to convert.
* @returns The no case version of the string.
* @example
* ```ts
* Bun.noCase("fooBar") // "foo bar"
* ```
*/
function noCase(input: string): string;
interface WrapAnsiOptions {
/**
* If `true`, break words in the middle if they don't fit on a line.

View File

@@ -82,6 +82,8 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionBunStripANSI);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunWrapAnsi);
}
#include "CaseChange.h"
using namespace JSC;
using namespace WebCore;
@@ -932,14 +934,18 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
allocUnsafe BunObject_callback_allocUnsafe DontDelete|Function 1
argv BunObject_lazyPropCb_wrap_argv DontDelete|PropertyCallback
build BunObject_callback_build DontDelete|Function 1
camelCase jsFunctionBunCamelCase DontDelete|Function 1
capitalCase jsFunctionBunCapitalCase DontDelete|Function 1
concatArrayBuffers functionConcatTypedArrays DontDelete|Function 3
connect BunObject_callback_connect DontDelete|Function 1
constantCase jsFunctionBunConstantCase DontDelete|Function 1
cwd BunObject_lazyPropCb_wrap_cwd DontEnum|DontDelete|PropertyCallback
color BunObject_callback_color DontDelete|Function 2
deepEquals functionBunDeepEquals DontDelete|Function 2
deepMatch functionBunDeepMatch DontDelete|Function 2
deflateSync BunObject_callback_deflateSync DontDelete|Function 1
dns constructDNSObject ReadOnly|DontDelete|PropertyCallback
dotCase jsFunctionBunDotCase DontDelete|Function 1
enableANSIColors BunObject_lazyPropCb_wrap_enableANSIColors DontDelete|PropertyCallback
env constructEnvObject ReadOnly|DontDelete|PropertyCallback
escapeHTML functionBunEscapeHTML DontDelete|Function 2
@@ -954,6 +960,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
indexOfLine BunObject_callback_indexOfLine DontDelete|Function 1
inflateSync BunObject_callback_inflateSync DontDelete|Function 1
inspect BunObject_lazyPropCb_wrap_inspect DontDelete|PropertyCallback
kebabCase jsFunctionBunKebabCase DontDelete|Function 1
isMainThread constructIsMainThread ReadOnly|DontDelete|PropertyCallback
jest BunObject_callback_jest DontEnum|DontDelete|Function 1
listen BunObject_callback_listen DontDelete|Function 1
@@ -961,7 +968,10 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
main bunObjectMain DontDelete|CustomAccessor
mmap BunObject_callback_mmap DontDelete|Function 1
nanoseconds functionBunNanoseconds DontDelete|Function 0
noCase jsFunctionBunNoCase DontDelete|Function 1
openInEditor BunObject_callback_openInEditor DontDelete|Function 1
pascalCase jsFunctionBunPascalCase DontDelete|Function 1
pathCase jsFunctionBunPathCase DontDelete|Function 1
origin BunObject_lazyPropCb_wrap_origin DontEnum|ReadOnly|DontDelete|PropertyCallback
version_with_sha constructBunVersionWithSha DontEnum|ReadOnly|DontDelete|PropertyCallback
password constructPasswordObject DontDelete|PropertyCallback
@@ -982,6 +992,8 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
resolveSync BunObject_callback_resolveSync DontDelete|Function 1
revision constructBunRevision ReadOnly|DontDelete|PropertyCallback
semver BunObject_lazyPropCb_wrap_semver ReadOnly|DontDelete|PropertyCallback
sentenceCase jsFunctionBunSentenceCase DontDelete|Function 1
snakeCase jsFunctionBunSnakeCase DontDelete|Function 1
sql defaultBunSQLObject DontDelete|PropertyCallback
postgres defaultBunSQLObject DontDelete|PropertyCallback
SQL constructBunSQLObject DontDelete|PropertyCallback
@@ -997,6 +1009,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
stdout BunObject_lazyPropCb_wrap_stdout DontDelete|PropertyCallback
stringWidth Generated::BunObject::jsStringWidth DontDelete|Function 2
stripANSI jsFunctionBunStripANSI DontDelete|Function 1
trainCase jsFunctionBunTrainCase DontDelete|Function 1
wrapAnsi jsFunctionBunWrapAnsi DontDelete|Function 3
Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback

View File

@@ -0,0 +1,355 @@
#include "root.h"
#include "CaseChange.h"
#include <unicode/uchar.h>
#include <unicode/utf16.h>
#include <wtf/text/StringBuilder.h>
#include <wtf/text/WTFString.h>
namespace Bun {
using namespace JSC;
using namespace WTF;
enum class CaseType {
Camel,
Pascal,
Snake,
Kebab,
Constant,
Dot,
Capital,
Train,
Path,
Sentence,
No
};
enum class CharClass {
Lower,
Upper,
Digit,
Other
};
enum class WordTransform {
Lower,
Upper,
Capitalize
};
static inline CharClass classifyCp(char32_t c)
{
if (c < 0x80) {
if (c >= 'a' && c <= 'z')
return CharClass::Lower;
if (c >= 'A' && c <= 'Z')
return CharClass::Upper;
if (c >= '0' && c <= '9')
return CharClass::Digit;
return CharClass::Other;
}
if (u_hasBinaryProperty(c, UCHAR_UPPERCASE))
return CharClass::Upper;
if (u_hasBinaryProperty(c, UCHAR_ALPHABETIC))
return CharClass::Lower;
return CharClass::Other;
}
static inline char separator(CaseType type)
{
switch (type) {
case CaseType::Camel:
case CaseType::Pascal:
return 0;
case CaseType::Snake:
case CaseType::Constant:
return '_';
case CaseType::Kebab:
case CaseType::Train:
return '-';
case CaseType::Dot:
return '.';
case CaseType::Capital:
case CaseType::Sentence:
case CaseType::No:
return ' ';
case CaseType::Path:
return '/';
}
RELEASE_ASSERT_NOT_REACHED();
}
static inline bool hasDigitPrefixUnderscore(CaseType type)
{
return type == CaseType::Camel || type == CaseType::Pascal;
}
static inline WordTransform getTransform(CaseType type, size_t wordIndex)
{
switch (type) {
case CaseType::Camel:
return wordIndex == 0 ? WordTransform::Lower : WordTransform::Capitalize;
case CaseType::Pascal:
return WordTransform::Capitalize;
case CaseType::Snake:
case CaseType::Kebab:
case CaseType::Dot:
case CaseType::Path:
case CaseType::No:
return WordTransform::Lower;
case CaseType::Constant:
return WordTransform::Upper;
case CaseType::Capital:
case CaseType::Train:
return WordTransform::Capitalize;
case CaseType::Sentence:
return wordIndex == 0 ? WordTransform::Capitalize : WordTransform::Lower;
}
RELEASE_ASSERT_NOT_REACHED();
}
// Word boundary detection and case conversion, templated on character type.
// For Latin1Character, each element is a codepoint.
// For UChar, we use U16_NEXT to handle surrogate pairs.
template<typename CharType>
static WTF::String convertCase(CaseType type, std::span<const CharType> input)
{
// First pass: collect word boundaries (start/end byte offsets)
struct WordRange {
uint32_t start;
uint32_t end;
};
Vector<WordRange, 16> words;
{
bool inWord = false;
uint32_t wordStart = 0;
uint32_t wordEnd = 0;
CharClass prevClass = CharClass::Other;
CharClass prevPrevClass = CharClass::Other;
uint32_t prevPos = 0;
int32_t i = 0;
int32_t length = static_cast<int32_t>(input.size());
while (i < length) {
uint32_t curPos = static_cast<uint32_t>(i);
char32_t cp;
if constexpr (std::is_same_v<CharType, Latin1Character>) {
cp = input[i];
i++;
} else {
U16_NEXT(input.data(), i, length, cp);
}
uint32_t curEnd = static_cast<uint32_t>(i);
CharClass curClass = classifyCp(cp);
if (curClass == CharClass::Other) {
if (inWord) {
inWord = false;
words.append({ wordStart, wordEnd });
prevClass = CharClass::Other;
prevPrevClass = CharClass::Other;
} else {
prevClass = CharClass::Other;
prevPrevClass = CharClass::Other;
}
continue;
}
if (!inWord) {
inWord = true;
wordStart = curPos;
wordEnd = curEnd;
prevPrevClass = CharClass::Other;
prevClass = curClass;
prevPos = curPos;
continue;
}
// Rule 2: upper+upper+lower → boundary before the last upper
if (prevPrevClass == CharClass::Upper && prevClass == CharClass::Upper && curClass == CharClass::Lower) {
words.append({ wordStart, prevPos });
wordStart = prevPos;
wordEnd = curEnd;
prevPrevClass = prevClass;
prevClass = curClass;
prevPos = curPos;
continue;
}
// Rule 1: (lower | digit) → upper boundary
if ((prevClass == CharClass::Lower || prevClass == CharClass::Digit) && curClass == CharClass::Upper) {
words.append({ wordStart, wordEnd });
wordStart = curPos;
wordEnd = curEnd;
prevPrevClass = CharClass::Other;
prevClass = curClass;
prevPos = curPos;
continue;
}
// No boundary, extend current word
wordEnd = curEnd;
prevPrevClass = prevClass;
prevClass = curClass;
prevPos = curPos;
}
// Flush last word
if (inWord)
words.append({ wordStart, wordEnd });
}
if (words.isEmpty())
return emptyString();
// Second pass: build the output string
StringBuilder builder;
builder.reserveCapacity(input.size() + input.size() / 4);
char sep = separator(type);
for (size_t wordIndex = 0; wordIndex < words.size(); wordIndex++) {
auto& word = words[wordIndex];
// Separator between words
if (wordIndex > 0 && sep)
builder.append(sep);
// Digit-prefix underscore for camelCase/pascalCase
if (wordIndex > 0 && hasDigitPrefixUnderscore(type)) {
char32_t firstCp;
if constexpr (std::is_same_v<CharType, Latin1Character>) {
firstCp = input[word.start];
} else {
int32_t tmpI = word.start;
U16_NEXT(input.data(), tmpI, static_cast<int32_t>(input.size()), firstCp);
}
if (firstCp >= '0' && firstCp <= '9')
builder.append('_');
}
WordTransform transform = getTransform(type, wordIndex);
// Iterate codepoints within the word and apply transform
int32_t pos = word.start;
int32_t end = word.end;
bool isFirst = true;
while (pos < end) {
char32_t cp;
if constexpr (std::is_same_v<CharType, Latin1Character>) {
cp = input[pos];
pos++;
} else {
U16_NEXT(input.data(), pos, end, cp);
}
char32_t transformed;
switch (transform) {
case WordTransform::Lower:
transformed = u_tolower(cp);
break;
case WordTransform::Upper:
transformed = u_toupper(cp);
break;
case WordTransform::Capitalize:
transformed = isFirst ? u_toupper(cp) : u_tolower(cp);
break;
}
isFirst = false;
builder.append(static_cast<char32_t>(transformed));
}
}
return builder.toString();
}
static EncodedJSValue caseChangeImpl(CaseType type, JSGlobalObject* globalObject, CallFrame* callFrame)
{
auto& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);
JSValue input = callFrame->argument(0);
if (!input.isString()) {
throwTypeError(globalObject, scope, "Expected a string argument"_s);
return {};
}
JSString* jsStr = input.toString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto view = jsStr->view(globalObject);
RETURN_IF_EXCEPTION(scope, {});
if (view->isEmpty())
return JSValue::encode(jsEmptyString(vm));
WTF::String result = view->is8Bit()
? convertCase<Latin1Character>(type, view->span8())
: convertCase<UChar>(type, view->span16());
return JSValue::encode(jsString(vm, WTF::move(result)));
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunCamelCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Camel, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunPascalCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Pascal, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunSnakeCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Snake, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunKebabCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Kebab, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunConstantCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Constant, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunDotCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Dot, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunCapitalCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Capital, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunTrainCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Train, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunPathCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Path, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunSentenceCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::Sentence, globalObject, callFrame);
}
JSC_DEFINE_HOST_FUNCTION(jsFunctionBunNoCase, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
return caseChangeImpl(CaseType::No, globalObject, callFrame);
}
} // namespace Bun

View File

@@ -0,0 +1,19 @@
#pragma once
#include "root.h"
namespace Bun {
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunCamelCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunPascalCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunSnakeCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunKebabCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunConstantCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunDotCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunCapitalCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunTrainCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunPathCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunSentenceCase);
JSC_DECLARE_HOST_FUNCTION(jsFunctionBunNoCase);
}

View File

@@ -0,0 +1,604 @@
import { describe, expect, test } from "bun:test";
import {
camelCase,
capitalCase,
constantCase,
dotCase,
kebabCase,
noCase,
pascalCase,
pathCase,
sentenceCase,
snakeCase,
trainCase,
} from "change-case";
type CaseFn = (input: string) => string;
const bunFns: Record<string, CaseFn> = {
camelCase: Bun.camelCase,
capitalCase: Bun.capitalCase,
constantCase: Bun.constantCase,
dotCase: Bun.dotCase,
kebabCase: Bun.kebabCase,
noCase: Bun.noCase,
pascalCase: Bun.pascalCase,
pathCase: Bun.pathCase,
sentenceCase: Bun.sentenceCase,
snakeCase: Bun.snakeCase,
trainCase: Bun.trainCase,
};
const changeCaseFns: Record<string, CaseFn> = {
camelCase,
capitalCase,
constantCase,
dotCase,
kebabCase,
noCase,
pascalCase,
pathCase,
sentenceCase,
snakeCase,
trainCase,
};
// Comprehensive input set covering many patterns
const testInputs = [
// Basic words
"test",
"foo",
"a",
"",
// Multi-word with various separators
"test string",
"test_string",
"test-string",
"test.string",
"test/string",
"test\tstring",
// Cased inputs
"Test String",
"TEST STRING",
"TestString",
"testString",
"TEST_STRING",
// Acronyms and consecutive uppercase
"XMLParser",
"getHTTPSURL",
"parseJSON",
"simpleXML",
"PDFLoader",
"I18N",
"ABC",
"ABCdef",
"ABCDef",
"HTMLElement",
"innerHTML",
"XMLHttpRequest",
"getURLParams",
"isHTTPS",
"CSSStyleSheet",
"IOError",
"UIKit",
// Numbers
"version 1.2.10",
"TestV2",
"test123",
"123test",
"test 123 value",
"1st place",
"v2beta1",
"ES6Module",
"utf8Decode",
"base64Encode",
"h1Element",
"int32Array",
"123",
"123 456",
"a1b2c3",
"test0",
// Multiple separators / weird spacing
"foo___bar",
"foo---bar",
"foo...bar",
"foo bar",
" leading spaces ",
"__private",
"--dashed--",
"..dotted..",
" ",
"\t\ttabs\t\t",
"foo_-_bar",
"foo.-bar",
// All uppercase
"FOO_BAR_BAZ",
"ALLCAPS",
"FOO BAR",
"FOO-BAR",
"FOO.BAR",
// Mixed case
"fooBarBaz",
"FooBarBaz",
"Foo Bar",
"MiXeD CaSe",
"already camelCase",
"already PascalCase",
"already_snake_case",
"already-kebab-case",
"Already Capital Case",
"ALREADY_CONSTANT_CASE",
// Pre-formatted cases
"Train-Case-Input",
"dot.case.input",
"path/case/input",
"Sentence case input",
"no case input",
// Single characters
"A",
"z",
"Z",
"0",
// Real-world identifiers
"backgroundColor",
"border-top-color",
"MAX_RETRY_COUNT",
"Content-Type",
"X-Forwarded-For",
"user_id",
"getUserById",
"class_name",
"className",
"is_active",
"isActive",
"created_at",
"createdAt",
"HTTPSConnection",
"myXMLParser",
"getDBConnection",
"setHTTPSEnabled",
"enableSSL",
"useGPU",
"readCSV",
"parseHTML",
"toJSON",
"fromURL",
"isNaN",
"toString",
"valueOf",
// Column name style inputs (SQL)
"first_name",
"last_name",
"email_address",
"phone_number",
"order_total",
"created_at",
"updated_at",
"is_deleted",
// Hyphenated compound words
"well-known",
"read-only",
"built-in",
"self-contained",
// Strings with only separators
"---",
"___",
"...",
"///",
"-_.-_.",
// Unicode (basic)
"café latte",
"naïve résumé",
"hello 世界",
// Long strings
"this is a much longer test string with many words to convert",
"thisIsAMuchLongerTestStringWithManyWordsToConvert",
"THIS_IS_A_MUCH_LONGER_TEST_STRING_WITH_MANY_WORDS_TO_CONVERT",
];
const allCaseNames = Object.keys(bunFns);
describe("case-change", () => {
// Main compatibility matrix: every function x every input
for (const caseName of allCaseNames) {
const bunFn = bunFns[caseName];
const changeCaseFn = changeCaseFns[caseName];
describe(caseName, () => {
for (const input of testInputs) {
const expected = changeCaseFn(input);
test(`${JSON.stringify(input)} => ${JSON.stringify(expected)}`, () => {
expect(bunFn(input)).toBe(expected);
});
}
});
}
// Cross-conversion round-trips: convert from A to B, compare both implementations
describe("cross-conversion round-trips", () => {
const conversions = [
"camelCase",
"pascalCase",
"snakeCase",
"kebabCase",
"constantCase",
"noCase",
"dotCase",
] as const;
const roundTripInputs = [
"hello world",
"fooBarBaz",
"FOO_BAR",
"XMLParser",
"getHTTPSURL",
"test_string",
"Test String",
"already-kebab",
"version 1.2.10",
];
for (const input of roundTripInputs) {
for (const from of conversions) {
for (const to of conversions) {
const intermediate = changeCaseFns[from](input);
const expected = changeCaseFns[to](intermediate);
test(`${from}(${JSON.stringify(input)}) => ${to}`, () => {
const bunIntermediate = bunFns[from](input);
expect(bunFns[to](bunIntermediate)).toBe(expected);
});
}
}
}
});
// Double-conversion stability: converting the output again should be idempotent
describe("idempotency", () => {
const idempotentInputs = ["hello world", "fooBarBaz", "FOO_BAR_BAZ", "XMLParser", "test 123", "café latte"];
for (const caseName of allCaseNames) {
const bunFn = bunFns[caseName];
const changeCaseFn = changeCaseFns[caseName];
for (const input of idempotentInputs) {
test(`${caseName}(${caseName}(${JSON.stringify(input)})) is idempotent`, () => {
const once = bunFn(input);
const twice = bunFn(once);
const expectedOnce = changeCaseFn(input);
const expectedTwice = changeCaseFn(expectedOnce);
expect(once).toBe(expectedOnce);
expect(twice).toBe(expectedTwice);
});
}
}
});
// Specific per-function expected values (hardcoded, not generated)
describe("specific expected values", () => {
test("camelCase", () => {
expect(Bun.camelCase("foo bar")).toBe("fooBar");
expect(Bun.camelCase("foo-bar")).toBe("fooBar");
expect(Bun.camelCase("foo_bar")).toBe("fooBar");
expect(Bun.camelCase("FOO_BAR")).toBe("fooBar");
expect(Bun.camelCase("FooBar")).toBe("fooBar");
expect(Bun.camelCase("fooBar")).toBe("fooBar");
expect(Bun.camelCase("")).toBe("");
expect(Bun.camelCase("foo")).toBe("foo");
expect(Bun.camelCase("A")).toBe("a");
});
test("pascalCase", () => {
expect(Bun.pascalCase("foo bar")).toBe("FooBar");
expect(Bun.pascalCase("foo-bar")).toBe("FooBar");
expect(Bun.pascalCase("foo_bar")).toBe("FooBar");
expect(Bun.pascalCase("FOO_BAR")).toBe("FooBar");
expect(Bun.pascalCase("fooBar")).toBe("FooBar");
expect(Bun.pascalCase("")).toBe("");
expect(Bun.pascalCase("foo")).toBe("Foo");
});
test("snakeCase", () => {
expect(Bun.snakeCase("foo bar")).toBe("foo_bar");
expect(Bun.snakeCase("fooBar")).toBe("foo_bar");
expect(Bun.snakeCase("FooBar")).toBe("foo_bar");
expect(Bun.snakeCase("FOO_BAR")).toBe("foo_bar");
expect(Bun.snakeCase("foo-bar")).toBe("foo_bar");
expect(Bun.snakeCase("")).toBe("");
});
test("kebabCase", () => {
expect(Bun.kebabCase("foo bar")).toBe("foo-bar");
expect(Bun.kebabCase("fooBar")).toBe("foo-bar");
expect(Bun.kebabCase("FooBar")).toBe("foo-bar");
expect(Bun.kebabCase("FOO_BAR")).toBe("foo-bar");
expect(Bun.kebabCase("foo_bar")).toBe("foo-bar");
expect(Bun.kebabCase("")).toBe("");
});
test("constantCase", () => {
expect(Bun.constantCase("foo bar")).toBe("FOO_BAR");
expect(Bun.constantCase("fooBar")).toBe("FOO_BAR");
expect(Bun.constantCase("FooBar")).toBe("FOO_BAR");
expect(Bun.constantCase("foo-bar")).toBe("FOO_BAR");
expect(Bun.constantCase("foo_bar")).toBe("FOO_BAR");
expect(Bun.constantCase("")).toBe("");
});
test("dotCase", () => {
expect(Bun.dotCase("foo bar")).toBe("foo.bar");
expect(Bun.dotCase("fooBar")).toBe("foo.bar");
expect(Bun.dotCase("FOO_BAR")).toBe("foo.bar");
expect(Bun.dotCase("")).toBe("");
});
test("capitalCase", () => {
expect(Bun.capitalCase("foo bar")).toBe("Foo Bar");
expect(Bun.capitalCase("fooBar")).toBe("Foo Bar");
expect(Bun.capitalCase("FOO_BAR")).toBe("Foo Bar");
expect(Bun.capitalCase("")).toBe("");
});
test("trainCase", () => {
expect(Bun.trainCase("foo bar")).toBe("Foo-Bar");
expect(Bun.trainCase("fooBar")).toBe("Foo-Bar");
expect(Bun.trainCase("FOO_BAR")).toBe("Foo-Bar");
expect(Bun.trainCase("")).toBe("");
});
test("pathCase", () => {
expect(Bun.pathCase("foo bar")).toBe("foo/bar");
expect(Bun.pathCase("fooBar")).toBe("foo/bar");
expect(Bun.pathCase("FOO_BAR")).toBe("foo/bar");
expect(Bun.pathCase("")).toBe("");
});
test("sentenceCase", () => {
expect(Bun.sentenceCase("foo bar")).toBe("Foo bar");
expect(Bun.sentenceCase("fooBar")).toBe("Foo bar");
expect(Bun.sentenceCase("FOO_BAR")).toBe("Foo bar");
expect(Bun.sentenceCase("")).toBe("");
});
test("noCase", () => {
expect(Bun.noCase("foo bar")).toBe("foo bar");
expect(Bun.noCase("fooBar")).toBe("foo bar");
expect(Bun.noCase("FOO_BAR")).toBe("foo bar");
expect(Bun.noCase("FooBar")).toBe("foo bar");
expect(Bun.noCase("")).toBe("");
});
});
// Edge cases
describe("edge cases", () => {
test("empty string returns empty for all functions", () => {
for (const caseName of allCaseNames) {
expect(bunFns[caseName]("")).toBe("");
}
});
test("single character", () => {
for (const ch of ["a", "A", "z", "Z", "0", "9"]) {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](ch)).toBe(changeCaseFns[caseName](ch));
}
}
});
test("all separators produce empty for all functions", () => {
for (const sep of ["---", "___", "...", " ", "\t\t", "-_.-_."]) {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](sep)).toBe(changeCaseFns[caseName](sep));
}
}
});
test("numbers only", () => {
for (const input of ["123", "0", "999", "123 456", "1.2.3"]) {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](input)).toBe(changeCaseFns[caseName](input));
}
}
});
test("mixed numbers and letters", () => {
for (const input of [
"test123",
"123test",
"test 123 value",
"1st place",
"v2beta1",
"a1b2c3",
"ES6Module",
"utf8Decode",
"base64Encode",
"h1Element",
"int32Array",
]) {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](input)).toBe(changeCaseFns[caseName](input));
}
}
});
test("consecutive separators are collapsed", () => {
for (const input of ["foo___bar", "foo---bar", "foo...bar", "foo bar"]) {
expect(Bun.camelCase(input)).toBe(camelCase(input));
expect(Bun.snakeCase(input)).toBe(snakeCase(input));
}
});
test("leading and trailing separators are stripped", () => {
for (const input of [" foo ", "__bar__", "--baz--", "..qux.."]) {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](input)).toBe(changeCaseFns[caseName](input));
}
}
});
test("unicode strings", () => {
for (const input of ["café latte", "naïve résumé", "hello 世界"]) {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](input)).toBe(changeCaseFns[caseName](input));
}
}
});
test("acronym splitting", () => {
// These specifically test the upper->upper+lower boundary rule
for (const input of [
"XMLParser",
"HTMLElement",
"innerHTML",
"XMLHttpRequest",
"getURLParams",
"isHTTPS",
"CSSStyleSheet",
"IOError",
"UIKit",
"HTTPSConnection",
"myXMLParser",
"getDBConnection",
"setHTTPSEnabled",
"ABCDef",
"ABCdef",
]) {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](input)).toBe(changeCaseFns[caseName](input));
}
}
});
test("digit-prefix underscore in camelCase/pascalCase", () => {
// change-case inserts _ before digit-starting words (index > 0) in camel/pascal
const input = "version 1.2.10";
expect(Bun.camelCase(input)).toBe(camelCase(input));
expect(Bun.pascalCase(input)).toBe(pascalCase(input));
// snake/kebab/etc should NOT have the _ prefix
expect(Bun.snakeCase(input)).toBe(snakeCase(input));
expect(Bun.kebabCase(input)).toBe(kebabCase(input));
});
test("long strings", () => {
const long =
"this is a much longer test string with many words to convert and it keeps going and going and going";
for (const caseName of allCaseNames) {
expect(bunFns[caseName](long)).toBe(changeCaseFns[caseName](long));
}
});
test("repeated single word", () => {
expect(Bun.camelCase("foo")).toBe("foo");
expect(Bun.pascalCase("foo")).toBe("Foo");
expect(Bun.snakeCase("foo")).toBe("foo");
expect(Bun.kebabCase("foo")).toBe("foo");
expect(Bun.constantCase("foo")).toBe("FOO");
});
test("single uppercase word", () => {
expect(Bun.camelCase("FOO")).toBe(camelCase("FOO"));
expect(Bun.pascalCase("FOO")).toBe(pascalCase("FOO"));
expect(Bun.snakeCase("FOO")).toBe(snakeCase("FOO"));
});
});
// Error handling
describe("error handling", () => {
for (const caseName of allCaseNames) {
const fn = bunFns[caseName];
test(`${caseName}() with no arguments throws`, () => {
// @ts-expect-error
expect(() => fn()).toThrow();
});
test(`${caseName}(123) with number throws`, () => {
// @ts-expect-error
expect(() => fn(123)).toThrow();
});
test(`${caseName}(null) throws`, () => {
// @ts-expect-error
expect(() => fn(null)).toThrow();
});
test(`${caseName}(undefined) throws`, () => {
// @ts-expect-error
expect(() => fn(undefined)).toThrow();
});
test(`${caseName}({}) with object throws`, () => {
// @ts-expect-error
expect(() => fn({})).toThrow();
});
test(`${caseName}([]) with array throws`, () => {
// @ts-expect-error
expect(() => fn([])).toThrow();
});
test(`${caseName}(true) with boolean throws`, () => {
// @ts-expect-error
expect(() => fn(true)).toThrow();
});
}
});
// Ensure .length property is 1
describe("function.length", () => {
for (const caseName of allCaseNames) {
test(`Bun.${caseName}.length === 1`, () => {
expect(bunFns[caseName].length).toBe(1);
});
}
});
// Stress test with generated inputs
describe("generated inputs", () => {
// Words joined with various separators
const words = ["foo", "bar", "baz", "qux"];
const separators = [" ", "_", "-", ".", "/", " ", "__", "--"];
for (const sep of separators) {
const input = words.join(sep);
test(`words joined by ${JSON.stringify(sep)}: ${JSON.stringify(input)}`, () => {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](input)).toBe(changeCaseFns[caseName](input));
}
});
}
// Various camelCase-style inputs
const camelInputs = [
"oneTwoThree",
"OneTwoThree",
"oneTWOThree",
"ONETwoThree",
"oneTwo3",
"one2Three",
"one23",
"oneABCTwo",
];
for (const input of camelInputs) {
test(`camelCase-style: ${JSON.stringify(input)}`, () => {
for (const caseName of allCaseNames) {
expect(bunFns[caseName](input)).toBe(changeCaseFns[caseName](input));
}
});
}
});
});