JoyC

JoyC transpiles to C99. It keeps what makes C great while addressing some of the boilerplate and cross-platform friction that can slow you down. The sections below walk through each feature.

#include "joyc/mem.jc"
#include "joyc/io.jc"

struct Point { i32 x; i32 y }
enum Direction { North, South, East, West }

void move(Point *p, Direction dir, i32 dist) {
    switch dir {
    case Direction.North: p.y += dist  // auto-deref
    case Direction.South: p.y -= dist
    case Direction.East:  p.x += dist
    case Direction.West:  p.x -= dist
    }
}

i32 sum(i32[] items) {
    i32 total = 0
    for usize i = 0; i < items.len; i++: total += items.ptr[i]
    return total
}

i32 main() {
    Point p = { .x = 0, .y = 0 }
    move(&p, Direction.North, 10)

    i32[5] nums = {1, 2, 3, 4, 5}
    io.println("sum=%d, pos=(%d,%d)", sum(nums[:]), p.x, p.y)

    char *buf = mem.alloc(256)
    defer mem.dealloc(buf)

	return 0
}

Built-in Types

In standard C, using fixed-width types like int32_t requires including <stdint.h>, and bool requires <stdbool.h>. JoyC has these types built in, so you can use them directly.

C TypeJoyC Type
int8_ti8
uint8_tu8
int16_ti16
uint16_tu16
int32_ti32
uint32_tu32
int64_ti64
uint64_tu64
C TypeJoyC Type
intptr_tisize
size_tusize
floatf32
doublef64
_Boolbool
uint32_trune
(void*)0null
void, charsame
i32 x = 42
f64 pi = 3.14159
bool done = false
rune codepoint = 0x1F600  // Unicode: U+1F600
char* name = null

Cleaner Syntax

JoyC makes a handful of syntax changes compared to C. None of them change the semantics of your code, but they tend to reduce the amount of punctuation you need to write.

Optional semicolons

JoyC inserts semicolons automatically using the same rules as Go. If a line ends with an operator, the insertion is suppressed so that multi-line expressions work as expected.

i32 x = 42
i32 y = x + 10
i32 total = first_value +
            second_value +
            third_value

Optional parentheses

Control flow statements like if, while, for, and switch do not require parentheses around their conditions. You can still use them if you prefer, but they are optional.

if x > 0 {
    printf("positive\n")
}

while count > 0 {
    count--
}

for i32 i = 0; i < 10; i++ {
    printf("%d\n", i)
}

Colon shorthand

When a block body is just a single statement, you can use a colon instead of braces. This is especially useful for short conditionals and loops.

if x > 0: return x
while n > 1: n /= 2
for i32 i = 0; i < 10; i++: printf("%d ", i)

and / or keywords

The keywords and and or are available as alternatives to && and ||. Both forms work interchangeably, so you can mix them depending on what reads best.

if x > 0 and x < 100 {
    printf("in range\n")
}
if done or cancelled: return

No forward declarations

You can call functions before they are defined in the file. The compiler resolves declaration order automatically, so prototypes and forward declarations are not needed.

i32 main() {
    greet("world")  // works, no prototype needed
}

void greet(const char *name) {
    printf("Hello, %s!\n", name)
}

Structs & Enums

In C, using a struct without the struct keyword at every usage site requires a typedef, and definitions need trailing semicolons. JoyC removes both of those requirements.

Structs

struct Point {
    i32 x
    i32 y
}

Point p = { .x = 10, .y = 20 }  // no "struct Point"
Point zero = {}                   // all fields zero-initialized

Scoped enums

In C, all enum values share the same namespace, so two enums can’t both have a member named Red. JoyC scopes enum values to their type, which avoids these collisions.

enum Color { Red, Green, Blue }
enum Light { Red, Yellow, Green }  // no conflict

Color c = Color.Red
Light l = Light.Green

Automatic dereferencing

In C, accessing a struct field through a pointer requires the -> operator. JoyC lets you use . for both values and pointers, and the compiler emits the correct operator in the generated C.

void move(Point *p, i32 dx, i32 dy) {
    p.x += dx  // automatically becomes p->x in C
    p.y += dy
}

Zero initialization

Empty braces {} zero-initialize any struct or array. If you provide only some fields in a designated initializer, the rest are guaranteed to be zero.

Point p = {}              // x=0, y=0
i32[10] arr = {}          // all elements zero
Point p = { .x = 10 }    // y is guaranteed zero

Arrays & Slices

JoyC moves array dimensions from the variable name into the type, so the type reads left to right. It also introduces slices, which are fat pointers that pair a pointer with a length.

Arrays

In C, array dimensions are attached to the variable name (int numbers[10]), which can get confusing in complex declarations. JoyC puts the dimensions in the type instead, so the type and variable name are always clearly separated.

C
int numbers[10];
JoyC
i32[10] numbers

Slices

A slice T[] is a fat pointer that pairs a data pointer with a length. Under the hood it compiles to a simple anonymous struct ({T* ptr, usize len}) with no runtime overhead.

i32[5] arr = {1, 2, 3, 4, 5}
i32[] s = arr[:]          // slice of whole array
i32[] sub = s[1:3]        // sub-slice: elements 1, 2
i32[] rest = s[2:]        // from index 2 to end
i32[] first = s[:3]       // first 3 elements

usize len = s.len         // length
i32* ptr = s.ptr          // raw pointer access

Slices as function parameters

Slices are commonly used as function parameters because they let you accept arrays of any size without needing a separate length argument. The length travels with the pointer.

i32 sum(i32[] items) {
    i32 total = 0
    for usize i = 0; i < items.len; i++ {
        total += items.ptr[i]
    }
    return total
}

i32[5] data = {10, 20, 30, 40, 50}
i32 result = sum(data[:])  // pass slice of array

Defer

The defer statement lets you schedule cleanup code that runs when the current scope exits. The compiler inlines the deferred expression at every exit point in the scope, so there is no runtime overhead.

void process() {
    FILE *f = fopen("data.txt", "r")
    defer fclose(f)

    char *buf = malloc(1024)
    defer free(buf)

    if error: return  // both fclose and free run here
    // ... and here at end of scope
}

LIFO order

When multiple defer statements appear in the same scope, they execute in reverse order (last in, first out). This matches the natural pattern of nested resource acquisition.

defer printf("1st ")
defer printf("2nd ")
defer printf("3rd ")
// prints: 3rd 2nd 1st

Defer is not a runtime feature. The compiler copies the deferred expression to every exit point in the scope (return statements, end of function). The generated C has no overhead.

Functions

JoyC adds a few conveniences to function declarations and calls that C doesn’t have, including default parameter values, named arguments at call sites, and aliasing for external C symbols.

Default parameter values

You can give function parameters default values. When a caller omits those arguments, the compiler fills in the defaults at the call site.

void draw(i32 x, i32 y, f32 scale = 1.0, bool visible = true)

draw(10, 20)           // scale=1.0, visible=true
draw(10, 20, 2.0)      // scale=2.0, visible=true

Named arguments

Function calls can use named arguments to make the intent clearer at the call site. Named arguments can also be passed in any order, and the compiler will reorder them to match the function signature.

create_window(width: 800, height: 600, title: "My App")

i32 add(i32 a, i32 b) { return a + b }
add(b: 10, a: 5)  // reordered to add(5, 10) in generated C

Extern aliasing

The extern declaration can include a string literal that specifies the actual C symbol name. This lets you give external functions more readable names in your JoyC code while still linking to the original symbols.

extern "malloc" void *alloc(usize size)
extern "free" void dealloc(void *ptr)
extern "strlen" usize str_len(const char *s)

void *p = alloc(1024)  // calls malloc(1024)
dealloc(p)             // calls free(p)

Control Flow

JoyC makes two changes to control flow compared to C. Switch cases no longer fall through by default, and loops can be labeled so you can break or continue an outer loop from within a nested one.

Switch without break

In C, forgetting a break at the end of a switch case silently falls through to the next one. JoyC flips this so that cases do not fall through by default. When you do want fallthrough, you say so explicitly with the fallthrough keyword.

switch day {
case 1:
    printf("Monday\n")
case 6:
    printf("Saturday — also a ")
    fallthrough
case 7:
    printf("weekend day\n")
default:
    printf("some day\n")
}

Named loop labels

Loops can be given a label by prefixing them with :name. You can then use that label with break or continue to target an outer loop from within a nested one.

:outer for i32 i = 0; i < 10; i++ {
    for i32 j = 0; j < 10; j++ {
        if i + j == 15: break :outer
        if j == 5: continue :outer
    }
}

Namespaces

Since C has a flat global symbol namespace, libraries typically prefix their functions manually (e.g., SDL_CreateWindow). JoyC’s #namespace directive handles this for you by automatically prefixing all symbols in a file.

// math.jc
#namespace math

const f64 PI = 3.14159
f64 square(f64 x) { return x * x }

Other files can use these symbols with the namespace qualifier, in this case, math.PI and math.square(). In the generated C code, these will be prefixed with math_ to avoid naming conflicts (e.g., math_PI and math_square).

