Case Converter — Zig source
Convert text between UPPER, lower, Title, Sentence, camelCase, PascalCase, snake_case, kebab-case, CONSTANT_CASE and slug. Live, one-click copy.
This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! case-converter — pure, deterministic text-case transformations.
//!
//! Language: Zig 0.13 (standard library only)
//! Source: CosmoDev polyglot showcase port of the Case Converter tool.
//! Canonical: src/lib/case.ts (the live TypeScript) plus this
//! tool's python.py / rust.rs reference ports.
//! License: display source — part of CosmoDev's polyglot tool pages.
//!
//! Split any input into word tokens and re-emit it in upper / lower / title /
//! sentence / camel / pascal / snake / kebab / constant / slug case — same
//! inputs produce the same outputs as the TypeScript original, including edge
//! cases.
//!
//! Zig's standard library has no Unicode case tables (and no regex engine),
//! so this port maps ASCII letters — the tool's token alphabet is
//! [a-zA-Z0-9] — and passes every other byte through verbatim; the word
//! splitter is a hand-rolled single forward pass, like the Rust port's state
//! machine. Single-byte scans are safe for UTF-8 payload: every ASCII test
//! below only ever matches single-byte characters.
//!
//! All allocating functions take an explicit allocator and return owned
//! slices the caller must free.
const std = @import("std");
const Allocator = std.mem.Allocator;
/// Identifier for one of the supported case transformations.
///
/// An enum plus `convert` is the idiomatic Zig equivalent of the TypeScript
/// `caseTransforms` object + `CaseId` union: exhaustive, statically
/// dispatched, and impossible to get out of sync at runtime. The enum tag
/// names are exactly the TS object's keys, so `@tagName` / `std.meta.stringToEnum`
/// give the string-keyed dispatch for free.
pub const CaseId = enum {
upper,
lower,
title,
sentence,
camel,
pascal,
snake,
kebab,
constant,
slug,
/// Lowercase string form, matching the TypeScript object keys.
pub fn label(self: CaseId) []const u8 {
return @tagName(self);
}
/// Parse a case id from its lowercase string form (null if unknown).
pub fn fromString(id: []const u8) ?CaseId {
return std.meta.stringToEnum(CaseId, id);
}
};
/// JavaScript's \w = [A-Za-z0-9_].
fn isWordChar(c: u8) bool {
return std.ascii.isAlphanumeric(c) or c == '_';
}
/// Close the current token, if any, pushing it into the output list.
fn flushWord(current: *std.ArrayList(u8), out: *std.ArrayList([]const u8)) Allocator.Error!void {
if (current.items.len == 0) return;
try out.append(try current.toOwnedSlice());
}
/// Tokenize `s` into word units.
///
/// Mirrors the TypeScript reference exactly, as one fused forward pass:
/// 1. Break at every camelCase boundary (a lowercase letter or digit
/// immediately followed by an uppercase letter).
/// 2. Break on runs of whitespace / underscore / hyphen / dot.
/// 3. Strip any remaining non-alphanumeric characters and drop empty tokens.
///
/// The caller owns the returned slice and every token in it — release with
/// `freeWords`.
pub fn words(allocator: Allocator, s: []const u8) Allocator.Error![][]const u8 {
var out = std.ArrayList([]const u8).init(allocator);
var current = std.ArrayList(u8).init(allocator);
defer current.deinit();
for (s, 0..) |c, i| {
// 1. camelCase boundary: the previous char was lowercase-or-digit and
// this one is uppercase — close the current token.
if (i > 0) {
const prev = s[i - 1];
if ((std.ascii.isLower(prev) or std.ascii.isDigit(prev)) and
std.ascii.isUpper(c))
{
try flushWord(¤t, &out);
}
}
if (std.ascii.isWhitespace(c) or c == '_' or c == '-' or c == '.') {
// 2. delimiter — end the current token.
try flushWord(¤t, &out);
} else if (std.ascii.isAlphanumeric(c)) {
// 3a. keep ASCII letters and digits.
try current.append(c);
}
// 3b. anything else (punctuation, symbols) is silently dropped,
// matching the reference's `[^a-zA-Z0-9]` stripping.
}
try flushWord(¤t, &out);
return out.toOwnedSlice();
}
/// Release a slice returned by `words`.
pub fn freeWords(allocator: Allocator, ws: []const []const u8) void {
for (ws) |w| allocator.free(w);
allocator.free(ws);
}
/// Map ASCII letters through `map`; copy every other byte unchanged.
fn mapAscii(allocator: Allocator, s: []const u8, comptime map: fn (u8) u8) Allocator.Error![]u8 {
const out = try allocator.dupe(u8, s);
for (out) |*c| c.* = map(c.*);
return out;
}
/// Upper-case every ASCII letter.
pub fn upper(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
return mapAscii(allocator, s, std.ascii.toUpper);
}
/// Lower-case every ASCII letter.
pub fn lower(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
return mapAscii(allocator, s, std.ascii.toLower);
}
/// Capitalize the first character and lowercase the rest.
pub fn cap(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
const out = try allocator.dupe(u8, s);
if (out.len > 0) {
out[0] = std.ascii.toUpper(out[0]);
for (out[1..]) |*c| c.* = std.ascii.toLower(c.*);
}
return out;
}
/// Title case: capitalize the first character of each maximal word-run.
///
/// A "word-run" starts at an ASCII word character ([A-Za-z0-9_]) and runs to
/// the next whitespace, mirroring JavaScript's /\w\S*/g. Characters between
/// runs (spacing, punctuation) are preserved verbatim — only each run's first
/// character is touched.
pub fn title(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
var i: usize = 0;
while (i < s.len) {
if (isWordChar(s[i])) {
// Consume the whole `\w\S*` run up to the next whitespace.
const start = i;
i += 1;
while (i < s.len and !std.ascii.isWhitespace(s[i])) i += 1;
const run = try cap(allocator, s[start..i]);
defer allocator.free(run);
try out.appendSlice(run);
} else {
// Not the start of a word-run: copy verbatim.
try out.append(s[i]);
i += 1;
}
}
return out.toOwnedSlice();
}
/// ASCII whitespace — the trim cutset for `sentence`, matching C's isspace().
const ascii_ws = " \t\n\r\x0b\x0c";
/// Sentence case: lowercase + trim the whole string, then uppercase the first
/// character. Empty (or all-whitespace) input yields empty output.
pub fn sentence(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
const lowered = try lower(allocator, s);
defer allocator.free(lowered);
const trimmed = std.mem.trim(u8, lowered, ascii_ws);
const out = try allocator.dupe(u8, trimmed);
if (out.len > 0) out[0] = std.ascii.toUpper(out[0]);
return out;
}
/// Join the tokens with `sep` into one owned string.
fn joinWords(allocator: Allocator, ws: []const []const u8, sep: u8) Allocator.Error![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
for (ws, 0..) |w, i| {
if (i > 0) try out.append(sep);
try out.appendSlice(w);
}
return out.toOwnedSlice();
}
/// camelCase: the first word stays lowercase, every later word is capitalized.
pub fn camel(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
const ws = try words(allocator, s);
defer freeWords(allocator, ws);
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
for (ws, 0..) |w, i| {
const piece = if (i == 0) try lower(allocator, w) else try cap(allocator, w);
defer allocator.free(piece);
try out.appendSlice(piece);
}
return out.toOwnedSlice();
}
/// PascalCase: every word capitalized and concatenated.
pub fn pascal(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
const ws = try words(allocator, s);
defer freeWords(allocator, ws);
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
for (ws) |w| {
const piece = try cap(allocator, w);
defer allocator.free(piece);
try out.appendSlice(piece);
}
return out.toOwnedSlice();
}
/// Join + re-case helpers: `snake` / `kebab` / `constant` / `slug` all join
/// the tokens first, then map the joined result — exactly like the
/// reference's "_".join(words).lower() chains.
fn joinedCase(
allocator: Allocator,
s: []const u8,
sep: u8,
comptime map: fn (u8) u8,
) Allocator.Error![]u8 {
const ws = try words(allocator, s);
defer freeWords(allocator, ws);
const joined = try joinWords(allocator, ws, sep);
defer allocator.free(joined);
return mapAscii(allocator, joined, map);
}
/// snake_case — "_".join(words).lower() in the reference.
pub fn snake(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
return joinedCase(allocator, s, '_', std.ascii.toLower);
}
/// kebab-case — "-".join(words).lower() in the reference.
pub fn kebab(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
return joinedCase(allocator, s, '-', std.ascii.toLower);
}
/// CONSTANT_CASE — "_".join(words).upper() in the reference.
pub fn constant(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
return joinedCase(allocator, s, '_', std.ascii.toUpper);
}
/// slug (kebab's URL-safe twin) — "-".join(words).lower() in the reference.
pub fn slug(allocator: Allocator, s: []const u8) Allocator.Error![]u8 {
return joinedCase(allocator, s, '-', std.ascii.toLower);
}
/// Apply the case transform identified by `id`.
///
/// String-keyed dispatch is available via `tryConvert`:
/// `CaseId.fromString(name).?(id) try convert(allocator, id, s)`.
pub fn convert(allocator: Allocator, id: CaseId, s: []const u8) Allocator.Error![]u8 {
return switch (id) {
.upper => upper(allocator, s),
.lower => lower(allocator, s),
.title => title(allocator, s),
.sentence => sentence(allocator, s),
.camel => camel(allocator, s),
.pascal => pascal(allocator, s),
.snake => snake(allocator, s),
.kebab => kebab(allocator, s),
.constant => constant(allocator, s),
.slug => slug(allocator, s),
};
}
/// Like `convert` but keyed by the string form; null when `name` is not a
/// known case id.
pub fn tryConvert(allocator: Allocator, name: []const u8, s: []const u8) Allocator.Error!?[]u8 {
const id = CaseId.fromString(name) orelse return null;
return convert(allocator, id, s);
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →