Compare commits

...

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
ff3f701486 [autofix.ci] apply automated fixes 2026-02-06 08:35:08 +00: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
8 changed files with 1102 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

@@ -12,31 +12,42 @@ pub const BunObject = struct {
// --- Callbacks ---
pub const allocUnsafe = toJSCallback(Bun.allocUnsafe);
pub const build = toJSCallback(Bun.JSBundler.buildFn);
pub const camelCase = toJSCallback(bun.string_case.camelCase);
pub const capitalCase = toJSCallback(bun.string_case.capitalCase);
pub const color = toJSCallback(bun.css.CssColor.jsFunctionColor);
pub const connect = toJSCallback(host_fn.wrapStaticMethod(api.Listener, "connect", false));
pub const constantCase = toJSCallback(bun.string_case.constantCase);
pub const createParsedShellScript = toJSCallback(bun.shell.ParsedShellScript.createParsedShellScript);
pub const createShellInterpreter = toJSCallback(bun.shell.Interpreter.createShellInterpreter);
pub const deflateSync = toJSCallback(JSZlib.deflateSync);
pub const dotCase = toJSCallback(bun.string_case.dotCase);
pub const file = toJSCallback(WebCore.Blob.constructBunFile);
pub const gunzipSync = toJSCallback(JSZlib.gunzipSync);
pub const gzipSync = toJSCallback(JSZlib.gzipSync);
pub const indexOfLine = toJSCallback(Bun.indexOfLine);
pub const inflateSync = toJSCallback(JSZlib.inflateSync);
pub const jest = toJSCallback(@import("../test/jest.zig").Jest.call);
pub const kebabCase = toJSCallback(bun.string_case.kebabCase);
pub const listen = toJSCallback(host_fn.wrapStaticMethod(api.Listener, "listen", false));
pub const mmap = toJSCallback(Bun.mmapFile);
pub const nanoseconds = toJSCallback(Bun.nanoseconds);
pub const noCase = toJSCallback(bun.string_case.noCase);
pub const openInEditor = toJSCallback(Bun.openInEditor);
pub const pascalCase = toJSCallback(bun.string_case.pascalCase);
pub const pathCase = toJSCallback(bun.string_case.pathCase);
pub const registerMacro = toJSCallback(Bun.registerMacro);
pub const resolve = toJSCallback(Bun.resolve);
pub const resolveSync = toJSCallback(Bun.resolveSync);
pub const sentenceCase = toJSCallback(bun.string_case.sentenceCase);
pub const serve = toJSCallback(Bun.serve);
pub const sha = toJSCallback(host_fn.wrapStaticMethod(Crypto.SHA512_256, "hash_", true));
pub const snakeCase = toJSCallback(bun.string_case.snakeCase);
pub const shellEscape = toJSCallback(Bun.shellEscape);
pub const shrink = toJSCallback(Bun.shrink);
pub const sleepSync = toJSCallback(Bun.sleepSync);
pub const spawn = toJSCallback(host_fn.wrapStaticMethod(api.Subprocess, "spawn", false));
pub const spawnSync = toJSCallback(host_fn.wrapStaticMethod(api.Subprocess, "spawnSync", false));
pub const trainCase = toJSCallback(bun.string_case.trainCase);
pub const udpSocket = toJSCallback(host_fn.wrapStaticMethod(api.UDPSocket, "udpSocket", false));
pub const which = toJSCallback(Bun.which);
pub const write = toJSCallback(jsc.WebCore.Blob.writeFile);
@@ -157,31 +168,42 @@ pub const BunObject = struct {
// --- Callbacks ---
@export(&BunObject.allocUnsafe, .{ .name = callbackName("allocUnsafe") });
@export(&BunObject.build, .{ .name = callbackName("build") });
@export(&BunObject.camelCase, .{ .name = callbackName("camelCase") });
@export(&BunObject.capitalCase, .{ .name = callbackName("capitalCase") });
@export(&BunObject.color, .{ .name = callbackName("color") });
@export(&BunObject.connect, .{ .name = callbackName("connect") });
@export(&BunObject.constantCase, .{ .name = callbackName("constantCase") });
@export(&BunObject.createParsedShellScript, .{ .name = callbackName("createParsedShellScript") });
@export(&BunObject.createShellInterpreter, .{ .name = callbackName("createShellInterpreter") });
@export(&BunObject.deflateSync, .{ .name = callbackName("deflateSync") });
@export(&BunObject.dotCase, .{ .name = callbackName("dotCase") });
@export(&BunObject.file, .{ .name = callbackName("file") });
@export(&BunObject.gunzipSync, .{ .name = callbackName("gunzipSync") });
@export(&BunObject.gzipSync, .{ .name = callbackName("gzipSync") });
@export(&BunObject.indexOfLine, .{ .name = callbackName("indexOfLine") });
@export(&BunObject.inflateSync, .{ .name = callbackName("inflateSync") });
@export(&BunObject.jest, .{ .name = callbackName("jest") });
@export(&BunObject.kebabCase, .{ .name = callbackName("kebabCase") });
@export(&BunObject.listen, .{ .name = callbackName("listen") });
@export(&BunObject.mmap, .{ .name = callbackName("mmap") });
@export(&BunObject.nanoseconds, .{ .name = callbackName("nanoseconds") });
@export(&BunObject.noCase, .{ .name = callbackName("noCase") });
@export(&BunObject.openInEditor, .{ .name = callbackName("openInEditor") });
@export(&BunObject.pascalCase, .{ .name = callbackName("pascalCase") });
@export(&BunObject.pathCase, .{ .name = callbackName("pathCase") });
@export(&BunObject.registerMacro, .{ .name = callbackName("registerMacro") });
@export(&BunObject.resolve, .{ .name = callbackName("resolve") });
@export(&BunObject.resolveSync, .{ .name = callbackName("resolveSync") });
@export(&BunObject.sentenceCase, .{ .name = callbackName("sentenceCase") });
@export(&BunObject.serve, .{ .name = callbackName("serve") });
@export(&BunObject.sha, .{ .name = callbackName("sha") });
@export(&BunObject.shellEscape, .{ .name = callbackName("shellEscape") });
@export(&BunObject.snakeCase, .{ .name = callbackName("snakeCase") });
@export(&BunObject.shrink, .{ .name = callbackName("shrink") });
@export(&BunObject.sleepSync, .{ .name = callbackName("sleepSync") });
@export(&BunObject.spawn, .{ .name = callbackName("spawn") });
@export(&BunObject.spawnSync, .{ .name = callbackName("spawnSync") });
@export(&BunObject.trainCase, .{ .name = callbackName("trainCase") });
@export(&BunObject.udpSocket, .{ .name = callbackName("udpSocket") });
@export(&BunObject.which, .{ .name = callbackName("which") });
@export(&BunObject.write, .{ .name = callbackName("write") });

View File

@@ -44,11 +44,15 @@
macro(allocUnsafe) \
macro(braces) \
macro(build) \
macro(camelCase) \
macro(capitalCase) \
macro(color) \
macro(connect) \
macro(constantCase) \
macro(createParsedShellScript) \
macro(createShellInterpreter) \
macro(deflateSync) \
macro(dotCase) \
macro(file) \
macro(fs) \
macro(gc) \
@@ -58,21 +62,28 @@
macro(indexOfLine) \
macro(inflateSync) \
macro(jest) \
macro(kebabCase) \
macro(listen) \
macro(mmap) \
macro(nanoseconds) \
macro(noCase) \
macro(openInEditor) \
macro(pascalCase) \
macro(pathCase) \
macro(registerMacro) \
macro(resolve) \
macro(resolveSync) \
macro(sentenceCase) \
macro(serve) \
macro(sha) \
macro(shellEscape) \
macro(snakeCase) \
macro(shrink) \
macro(sleepSync) \
macro(spawn) \
macro(spawnSync) \
macro(stringWidth) \
macro(trainCase) \
macro(udpSocket) \
macro(which) \
macro(write) \

View File

@@ -932,14 +932,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 BunObject_callback_camelCase DontDelete|Function 1
capitalCase BunObject_callback_capitalCase DontDelete|Function 1
concatArrayBuffers functionConcatTypedArrays DontDelete|Function 3
connect BunObject_callback_connect DontDelete|Function 1
constantCase BunObject_callback_constantCase 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 BunObject_callback_dotCase DontDelete|Function 1
enableANSIColors BunObject_lazyPropCb_wrap_enableANSIColors DontDelete|PropertyCallback
env constructEnvObject ReadOnly|DontDelete|PropertyCallback
escapeHTML functionBunEscapeHTML DontDelete|Function 2
@@ -954,6 +958,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 BunObject_callback_kebabCase 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 +966,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 BunObject_callback_noCase DontDelete|Function 1
openInEditor BunObject_callback_openInEditor DontDelete|Function 1
pascalCase BunObject_callback_pascalCase DontDelete|Function 1
pathCase BunObject_callback_pathCase 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 +990,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 BunObject_callback_sentenceCase DontDelete|Function 1
snakeCase BunObject_callback_snakeCase DontDelete|Function 1
sql defaultBunSQLObject DontDelete|PropertyCallback
postgres defaultBunSQLObject DontDelete|PropertyCallback
SQL constructBunSQLObject DontDelete|PropertyCallback
@@ -997,6 +1007,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 BunObject_callback_trainCase DontDelete|Function 1
wrapAnsi jsFunctionBunWrapAnsi DontDelete|Function 3
Terminal BunObject_lazyPropCb_wrap_Terminal DontDelete|PropertyCallback
unsafe BunObject_lazyPropCb_wrap_unsafe DontDelete|PropertyCallback

View File

@@ -277,4 +277,14 @@ extern "C" bool icu_hasBinaryProperty(UChar32 cp, unsigned int prop)
return u_hasBinaryProperty(cp, static_cast<UProperty>(prop));
}
extern "C" UChar32 icu_toUpper(UChar32 cp)
{
return u_toupper(cp);
}
extern "C" UChar32 icu_toLower(UChar32 cp)
{
return u_tolower(cp);
}
extern "C" __attribute__((weak)) void mi_thread_set_in_threadpool() {}

View File

@@ -231,6 +231,7 @@ pub const bits = @import("./bits.zig");
pub const css = @import("./css/css_parser.zig");
pub const SmallList = css.SmallList;
pub const csrf = @import("./csrf.zig");
pub const string_case = @import("./string_case.zig");
pub const validators = @import("./bun.js/node/util/validators.zig");
pub const shell = @import("./shell/shell.zig");

320
src/string_case.zig Normal file
View File

@@ -0,0 +1,320 @@
/// Case-changing utility methods matching the `change-case` npm package.
/// Exposes 11 case conversion functions: camelCase, pascalCase, snakeCase,
/// kebabCase, constantCase, dotCase, capitalCase, trainCase, pathCase,
/// sentenceCase, noCase.
///
/// Word splitting matches `change-case`'s three-phase approach:
/// 1. Lower/digit → upper boundary
/// 2. Upper → upper+lower boundary (e.g., "XMLParser" → "XML" + "Parser")
/// 3. Non-letter/non-digit separators
pub const CaseType = enum {
camel,
pascal,
snake,
kebab,
constant,
dot,
capital,
train,
path,
sentence,
no,
fn separator(self: CaseType) ?u8 {
return switch (self) {
.camel, .pascal => null,
.snake, .constant => '_',
.kebab, .train => '-',
.dot => '.',
.capital, .sentence, .no => ' ',
.path => '/',
};
}
fn hasDigitPrefixUnderscore(self: CaseType) bool {
return self == .camel or self == .pascal;
}
fn getTransform(self: CaseType, word_index: usize) WordTransform {
return switch (self) {
.camel => if (word_index == 0) .lower else .capitalize,
.pascal => .capitalize,
.snake, .kebab, .dot, .path, .no => .lower,
.constant => .upper,
.capital, .train => .capitalize,
.sentence => if (word_index == 0) .capitalize else .lower,
};
}
};
const WordTransform = enum { lower, upper, capitalize };
const CaseOp = enum { lower, upper };
const CharClass = enum { lower, upper, digit, other };
const WordRange = struct { start: u32, end: u32 };
fn classifyCp(c: u32) CharClass {
if (c < 0x80) {
const b: u8 = @intCast(c);
if (std.ascii.isLower(b)) return .lower;
if (std.ascii.isUpper(b)) return .upper;
if (std.ascii.isDigit(b)) return .digit;
return .other;
}
if (icu_hasBinaryProperty(c, uchar_uppercase)) return .upper;
if (icu_hasBinaryProperty(c, uchar_alphabetic)) return .lower;
return .other;
}
/// Encode a codepoint as UTF-8 and append to result buffer.
fn appendCodepoint(result: *std.array_list.Managed(u8), cp: u21) !void {
var buf: [4]u8 = undefined;
const len = strings.encodeWTF8RuneT(&buf, u21, cp);
try result.appendSlice(buf[0..len]);
}
/// Apply case conversion to a codepoint using ICU.
fn transformCp(cp: u32, op: CaseOp) u21 {
return @intCast(switch (op) {
.upper => icu_toUpper(cp),
.lower => icu_toLower(cp),
});
}
/// Core conversion function. Takes UTF-8 input, produces UTF-8 output.
pub fn convert(case_type: CaseType, input: []const u8, allocator: std.mem.Allocator) ![]u8 {
var result = std.array_list.Managed(u8).init(allocator);
errdefer result.deinit();
try result.ensureTotalCapacity(input.len + input.len / 4);
// Two-pass approach:
// Pass 1: Find word boundaries using codepoint iteration
// Pass 2: For each word, iterate codepoints and apply case transform
var boundary_iter = WordBoundaryIterator.init(input);
var word_index: usize = 0;
while (boundary_iter.next()) |word_range| {
// Separator between words
if (word_index > 0) {
if (case_type.separator()) |sep| {
try result.append(sep);
}
}
// Digit-prefix underscore for camelCase/pascalCase
if (word_index > 0 and case_type.hasDigitPrefixUnderscore()) {
if (word_range.start < input.len and input[word_range.start] < 0x80 and
std.ascii.isDigit(input[word_range.start]))
{
try result.append('_');
}
}
const transform = case_type.getTransform(word_index);
// Iterate codepoints within the word and apply transform
var cp_iter = strings.UnsignedCodepointIterator.init(input[word_range.start..word_range.end]);
var cursor = strings.UnsignedCodepointIterator.Cursor{};
var is_first = true;
while (cp_iter.next(&cursor)) {
const char_op: CaseOp = switch (transform) {
.lower => .lower,
.upper => .upper,
.capitalize => if (is_first) .upper else .lower,
};
is_first = false;
try appendCodepoint(&result, transformCp(cursor.c, char_op));
}
word_index += 1;
}
return result.toOwnedSlice();
}
/// Iterates over word boundaries in UTF-8 input, yielding byte ranges.
///
/// Implements the three-phase word splitting from `change-case`:
/// 1. Lower/digit → upper boundary ("camelCase" → "camel" + "Case")
/// 2. Upper → upper+lower boundary ("XMLParser" → "XML" + "Parser")
/// 3. Non-letter/non-digit separators
const WordBoundaryIterator = struct {
cp_iter: strings.UnsignedCodepointIterator,
cursor: strings.UnsignedCodepointIterator.Cursor = .{},
prev_class: CharClass = .other,
prev_prev_class: CharClass = .other,
prev_byte_pos: u32 = 0,
in_word: bool = false,
word_start: u32 = 0,
word_end: u32 = 0,
eof: bool = false,
fn init(input: []const u8) WordBoundaryIterator {
return .{ .cp_iter = strings.UnsignedCodepointIterator.init(input) };
}
fn next(self: *WordBoundaryIterator) ?WordRange {
while (!self.eof) {
if (!self.cp_iter.next(&self.cursor)) {
self.eof = true;
// Flush last word
if (self.in_word) {
self.in_word = false;
return WordRange{ .start = self.word_start, .end = self.word_end };
}
return null;
}
const cur_class = classifyCp(self.cursor.c);
const cur_pos = self.cursor.i;
const cur_end = self.cursor.i + self.cursor.width;
if (cur_class == .other) {
// Separator: end current word if any
if (self.in_word) {
self.in_word = false;
self.prev_class = .other;
self.prev_prev_class = .other;
return WordRange{ .start = self.word_start, .end = self.word_end };
}
self.prev_class = .other;
self.prev_prev_class = .other;
continue;
}
if (!self.in_word) {
// Start new word
self.in_word = true;
self.word_start = cur_pos;
self.word_end = cur_end;
self.prev_prev_class = .other;
self.prev_class = cur_class;
self.prev_byte_pos = cur_pos;
continue;
}
// Rule 2: upper+upper+lower → boundary before the last upper
if (self.prev_prev_class == .upper and self.prev_class == .upper and cur_class == .lower) {
const completed_word = WordRange{ .start = self.word_start, .end = self.prev_byte_pos };
self.word_start = self.prev_byte_pos;
self.word_end = cur_end;
self.prev_prev_class = self.prev_class;
self.prev_class = cur_class;
self.prev_byte_pos = cur_pos;
return completed_word;
}
// Rule 1: (lower | digit) → upper boundary
if ((self.prev_class == .lower or self.prev_class == .digit) and cur_class == .upper) {
const completed_word = WordRange{ .start = self.word_start, .end = self.word_end };
self.word_start = cur_pos;
self.word_end = cur_end;
self.prev_prev_class = .other;
self.prev_class = cur_class;
self.prev_byte_pos = cur_pos;
return completed_word;
}
// No boundary, extend current word
self.word_end = cur_end;
self.prev_prev_class = self.prev_class;
self.prev_class = cur_class;
self.prev_byte_pos = cur_pos;
}
return null;
}
};
/// JS callback wrapper. Extracts a string argument and performs case conversion.
fn caseChangeJS(case_type: CaseType, globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
const arg = callframe.argument(0);
if (!arg.isString()) {
return globalThis.throwInvalidArguments("Expected a string argument", .{});
}
const bunstr = try arg.toBunString(globalThis);
if (globalThis.hasException()) return .zero;
defer bunstr.deref();
if (bunstr.isEmpty()) {
return bun.String.empty.toJS(globalThis);
}
const utf8_slice = bunstr.toUTF8(bun.default_allocator);
defer utf8_slice.deinit();
const result_bytes = convert(case_type, utf8_slice.slice(), bun.default_allocator) catch {
return globalThis.throwOutOfMemory();
};
defer bun.default_allocator.free(result_bytes);
var str = bun.String.cloneUTF8(result_bytes);
return str.transferToJS(globalThis);
}
// --- Public JS callback functions ---
pub fn camelCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.camel, globalThis, callframe);
}
pub fn pascalCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.pascal, globalThis, callframe);
}
pub fn snakeCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.snake, globalThis, callframe);
}
pub fn kebabCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.kebab, globalThis, callframe);
}
pub fn constantCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.constant, globalThis, callframe);
}
pub fn dotCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.dot, globalThis, callframe);
}
pub fn capitalCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.capital, globalThis, callframe);
}
pub fn trainCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.train, globalThis, callframe);
}
pub fn pathCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.path, globalThis, callframe);
}
pub fn sentenceCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.sentence, globalThis, callframe);
}
pub fn noCase(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!JSValue {
return caseChangeJS(.no, globalThis, callframe);
}
// --- ICU extern functions ---
extern fn icu_toUpper(cp: u32) u32;
extern fn icu_toLower(cp: u32) u32;
extern fn icu_hasBinaryProperty(cp: u32, which: c_uint) bool;
const uchar_uppercase = 30; // UCHAR_UPPERCASE
const uchar_alphabetic = 0; // UCHAR_ALPHABETIC
const std = @import("std");
const bun = @import("bun");
const strings = bun.strings;
const jsc = bun.jsc;
const JSValue = jsc.JSValue;

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));
}
});
}
});
});