Custom C prefix

By default, the namespace name is used as the C prefix (e.g., math produces math_square). You can override this with an explicit string literal if the generated prefix needs to match an existing convention.

#namespace os "OS_"   // os.args() → OS_args() in C

Multi-file includes

You can include other .jc files directly, and they will be parsed and inlined by the compiler. Standard C headers are passed through unchanged into the generated C output.

#include "mathlib.jc"  // JoyC file: parsed and inlined
#include <stdio.h>     // C header: passed through

Preprocessor & Build Config

In an effort to avoid external build systems like Makefiles, CMake, Bazel, and so on, JoyC uses in-source directives to handle linking, build configuration, code generation, and conditional compilation.

DirectivePurposeC Equivalent
#link "m"Link a shared library-lm
#link:static "z"Link a static library-static -lz
#link:framework "Cocoa"Link a macOS framework-framework Cocoa
#link:system "pthread"Link a system library-lpthread
#link:flag "-Wl,-rpath,/opt"Raw linker flagpassed through

#config: Build configuration

DirectivePurposeC Equivalent
#config:libpath "/usr/local/lib"Library search path-L/usr/local/lib
#config:include "/usr/local/include"Header search path-I/usr/local/include
#config:pkg "openssl"Use pkg-config$(pkg-config ...)
#config:cflags "-Wall -O2"C compiler flags-Wall -O2

#macro: Code generation

The #macro directive defines multi-line macros that support token pasting with #(param). This is useful for generating repetitive struct or function definitions from a template.

#macro PAIR(T, Name) {
    struct #(Name) { T first; T second; }
    Name #(Name)_make(T a, T b) {
        Name p; p.first = a; p.second = b; return p
    }
}

PAIR(int, IntPair)
IntPair p = IntPair_make(1, 2)

Conditional compilation

Rather than checking compiler-specific macros like _WIN32, __APPLE__, or __linux__ (which vary between compilers and can be easy to get wrong), JoyC provides a consistent set of built-in defines.

CategoryDefines
PlatformIS_MACOS, IS_LINUX, IS_WINDOWS, IS_FREEBSD, IS_UNIX
ArchitectureIS_X86_64, IS_ARM64, IS_WASM
Pointer sizeIS_64BIT, IS_32BIT
Build modeIS_DEBUG, IS_RELEASE, IS_SANITIZE, IS_SMALL
#if IS_MACOS {
    printf("Running on macOS\n")
}
#if IS_LINUX {
    printf("Running on Linux\n")
}

#embed: File embedding

#embed includes the contents of a file as a string literal at compile time. This is useful for embedding shaders, SQL queries, or other resources directly into your binary.

const char* vertex_shader = #embed "shaders/vertex.glsl"
const char* query = #embed "sql/init.sql"

Compile-time assertions

#assert checks a condition at compile time and produces an error if it fails.

#assert sizeof(i32) == 4
#assert sizeof(void*) == 8

Code annotations

#todo marks unfinished code. In debug builds it produces a warning; in release builds it becomes a compile error, so you don’t accidentally ship incomplete work.

#unreachable tells the compiler that a code path should never be reached. In debug builds it triggers a trap; in release builds it becomes an optimizer hint.

#deprecated causes a warning whenever a marked symbol is used, which is helpful when migrating callers away from old APIs.

#deprecated "use new_api() instead"
void old_api() { }

void process() {
    #todo "handle the error case"
    // ...
    #unreachable  // control should never reach here
}

Struct layout

#packed removes padding between struct fields, and offsetof(T, field) returns the byte offset of a field within a struct.

#packed
struct Header {
    u8 type
    u32 length
}
// sizeof(Header) == 5, not 8

usize off = offsetof(Header, length)  // 1

Symbol visibility and storage

#export marks a function or variable for export when building a shared library. The compiler emits the correct platform-specific attribute (__declspec(dllexport) on Windows, __attribute__((visibility("default"))) on Unix) so you don’t need to manage that yourself.

threadlocal puts a variable in thread-local storage so each thread gets its own copy. Different C compilers spell this differently (_Thread_local in C11, __thread on GCC/Clang, __declspec(thread) on MSVC), but JoyC handles the discrepancy for you.

#export
void public_api() { }

threadlocal i32 per_thread_counter = 0

Type introspection

typeof(expr) evaluates to the type of any expression, which is useful in macros and generic-style code.

i32 x = 42
typeof(x) y = x + 1  // y is i32

Testing & Benchmarks

Rather than requiring a separate test framework or test files, JoyC lets you write tests and benchmarks alongside the code they exercise using #test and #benchmark blocks.

