Skip to content

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(&current, &out);
            }
        }

        if (std.ascii.isWhitespace(c) or c == '_' or c == '-' or c == '.') {
            // 2. delimiter — end the current token.
            try flushWord(&current, &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(&current, &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 →