c-next
v0.1.11
Published
A safer C for embedded systems development. Transpiles to clean, readable C.
Maintainers
Readme
C-Next
A safer C for embedded systems development. Transpiles to clean, readable C.
Status: Working Transpiler — Verified on Teensy MicroMod hardware.
Quick Example
// Register binding with type-safe access
register GPIO7 @ 0x42004000 {
DR: u32 rw @ 0x00,
DR_SET: u32 wo @ 0x84,
DR_TOGGLE: u32 wo @ 0x8C,
}
u32 LED_BIT <- 3;
scope LED {
void toggle() {
// Type-aware bit indexing on write-only register
GPIO7.DR_TOGGLE[LED_BIT] <- true;
}
}Generates clean C:
#define GPIO7_DR_TOGGLE (*(volatile uint32_t*)(0x42004000 + 0x8C))
uint32_t LED_BIT = 3;
void LED_toggle(void) {
GPIO7_DR_TOGGLE = (1 << LED_BIT);
}Installation
From npm (Recommended)
npm install -g c-nextVerify the installation:
cnext --versionFrom Source (Development)
git clone https://github.com/jlaustill/c-next.git
cd c-next
npm install
npm linkUsage
# Transpile to C (output alongside input file)
cnext examples/blink.cnx
# Explicit output path
cnext examples/blink.cnx -o blink.c
# Parse only (syntax check)
cnext examples/blink.cnx --parse
# Output as C++ (.cpp)
cnext examples/blink.cnx --cpp
# Target platform for atomic code generation (ADR-049)
cnext examples/blink.cnx --target teensy41
# Show all options
cnext --helpGetting Started with PlatformIO
C-Next integrates seamlessly with PlatformIO embedded projects. The transpiler automatically converts .cnx files to .c before each build.
Quick Setup
From your PlatformIO project root:
cnext --pio-installThis command:
- Creates
cnext_build.py(pre-build transpilation script) - Modifies
platformio.inito addextra_scripts = pre:cnext_build.py
Usage
- Create
.cnxfiles in yoursrc/directory (alongside existing.c/.cppfiles)
src/
├── main.cpp # Existing C++ code
├── ConfigStorage.cnx # New c-next code
└── SensorProcessor.cnx # New c-next code- Build as usual — transpilation happens automatically:
pio runOutput:
Transpiling 2 c-next files...
✓ ConfigStorage.cnx
✓ SensorProcessor.cnx
Building...- Commit both
.cnxand generated.cfiles to version control
Why Commit Generated Files?
Generated .c files are reviewable artifacts in pull requests:
+ // ConfigStorage.cnx
+ u8 validate_config() {
+ counter +<- 1;
+ }
+ // ConfigStorage.c (generated)
+ uint8_t validate_config(void) {
+ counter = cnx_clamp_add_u8(counter, 1);
+ }Benefits:
- See exactly what C code the transpiler generates
- Review safety features (overflow protection, atomic operations)
- Verify transpiler behavior
- Build succeeds even if transpiler isn't available
This follows the same pattern as TypeScript committing .js files or Bison committing generated parsers.
Example Project Structure
my-teensy-project/
├── platformio.ini # PlatformIO config
├── cnext_build.py # Auto-generated transpilation script
├── src/
│ ├── main.cpp # C++ entry point
│ ├── ConfigStorage.cnx # c-next source
│ ├── ConfigStorage.c # Generated (committed)
│ ├── SensorProcessor.cnx # c-next source
│ └── SensorProcessor.c # Generated (committed)
└── include/
└── AppConfig.h # Shared typesUninstall
To remove c-next integration:
cnext --pio-uninstallThis removes:
cnext_build.pyscriptextra_scriptsreference fromplatformio.ini
Your .cnx files and generated .c files remain untouched.
Manual Integration
If you prefer manual control, you can also run the transpiler explicitly:
# Transpile all .cnx files in a directory (recursive)
cnext src/
# Or transpile specific files
cnext src/ConfigStorage.cnx
cnext src/SensorProcessor.cnxPhilosophy
C-Next follows the TypeScript model for adoption:
- Not all-or-nothing — Drop a single
.cnxfile into an existing C project - Clean escape hatch — Generated C is idiomatic and maintainable
- Helpful, not burdensome — If you know C, you can read C-Next immediately
Core Principles
KISS (Keep It Simple, Stupid) Every feature must pass the simplicity test: "Can a senior C developer read this cold and understand it in 30 seconds?" If not, it's too clever.
DRY (Don't Repeat Yourself) Configuration belongs in one place. No magic numbers scattered through code. Named constants and register bindings enforce single sources of truth.
Pragmatic, Not Dogmatic C-Next makes the right thing easy and the wrong thing hard, but doesn't prevent escape hatches. Generated C is always readable and maintainable.
C Preprocessor Compatibility
C-Next uses the standard C preprocessor — no custom module system. This means:
#includedirectives pass through to generated C- Include C-Next files:
#include "utils.cnx"→#include "utils.h"in generated C - Works with both
<file.cnx>and"file.cnx"syntax - MISRA preprocessor guidelines apply
- Full compatibility with existing toolchains (PlatformIO, arm-gcc, etc.)
- Conditional compilation (
#ifdef) works as expected
Generated headers automatically include guards:
#ifndef MYFILE_H
#define MYFILE_H
// ...
#endif /* MYFILE_H */The Simplicity Constraint
| Rust's Path | C-Next's Path |
| ---------------------------- | --------------------------------------- |
| Add concepts to catch errors | Remove the ability to make errors |
| Borrow checker complexity | Startup allocation = predictable memory |
| Lifetime annotations | Fixed runtime layout = clear lifetimes |
| unsafe escape hatch | Clean C is the escape hatch |
Guiding Principle: If Linus Torvalds wouldn't approve of the complexity, it doesn't ship. Safety through removal, not addition.
Core Features
Assignment: <- vs Equality: =
Eliminates the if (x = 5) bug by design:
x <- 5; // assignment: value flows INTO x
if (x = 5) // comparison: single equals, just like mathFixed-Width Types
u8, u16, u32, u64 // unsigned integers
i8, i16, i32, i64 // signed integers
f32, f64 // floating point
bool // booleanRegister Bindings
Type-safe hardware access with access modifiers:
register GPIO7 @ 0x42004000 {
DR: u32 rw @ 0x00, // Read-Write
PSR: u32 ro @ 0x08, // Read-Only
DR_SET: u32 wo @ 0x84, // Write-Only (atomic set)
DR_CLEAR: u32 wo @ 0x88, // Write-Only (atomic clear)
DR_TOGGLE: u32 wo @ 0x8C, // Write-Only (atomic toggle)
}Type-Aware Bit Indexing
Integers are indexable as bit arrays:
u8 flags <- 0;
flags[3] <- true; // Set bit 3
flags[0, 3] <- 5; // Set 3 bits starting at bit 0
bool isSet <- flags[3]; // Read bit 3
// .length property
u8 buffer[16];
buffer.length; // 16 (array element count)
flags.length; // 8 (bit width of u8)Write-only registers generate optimized code:
GPIO7.DR_SET[LED_BIT] <- true; // Generates: GPIO7_DR_SET = (1 << LED_BIT);Slice Assignment for Memory Operations
Multi-byte copying with compile-time validated memcpy generation (Issue #234):
u8 buffer[256];
u32 magic <- 0x12345678;
// Copy 4 bytes from value into buffer at offset 0
buffer[0, 4] <- magic;
// Named offsets using const variables
const u32 HEADER_OFFSET <- 0;
const u32 DATA_OFFSET <- 8;
buffer[HEADER_OFFSET, 4] <- magic;
buffer[DATA_OFFSET, 8] <- timestamp;Transpiles to direct memcpy (bounds validated at compile time):
uint8_t buffer[256] = {0};
uint32_t magic = 0x12345678;
memcpy(&buffer[0], &magic, 4);
memcpy(&buffer[8], ×tamp, 8);Key Features:
- Compile-time bounds checking prevents buffer overflows at compile time
- Offset and length must be compile-time constants (literals or
constvariables) - Silent runtime failures are now compile-time errors
- Works with struct fields:
buffer[0, 4] <- config.magic - Distinct from bit operations: array slices use
memcpy, scalar bit ranges use bit manipulation
Scopes (ADR-016)
Organize code with automatic name prefixing. Inside scopes, explicit qualification is required:
this.Xfor scope-local membersglobal.Xfor global variables, functions, and registers
const u8 LED_BIT <- 3;
scope LED {
u8 brightness <- 100;
void on() { global.GPIO7.DR_SET[global.LED_BIT] <- true; }
void off() { global.GPIO7.DR_CLEAR[global.LED_BIT] <- true; }
u8 getBrightness() { return this.brightness; }
}
// Call as:
LED.on();
LED.off();Transpiles to:
const uint8_t LED_BIT = 3;
static uint8_t LED_brightness = 100;
void LED_on(void) { GPIO7_DR_SET = (1 << LED_BIT); }
void LED_off(void) { GPIO7_DR_CLEAR = (1 << LED_BIT); }
uint8_t LED_getBrightness(void) { return LED_brightness; }Switch Statements (ADR-025)
Safe switch with MISRA compliance:
- Braces replace break (no colons needed)
- No fallthrough allowed
- Multiple cases with
||syntax - Counted
default(n)for enum exhaustiveness
enum EState { IDLE, RUNNING, STOPPED }
void handleState(EState state) {
switch (state) {
case EState.IDLE {
startMotor();
}
case EState.RUNNING || EState.STOPPED {
checkSensors();
}
}
}Transpiles to:
switch (state) {
case EState_IDLE: {
startMotor();
break;
}
case EState_RUNNING:
case EState_STOPPED: {
checkSensors();
break;
}
}Ternary Operator (ADR-022)
Safe conditional expressions with MISRA compliance:
- Parentheses required around condition
- Condition must be boolean (comparison or logical)
- No nesting allowed (use if/else instead)
u32 max <- (a > b) ? a : b;
u32 abs <- (x < 0) ? -x : x;
u32 result <- (a > 0 && b > 0) ? a : b;
// ERROR: Condition must be boolean
// u32 bad <- (x) ? 1 : 0;
// ERROR: Nested ternary not allowed
// i32 sign <- (x > 0) ? 1 : (x < 0) ? -1 : 0;Bounded Strings (ADR-045)
Safe, statically-allocated strings with compile-time capacity checking:
string<64> name <- "Hello"; // 64-char capacity, transpiles to char[65]
string<128> message; // Empty string, initialized to ""
const string VERSION <- "1.0.0"; // Auto-sized to string<5>
// Properties
u32 len <- name.length; // Runtime: strlen(name)
u32 cap <- name.capacity; // Compile-time: 64
// Comparison - uses strcmp
if (name = "Hello") { } // strcmp(name, "Hello") == 0
// Concatenation with capacity validation
string<32> first <- "Hello";
string<32> second <- " World";
string<64> result <- first + second; // OK: 64 >= 32 + 32
// Substring extraction with bounds checking
string<5> greeting <- name[0, 5]; // First 5 charsAll operations are validated at compile time:
- Literal overflow → compile error
- Truncation on assignment → compile error
- Concatenation capacity mismatch → compile error
- Substring out of bounds → compile error
Callbacks (ADR-029)
Type-safe function pointers with the Function-as-Type pattern:
- A function definition creates both a callable function AND a type
- Nominal typing: type identity is the function name, not just signature
- Never null: callbacks are always initialized to their default function
// Define callback type with default behavior
void onReceive(const CAN_Message_T msg) {
// default: no-op
}
struct Controller {
onReceive _handler; // Type is onReceive, initialized to default
}
// User implementation must match signature
void myHandler(const CAN_Message_T msg) {
Serial.println(msg.id);
}
controller._handler <- myHandler; // OK: signature matches
controller._handler(msg); // Always safe - never nullTranspiles to:
void onReceive(const CAN_Message_T msg) { }
typedef void (*onReceive_fp)(const CAN_Message_T);
struct Controller {
onReceive_fp _handler;
};
// Initialization always sets to default
struct Controller Controller_init(void) {
return (struct Controller){ ._handler = onReceive };
}Atomic Variables (ADR-049)
ISR-safe variables with hardware-assisted atomicity:
#pragma target teensy41
atomic u32 counter <- 0; // ISR-safe with LDREX/STREX
atomic clamp u8 brightness <- 100; // Combines atomic + clamp
void increment() {
counter +<- 1; // Lock-free atomic increment
}Generates optimized code based on target platform:
- Cortex-M3/M4/M7: LDREX/STREX retry loops (lock-free)
- Cortex-M0/M0+: PRIMASK disable/restore (interrupt masking)
Target detection priority: --target CLI flag > platformio.ini > #pragma target > default
Volatile Variables (ADR-108)
Prevent compiler optimization for variables that change outside normal program flow:
// Delay loop - prevent optimization
void delay_ms(const u32 ms) {
volatile u32 i <- 0;
volatile u32 count <- ms * 2000;
while (i < count) {
i +<- 1; // Compiler cannot optimize away
}
}
// Hardware register - reads actual memory
volatile u32 status_register @ 0x40020000;
void waitReady() {
while (status_register & 0x01 = 0) {
// Always reads from hardware
}
}When to use:
- ✅ Delay loops that must not be optimized away
- ✅ Memory-mapped hardware registers
- ✅ Variables polled in tight loops
- ❌ ISR-shared variables (use
atomicinstead for RMW safety)
Key difference from atomic:
volatile= prevents optimization onlyatomic= prevents optimization + adds synchronization (ISR-safe)
Critical Sections (ADR-050)
Multi-statement atomic blocks with automatic interrupt masking:
u8 buffer[64];
u32 writeIdx <- 0;
void enqueue(u8 data) {
critical {
buffer[writeIdx] <- data;
writeIdx +<- 1;
}
}Transpiles to PRIMASK save/restore:
void enqueue(uint8_t data) {
{
uint32_t __primask = __get_PRIMASK();
__disable_irq();
buffer[writeIdx] = data;
writeIdx += 1;
__set_PRIMASK(__primask);
}
}Safety: return inside critical { } is a compile error (E0853).
NULL for C Library Interop (ADR-047)
Safe interop with C stream functions that can return NULL:
#include <stdio.h>
string<64> buffer;
void readInput() {
// NULL check is REQUIRED - compiler enforces it
if (fgets(buffer, buffer.size, stdin) != NULL) {
printf("Got: %s", buffer);
}
}Constraints:
- NULL only valid in comparison context (
!= NULLor= NULL) - Only whitelisted stream functions:
fgets,fputs,fgetc,fputc - Cannot store C pointer returns in variables
fopen,malloc, etc. are errors (see ADR-103 for future FILE* support)
Startup Allocation
Allocate at startup, run with fixed memory. Per MISRA C:2023 Dir 4.12: all memory is allocated during initialization, then forbidden. No runtime allocation means no fragmentation, no OOM, no leaks.
Hardware Testing
Verified on Teensy MicroMod (NXP i.MX RT1062):
# Build and flash with PlatformIO
cd test-teensy
pio run -t uploadSee examples/blink.cnx for the complete LED blink example.
Project Structure
c-next/
├── grammar/CNext.g4 # ANTLR4 grammar definition
├── src/
│ ├── codegen/CodeGenerator.ts # Transpiler core
│ ├── parser/ # Generated ANTLR parser
│ └── index.ts # CLI entry point
├── examples/
│ ├── blink.cnx # LED blink (Teensy verified)
│ └── bit_test.cnx # Bit manipulation tests
├── test-teensy/ # PlatformIO test project
└── docs/decisions/ # Architecture Decision RecordsArchitecture Decision Records
Decisions are documented in /docs/decisions/:
Implemented
| ADR | Title | Description |
| --------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------ |
| ADR-001 | Assignment Operator | <- for assignment, = for comparison |
| ADR-003 | Static Allocation | No dynamic memory after init |
| ADR-004 | Register Bindings | Type-safe hardware access |
| ADR-006 | Simplified References | Pass by reference, no pointer syntax |
| ADR-007 | Type-Aware Bit Indexing | Integers as bit arrays, .length property |
| ADR-010 | C Interoperability | Unified ANTLR parser architecture |
| ADR-011 | VS Code Extension | Live C preview with syntax highlighting |
| ADR-012 | Static Analysis | cppcheck integration for generated C |
| ADR-013 | Const Qualifier | Compile-time const enforcement |
| ADR-014 | Structs | Data containers without methods |
| ADR-015 | Null State | Zero initialization for all variables |
| ADR-016 | Scope | this./global. explicit qualification |
| ADR-017 | Enums | Type-safe enums with C-style casting |
| ADR-030 | Define-Before-Use | Functions must be defined before called |
| ADR-037 | Preprocessor | Flag-only defines, const for values |
| ADR-043 | Comments | Comment preservation with MISRA compliance |
| ADR-044 | Primitive Types | Fixed-width types with clamp/wrap overflow |
| ADR-024 | Type Casting | Widening implicit, narrowing uses bit indexing |
| ADR-022 | Conditional Expressions | Ternary with required parens, boolean condition, no nesting |
| ADR-025 | Switch Statements | Safe switch with braces, \|\| syntax, counted default(n) |
| ADR-029 | Callbacks | Function-as-Type pattern with nominal typing |
| ADR-045 | Bounded Strings | string<N> with compile-time safety |
| ADR-023 | Sizeof | Type/value size queries with safety checks |
| ADR-027 | Do-While | do { } while () with boolean condition (E0701) |
| ADR-032 | Nested Structs | Named nested structs only (no anonymous) |
| ADR-035 | Array Initializers | [1, 2, 3] syntax with [0*] fill-all |
| ADR-036 | Multi-dim Arrays | arr[i][j] with compile-time bounds enforcement |
| ADR-040 | ISR Type | Built-in ISR type for void(void) function pointers |
| ADR-034 | Bitmap Types | bitmap8/bitmap16/bitmap32 for portable bit-packed data |
| ADR-048 | CLI Executable | cnext command with smart defaults |
| ADR-049 | Atomic Types | atomic keyword with LDREX/STREX or PRIMASK fallback |
| ADR-050 | Critical Sections | critical { } blocks with PRIMASK save/restore |
| ADR-108 | Volatile Variables | volatile keyword prevents compiler optimization |
| ADR-047 | NULL for C Interop | NULL keyword for C stream function comparisons |
| ADR-052 | Safe Numeric Literals | type_MIN/type_MAX constants + safe hex conversion |
| ADR-053 | Transpiler Pipeline | Unified multi-pass pipeline with header symbol extraction |
Research (v1 Roadmap)
| ADR | Title | Description |
| ------------------------------------------------------------ | ----------------------------- | --------------------------------------- |
| ADR-008 | Language-Level Bug Prevention | Top 15 embedded bugs and prevention |
| ADR-009 | ISR Safety | Safe interrupts without unsafe blocks |
Research (v2 Roadmap)
| ADR | Title | Description | | --------------------------------------------------------------- | -------------------------- | --------------------------------------- | | ADR-100 | Multi-Core Synchronization | ESP32/RP2040 spinlock patterns | | ADR-101 | Heap Allocation | Dynamic memory for desktop targets | | ADR-102 | Critical Section Analysis | Complexity warnings and cycle analysis | | ADR-103 | Stream Handling | FILE* and fopen patterns for file I/O | | ADR-104 | ISR-Safe Queues | Producer-consumer patterns for ISR/main | | ADR-105 | Prefixed Includes | Namespace control for includes | | ADR-106 | Vector Table Bindings | Register bindings for ISR vector tables |
Rejected
| ADR | Title | Description |
| ---------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------- |
| ADR-041 | Inline Assembly | Write assembly in C files; C-Next transpiles to C anyway |
| ADR-042 | Error Handling | Works with existing features (enums, pass-by-reference, struct returns) |
| ADR-039 | Null Safety | Emergent from ADR-003 + ADR-006 + ADR-015; no additional feature needed |
| ADR-020 | Size Type | Fixed-width types are more predictable than platform-sized |
| ADR-019 | Type Aliases | Fixed-width primitives already solve the problem |
| ADR-021 | Increment/Decrement | Use +<- 1 instead; separation of concerns |
| ADR-002 | Namespaces | Replaced by scope keyword (ADR-016) |
| ADR-005 | Classes | Use structs + free functions instead (ADR-016) |
| ADR-018 | Unions | Use ADR-004 register bindings or explicit byte manipulation |
| ADR-038 | Static/Extern | Use scope for visibility; no static keyword in v1 |
| ADR-026 | Break/Continue | Use structured loop conditions instead |
| ADR-028 | Goto | Permanently rejected; use structured alternatives |
| ADR-031 | Inline Functions | Trust compiler; inline is just a hint anyway |
| ADR-033 | Packed Structs | Use ADR-004 register bindings or explicit serialization |
Development
Setup
# Clone and install (IMPORTANT: npm install sets up pre-commit hooks)
git clone https://github.com/jlaustill/c-next.git
cd c-next
npm install # Installs dependencies and Husky pre-commit hooksPre-commit hooks: The project uses Husky to automatically format code (Prettier) and fix linting (ESLint) before every commit. This prevents formatting errors in PRs.
Commands
npm run antlr # Regenerate parser from grammar
npm run typecheck # Type-check TypeScript (no build required)
npm test # Run all tests
npm test -- --quiet # Minimal output (errors + summary only)
npm test -- tests/enum # Run specific directory
npm test -- tests/enum/my.test.cnx # Run single test file
# Code quality (auto-run by pre-commit hooks)
npm run prettier:fix # Format all code
npm run eslint:check # Check for lint errors
# Coverage tracking
npm run coverage:check # Feature coverage report
npm run coverage:grammar # Grammar rule coverage (generates GRAMMAR-COVERAGE.md)
npm run coverage:grammar:check # Grammar coverage with threshold check (CI)Note: C-Next runs directly via tsx without a build step. The typecheck command validates types only and does not generate any output files.
Formatting C-Next Files
The project includes a Prettier plugin for formatting .cnx files with consistent style (4-space indentation, same-line braces).
# Format a single file
npx prettier --plugin ./prettier-plugin/dist/index.js --write myfile.cnx
# Format all .cnx files in tests/
npx prettier --plugin ./prettier-plugin/dist/index.js --write "tests/**/*.cnx"To build the plugin from source (after making changes):
cd prettier-plugin
npm install
npm run buildContributing
See CONTRIBUTING.md for the complete development workflow, testing requirements, and PR process.
Quick start: Ideas and feedback welcome via issues.
License
MIT
Acknowledgments
- The R community for proving
<-works in practice - MISRA C consortium for codifying embedded safety wisdom
- The TypeScript team for demonstrating gradual adoption works
- ANTLR for the parser infrastructure