#include "joyc/assert.jc"

i32 add(i32 a, i32 b) { return a + b }

#test "addition works" {
    assert(add(2, 3) == 5)
    assert(add(-1, 1) == 0)
}

#test "handles overflow" {
    assert(add(2147483647, 0) == 2147483647)
}

#benchmark "add performance" {
    volatile i32 x = add(1, 2)
}
joyc test myfile.jc    # run all #test blocks
joyc bench myfile.jc   # run all #benchmark blocks

Compiler diagnostics

The compiler produces Elm-style diagnostics with source locations and explanations. It currently checks for the following categories of issues.

CLI Reference

The joyc command line tool is the single entry point for compiling, testing, formatting, translating, and inspecting your code.

Commands

CommandDescription
joyc build <file.jc>Compile to binary
joyc run <file.jc>Compile and run
joyc test <file.jc>Run #test blocks
joyc bench <file.jc>Run #benchmark blocks
joyc fmt [-w] <file.jc>Format source (-w for in-place)
joyc emit-c <file.jc>Emit generated C99 source
joyc translate <file.c>Convert C source to JoyC
joyc tokens <file.jc>Dump lexer token stream
joyc ast <file.jc>Dump the AST
joyc vet <file.jc>Check for style issues
joyc doc <file.jc>Generate documentation
joyc fetchDownload dependencies from joyc.mod
joyc watch [--run] <file.jc>Rebuild on file changes
joyc initCreate a new project
joyc cleanRemove .joyc/ build directory
joyc versionShow version

Build flags

joyc build -o myapp main.jc             # set output path
joyc build --debug main.jc              # debug profile
joyc build --release main.jc            # release profile
joyc build --profile sanitize main.jc   # ASan + UBSan
joyc build --profile small main.jc      # optimize for size
joyc build --cc clang main.jc           # choose C compiler
joyc build --target linux-x86_64 main.jc  # cross-compile

Build profiles

ProfileOptimizationDebug InfoBounds ChecksSanitizersDefine
debug-O0YesYesNoIS_DEBUG
release-O2NoNoNoIS_RELEASE
sanitize-O1YesYesASan + UBSanIS_SANITIZE
small-OsNoNoNoIS_SMALL

Cross-compilation

joyc build --target linux-x86_64 main.jc
joyc build --target macos-arm64 main.jc
joyc build --target windows-x86_64 --cc x86_64-w64-mingw32-gcc main.jc

The supported target platforms are linux, macos (or darwin), windows (or win32), freebsd, and wasm. The supported architectures are x86_64 (or amd64), arm64 (or aarch64), and wasm32.

C-to-JoyC translation

You can convert existing C code to JoyC using the translate command.

joyc translate input.c -o output.jc
joyc translate header.h source.c -o combined.jc
joyc translate --strip-prefix SDL_ --namespace sdl sdl.h -o sdl.jc
joyc translate --rules sdl.rules sdl.h -o sdl.jc

The translator handles common conversions automatically: int32_t becomes i32, semicolons are removed, -> becomes ., NULL becomes null, struct prefixes are dropped, header guards are stripped, and redundant typedefs are cleaned up. For more complex renaming like per-kind case transforms, explicit renames, or ignore patterns, you can provide a --rules file.

Editor support

The vscode/ directory contains a VS Code extension that provides syntax highlighting through a TextMate grammar. It also includes a full language server with support for hover information, completions, go-to-definition, find references, rename, formatting, semantic tokens, document symbols, and signature help. Code snippets are included as well.

Putting It Together

To see how these features come together in practice, here is a small cross-platform HTTP server. It pulls in the standard library socket module, links against platform-specific libraries with conditional compilation, serves an embedded HTML file, and includes inline tests for the routing logic.

// server.jc

#include "joyc/assert.jc"
#include "joyc/io.jc"
#include "joyc/mem.jc"
#include "joyc/strings.jc"
#include "joyc/socket.jc"
#include <string.h>

#link "m"

#if IS_LINUX {
    #link "pthread"
}
#if IS_MACOS {
    #link:framework "CoreFoundation"
}
#if IS_WINDOWS {
    #link "ws2_32"
}

#namespace server

const char* INDEX_PAGE = #embed "static/index.html"

enum Status { Ok = 200, NotFound = 404, Error = 500 }

struct Response {
    Status status
    const char* body
    usize body_len
}

struct Server {
    i32 sock
    u16 port
    bool running
}

Response route(const char* path) {
    if strings.equals(path, "/") or strings.equals(path, "/index.html") {
        return (Response){
            .status = Status.Ok,
            .body = INDEX_PAGE,
            .body_len = strings.len(INDEX_PAGE)
        }
    }
    return (Response){
        .status = Status.NotFound,
        .body = "not found",
        .body_len = 9
    }
}

void handle(i32 conn) {
    defer socket.close(conn)

    char[4096] buf = {}
    isize n = socket.receive(conn, buf[:])
    if n <= 0: return

    Response res = route("/")
    char[512] out = {}
    i32 len = io.sprint(out, 512,
        "HTTP/1.1 %d OK\r\nContent-Length: %zu\r\n\r\n%s",
        (i32)res.status, res.body_len, res.body)

    socket.send(conn, out[:len])
}

bool start(Server *s, u16 port = 8080) {
    s.sock = socket.tcp()                          // auto-deref
    if s.sock < 0: return false
    if socket.bind(s.sock, port) < 0: return false
    if socket.listen(s.sock, backlog: 128) < 0: return false
    s.port = port
    s.running = true
    return true
}

void stop(Server *s) {
    s.running = false
    socket.close(s.sock)
}

i32 main() {
    Server srv = {}
    if !start(&srv, port: 3000) {
        io.eprintln("failed to start server")
        return 1
    }
    defer stop(&srv)
    io.println("listening on :%d", srv.port)

    while srv.running {
        i32 conn = socket.accept(srv.sock)
        if conn >= 0: handle(conn)
    }
}

#test "root returns 200" {
    Response res = route("/")
    assert(res.status == Status.Ok and res.body_len > 0)
}

#test "unknown path returns 404" {
    Response res = route("/nope")
    assert(res.status == Status.NotFound)
}

Dependency Management

Instead of relying on external package managers or build system scripts, JoyC handles dependency management through a joyc.mod file in your project root.

joyc.mod

package myapp
version 0.1.0

require (
    stb          git https://github.com/nothings/stb          v1.0
    cjson        git https://github.com/DaveGamble/cJSON      v1.7.18
    sokol        git https://github.com/nickglenn/sokol-joyc       branch main
)

system (
    openssl      pkg-config openssl  >= 1.1
    sdl2         pkg-config sdl2
)

Fetching dependencies

joyc fetch            # download deps, uses lock file if present
joyc fetch --update   # update to latest matching versions

Dependencies are cloned into .joyc/vendor/ and pinned in joyc.lock for reproducible builds. Once fetched, you can include them directly in your source files.

#include "stb/stb_image.jc"
#include "cjson/cJSON.h"

System dependencies are verified via pkg-config.

Transitive dependencies

If a dependency has its own joyc.mod, its requirements are resolved automatically. JoyC uses flat dependency resolution, meaning all packages must agree on a single version per dependency name.

Build profiles in joyc.mod

profile release (
    cc clang
    flags -flto -march=native
)

profile wasm (
    cc emcc
    flags -sEXPORTED_FUNCTIONS=['_main']
)

These merge with built-in profile flags when --profile is used.

Standard Library

JoyC ships a small standard library that is embedded directly in the compiler binary. There is no runtime and no garbage collector. Each module is opt-in through #include, and every function compiles to straightforward C.

Usage

#include "joyc/io.jc"
#include "joyc/math.jc"
#include "joyc/mem.jc"

int main() {
    f64 val = math.clamp(42.0, 0.0, 100.0)
    void *buf = mem.alloc(1024)
    defer mem.dealloc(buf)
    io.println("clamped: %g", val)
    return 0
}

Modules

ModuleIncludePurpose
iojoyc/io.jcprint, println, eprint, eprintln, sprint, scan
mathjoyc/math.jcclamp, lerp, min, max, abs (f32/f64/i32), PI, E, TAU
memjoyc/mem.jcalloc, zalloc, dealloc, resize, copy, zero, set
stringsjoyc/strings.jcstarts_with, ends_with, contains, equals, len, index_of, find
hashjoyc/hash.jcfnv1a (32/64-bit), crc32
endianjoyc/endian.jcbswap16/32/64, htobe16/32, be16toh, be32toh
assertjoyc/assert.jcRuntime assert with error messages
fsjoyc/fs.jcCross-platform file I/O: open, close, read, read_line, read_all, write, write_string, writef, write_all, seek, tell, eof, flush
testjoyc/test.jcassert_eq_i32, assert_eq_i64, assert_str_eq, assert_near_f64, assert_not_null
socketjoyc/socket.jctcp, udp, close, bind, listen, accept, connect, send, receive, send_to, receive_from, INVALID

Every function is namespaced: math.clamp, mem.alloc, strings.contains, socket.tcp, etc.