The C language, in S-expressions — with Lisp macros as its build system and type system.
Cicili is a transpiler: you write C in Lisp syntax, and it emits the C you would have
written by hand, then drives the C compiler for you. Every clause — func, struct,
let, for — maps to C you can read and debug. On top of that core, Common Lisp macros
run at compile time with full access to Cicili's type inference, which is enough to build
generics, RAII, reference counting, pattern matching — and, one layer up, Functors and
Monads — all with zero runtime overhead and no garbage collector.
(source "hello.c" (make :std #t :compile #t :link #t)
(main
(letin ((v (new vector (cast (const int []) '{ 1 2 3 4 5 }))))
(push_vector v 6)
(printf "len %zu, last %d\n"
(len_vector v)
(matchn (nth_vector 5 v) (just x (cof x)) (nothing -1))))))
That is a growable vector with bounds-checked access answering a maybe — carrying a
pointer into the buffer, hence the cof — and it
compiles to plain C structs and functions, freed deterministically when letin's scope
ends. It owns its buffer outright; wrap it in an rc when you want it shared, and pay
for that only then.
- You see the C. The generated
.cfile is the ground truth, formatted for reading. Nothing is hidden in a runtime; a Cicili binary is a C binary. - The macro layer does the thinking. Cicili's compiler exposes its type inference to
macros (
CICILI:TYPE-CHECK,CICILI:INFER-TYPE). A macro can ask "what type is this expression?" and expand accordingly — that is howmatchdispatches, howfree_cellfinds the right destructor, and howautoworks.maybeandeitherneed no inference at all: their constructors are brace lists, typed by the slot they are written into. - Front-end / back-end. Every std-library feature is two parts: a back-end function
(declared in
decl-X, implemented inimpl-Xgenerics) for anything that touches its receiver more than once, and a front-end macro that only type-checks, infers, and dispatches. Single-use accessors compile to inline syntax, not calls. - Memory is managed, not collected.
letinscopes, thedeferattribute (__attribute__((cleanup))RAII),cellfor owned heap values,rcfor shared ones. Ownership violations — use aftermove, double free, non-copy assignment — are compile-time errors. - Errors point at your code. The compiler runs the C compiler once to collect diagnostics, maps each one back to the Cicili form that produced it, and reports it with the compile path that led there.
Cicili is meant to be a language an AI agent can write correctly on the first attempt, and the claim is made against specific factors rather than as an adjective. Each row below says what the factor is, why it matters when a model rather than a person is at the keyboard, and what Cicili actually does about it. The gaps are listed too — a claim you cannot check is worth nothing.
| factor | why it matters to a model | Cicili |
|---|---|---|
| Uniform shape | Most syntax errors come from guessing structure — precedence, statement-vs-expression, where a brace goes. | Every construct is (head …). There is no precedence to infer and no second grammar for expressions. A model that can balance parentheses cannot produce a malformed program. |
| One way to say a thing | Every alternative spelling is a chance to pick the wrong one, and a model picks from what it has seen most, not what is best here. | The C++ layer's whole design rule is if the language already has a construct that means this, use it. There is no class (it is struct), no namespace clause (it is module), no reference wrapper (it is the & already in the type descriptor). Each application deleted syntax. |
| Failing rather than being silently wrong | A model reports the build as green. A silent miscompile is therefore reported as success — the single worst outcome in an agent loop. | This is why C++ constructs are clauses and not text escapes. A (code …) escape spliced (cof p) as p, dropping the dereference with no diagnostic; as a clause the operand is a specified expression and cannot be dropped. |
| Errors that name your code | A model can only act on a message that names the thing it wrote. Internal state is noise it will guess against. | unknown member: w of type: Shape names the member and the type. Every C-compiler diagnostic is mapped back to the Cicili form that produced it and reported with the path that led there. |
| Locality | A model reasons about the form in front of it. Anything that depends on distant context is a chance to be wrong. | Attributes attach to the next clause only. A qualified name resolves without a using elsewhere in the file. A method's this is implicit and needs no declaration. |
| Determinism | An agent diffs its output to know what changed. Output that churns on its own makes the diff useless. | Same source, same bytes. Anonymous struct names were derived from the clock until recently; they are now derived from the translation unit, and two full suite runs produce byte-identical C for every target. |
| Checkable by construction | A model needs a cheap, total verdict, not a subjective review. | sh test/run.sh transpiles, compiles and runs every file under test/c, test/std and test/cpp, and must end red: 0. Every documented clause links to the runnable test that covers it. |
| A learnable surface | An LLM has very little Cicili in its training data. It must learn the language from what is in context. | The complete clause set is two index tables — DOC-C.md and DOC-CPP.md — each clause with the generated C beneath it. The language is small enough to read in one sitting, which is the only reason a model can use it at all. |
| Compile-time ownership | Use-after-move and double-free are exactly the bugs that survive review, human or model. | Ownership violations are compile-time errors, not runtime ones: use after move, double free, assigning a non-copy. |
| The generator is the language | Code that writes code is where a model is most useful and most likely to drift. | Macros are ordinary Common Lisp over the same s-expressions, with the compiler's own type inference exposed (CICILI:TYPE-CHECK, CICILI:INFER-TYPE). A generated form is checked exactly like a written one. |
Lisp was the first language built for AI, and it is worth being precise about why, because the reasons are the same ones above pointing in the opposite direction.
John McCarthy — who had coined "artificial intelligence" for the 1956 Dartmouth proposal — designed Lisp at MIT in 1958 for symbolic computation. Early AI was logic, planning, language and search: symbols and structure, not arithmetic. FORTRAN gave you arrays. Lisp gave you the symbol and the list as primitives, so representing knowledge needed no encoding layer between the idea and the program. Four things followed from that and made Lisp the field's language for thirty years:
- Code is data. A program is a list, so a program can build, inspect and transform another program. Expert-system shells, planners and theorem provers were written as Lisp programs that wrote Lisp.
eval. McCarthy defined the language by writing its interpreter in itself, so a system could construct a form and then run it.- Garbage collection, invented for Lisp in 1959, because the lifetime of a search tree cannot be known in advance.
- The REPL. AI programs could not be specified up front; they had to be grown while running.
The parenthesised syntax was not even the plan — McCarthy intended a more conventional "M-expression" surface, and S-expressions were the internal representation. Programmers preferred the internal one, which is how homoiconicity became the language's defining feature by accident.
The direction has reversed. Lisp was a language for building AI; the properties above made a machine's reasoning expressible. Today's AI is numeric and lives in Python and CUDA, and that era is over. But a model writing code needs uniform structure, one obvious spelling, and a program it can manipulate as data — which is the same list of properties, now serving AI writing the language rather than the language expressing AI. Cicili is a bet that those properties are worth as much in the second direction as they were in the first, and that they are worth having with C's performance underneath.
- No overloading. Two methods with the same name collide, and the error (
inner exists) says less than it should. - Some errors still print specifier internals.
pointer storage not found: #<(@NTH …names the compiler's state rather than your line. Every construct moved out of acodeescape removes one of these; there are still cases that reach it. - Move analysis is not path-sensitive.
(if c (eat a) (eat a))is rejected although only one branch runs. - No language server. No completion, no go-to-definition, no inline diagnostics.
- A small corpus. No model has meaningful Cicili in its weights. Everything above is what makes that survivable, not something that makes it untrue.
your.cicili ──read──► forms ──specify──► typed IR (sp tree, symbol table)
│
│ macros expand here, with type inference
▼
collecting pass ──► your.c ──► C compiler (diagnostics collected)
▼
final pass ──► your.c + your.h ──► compile ──► link (libtool)
-
Targets. A
.cicilifile holdssourceandheadertargets; each names its output file and its:compile/:linkarguments. One file can build several objects, a library and an executable (test/c/library.cicili). -
Two passes. The collecting pass writes C, hands it to clang/gcc, and files every
file:line:coldiagnostic under the position each printed symbol landed on. The final pass writes the real target and reports any collected error against the Cicili form — with theCOMPILE-*path down to it.--separatekeeps each pass as<target>.run<N>.c. -
Generics.
(generic decl-array (a) …)defines a macro;(decl-array int)instantiates it.<>interns type-parameterised names:(<> array int)→array_int. -
Layers.
layer where what compiler *.lispreader, specifier (type inference), backend (C emission), authority (ownership checks) builtins builtins.cicili,cpp.cicilimain,letin,closure,match,defer*,new,import, …C declarations lib/std/c/the C standard library and POSIX, as typed Cicili declarations std lib/std/maybe,either,array,cell,rc,vector,pthreadfunctional lib/std/haskell/ADTs, type classes, Functors, Monads — see doc/FUNCTIONAL.md -
Toolchain.
config.lispdrives clang (macOS) or gcc (Linux) through GNU libtool, with-Werror -Wall, in a debug (-g -O0) or a--release(-O3 -falign-loops=32) set.:cpp #tswitches a target to the C++ compiler.
- Install SBCL, a C compiler (clang on
macOS, gcc on Linux —
brew/aptinstall either), and GNU Libtool (glibtoolfrom brew on macOS). Compiler and linker commands can be changed inconfig.lisp. - Clone this repository. To make the package loadable through
ASDF/Quicklisp, place
(or symlink) it under
~/common-lispand run(ql:quickload "cicili")once insbcl. - That's it —
cicili.lispis the entry point:
sbcl --script cicili.lisp ./test/std/vector.cicili
./test/std/vec_testTested with SBCL 2.x, Apple clang 21, gcc 15.2, libtool 2.5.4.
Write hello.cicili:
(source "hello.c" (make :std #t :compile #t :link #t)
(main
(printf "hello from cicili\n")
(return 0)))
sbcl --script /path/to/cicili.lisp hello.cicili # writes hello.c, compiles, links
./mainRead hello.c — that habit is the fastest way to learn the language. Then:
- doc/DOC-C.md is the complete reference for the C core: every clause with the generated C underneath, a clause index at the top, and each section linked to the runnable test that covers it.
- doc/DOC-CPP.md is the C++ half: qualified
::names,structinheritance, methods with implicitthis,ctor/dtor,moduleasnamespace, references, templates andletin*. A supplement to DOC-C.md, not a replacement. - test/c/ is one green, self-checking file per clause family — operators, control, functions, aggregates, memory, macros, preprocessor, targets.
- test/run.sh transpiles, compiles and runs everything under
test/candtest/std:sh test/run.shmust endred: 0. - Build type:
--releasebuilds with-O3 -falign-loops=32; without it you get-g -O0. Both sets live per-OS inconfig.lisp, so no target carries its own optimisation flags and none can drift from the rest of the suite. - Debugging:
--separatekeeps each pass's C;--syslogcompiles in the std library's allocation/free trace;--macroexpandprints every macro expansion.--syslogis sized for the tests — on a benchmark that allocates a million times it emits tens of millions of lines, so redirect it to a file. - Emacs users:
(add-to-list 'load-path "/path/to/cicili/emacs") (require 'cicili-mode)— see doc/emacs.md.
Everything in lib/std follows the decl/impl rule: instantiate both, then use the
front-end macros. (decl-vector int) (impl-vector int) pulls in maybe and nothing else —
array, cell, rc and either stand alone, instantiate them where you need them.
| type | what it is | front ends | test |
|---|---|---|---|
maybe |
presence of a value, no sentinels | just / nothing build one without naming it; match / matchn open it |
maybe |
either |
the answer, or why there isn't one | right / left build one without naming it; match / matchn open it |
either |
array |
fixed contiguous buffer + length | new, len_array, nth_array (answers a (<> Maybe ref a) — a pointer into the buffer), let_array / take_array |
array |
cell |
owned heap value, freed exactly once | new_cell, let_cell / letn_cell (borrow), take_cell / taken_cell (consume) |
cell |
rc |
a cell that counts its owners |
new_rc, clone_rc, let_rc / letn_rc (borrow), take_rc / taken_rc (consume, last owner only) |
rc |
vector |
an array that owns its headroom — unshared, wrap it in rc yourself |
push_vector, append_vector (amortised power-of-two growth), nth_vector, len_vector |
vector |
btree |
ordered map, logarithmic | insert / delete answer an either, search / min / max a maybe, traverse in key order |
btree |
pthread |
threads with captured context | go, join, detach, cancel, exit-self |
thread |
Conventions worth knowing:
letinbindsnew-constructed values and frees them, in reverse order, when the scope ends — the std types are designed around it.- A
movebinding transfers ownership; using a moved variable again is a compile error. (inline)emits__attribute__((weak))— one definition serves every translation unit.--syslogbuilds show everyNEW/FREE/CLONE/GROW, so leaks are visible before you reach forleaksor valgrind — and the suite runs clean under both.
The functional layer on top — ADTs, pattern matching, Functors, Applicatives, Monads — is documented in doc/FUNCTIONAL.md.
The claim "zero runtime overhead" is worth only as much as the measurement behind it.
lib/std's vector is compared against Rust's Vec<i32>, and rc-wrapped against
Rc<Vec<i32>>, operation for operation. Sources:
benchmark/std-vector-bench.cicili and
benchmark/rust-vector-bench.
Every benchmark target refuses to build without --release — they carry
:compile #t and no flags of their own, so without it they compile at -g -O0
and print meaningless milliseconds. See
benchmark/release-only.cicili, and
doc/benchmark.md for a measurement that was withdrawn for
exactly that reason.
10⁹ elements per row, Intel i9-9880H, Apple clang 21.0.0 vs rustc 1.96.0. Cicili built
with --release; Rust with cargo build --release. Lower is better; bold is the
winner of that pair.
| Cicili | Rust | Cicili -flto |
Rust lto=true |
||
|---|---|---|---|---|---|
| construct | 105 ms | 100 ms | 62 ms | 90 ms | |
| nth | 504 ms | 516 ms | 487 ms | 481 ms | |
| push | 1942 ms | 1565 ms | 1074 ms | 1375 ms | |
| append | 94 ms | 99 ms | 51 ms | 89 ms |
| Cicili | Rust | Cicili -flto |
Rust lto=true |
||
|---|---|---|---|---|---|
| construct | 270 ms | 158 ms | 82 ms | 135 ms | |
| nth | 1259 ms | 518 ms | 482 ms | 473 ms | |
| push | 1899 ms | 2836 ms | 1220 ms | 2595 ms | |
| append | 284 ms | 165 ms | 83 ms | 143 ms |
Without LTO it is mixed; with LTO Cicili wins or ties every row but one. The two
places Cicili loses badly without LTO — shared nth and shared construct — are the two
LTO fixes outright, which says the gap was never the reference counting. clang cannot
propagate a length through malloc + memcpy into the box without whole-program view;
given it, the rc'd nth goes 1259 → 482 and lands level with the owned one.
-flto is not in the default release set, because the evidence points both ways: it
wins nearly everything here, and costs ~14% on a tight loop whose bounds check had already
folded (452 → 514 ms on test/std/array.cicili). Add it per target
until that is understood.
btree is the API of
draft/collection/persistent-btree.cicili
brought over to the std idiom: either for what can fail, maybe for what may
simply not be there. 10⁶ operations per row, node size matched (Rust's BTreeMap
is B=6, 11 pairs a node; the Cicili side is Cormen's t=6, the same 11), same
xorshift so both see the same keys in the same order.
| Cicili | Rust | ||
|---|---|---|---|
| insert | 216 ms | 233 ms | Cicili ~7% faster |
| search | 200 ms | 257 ms | Cicili ~22% faster |
| traverse in order | 5 ms | 5 ms | level |
| delete | 261 ms | 287 ms | Cicili ~9% faster |
Best of ten runs each, alternating run for run so neither side gets a quieter machine than the other. The machine was busy — absolute numbers on a quiet one are 20 to 40% lower for both — which is why the two are interleaved and why the best, not the mean, is quoted.
This table used to say the opposite, and it is worth saying why rather than
quietly replacing it. The earlier btree gave every node a kids[2t] array
inline, and Rust was 17 to 28% ahead on all four rows. The suspected cause was
{key,val} pairs stored together against BTreeMap's separate key and value
arrays. That was the wrong suspect. The real one was the leaves: in a tree
of degree t almost every node is a leaf, a leaf never reads a child, and 96
bytes of a 200-byte node were child pointers that nothing would ever load.
Two fixes were built and measured. Moving all the child pointers into one
pool shared by the tree saves the same memory and is worse — 10 to 20%
slower on insert and search at every size, cache resident or not, because it
puts an extra indirection on every descent. The dead weight was on the leaves
and that fix charged the internal nodes for it. Giving leaves their own type
is the one that worked: a leaf is 96 bytes with no kids at all, an internal
node keeps its 2t pointers inline exactly where the descent already reads them,
and the tree's height says which kind a pointer refers to — a B-tree is
balanced, so no node needs a flag. That is what
lib/std/btree.cicili is now, and against the inline-kids
version it is 20% faster on insert, 14% on search, 31% on traverse and 17% on
delete at 10⁶ keys.
LTO is close to noise here for both — measured at +5% on Cicili's insert, −2% on its delete, −5% on Rust's insert, nothing anywhere else. Worth saying because it is not what LTO did to the vector, where it erased the rc penalty outright.
What is worth more than the timings: every checksum matches exactly, and across all four builds above — 207679490886 pairs inserted, 644483 distinct keys, 524727962248 summed on lookup, 338135951754 in traversal order, 644483 deleted. Two independent implementations agreeing bit-for-bit over a million mixed operations is a stronger correctness signal than the test suite gives on its own.
Both languages' nth row has its bounds check deleted — the element count is a
compile-time constant, so index < len folds to true and neither loop branches. That is a
bounds-checked API compiling to an unchecked loop, on both sides, and it is the number
most benchmarks quote. Hide the length so the check actually runs, and:
Cicili array |
Cicili vector |
Rust | |
|---|---|---|---|
| check elided | 474 ms | 457 ms | 467 ms |
| check executed | 950 ms | 995 ms | 1085 ms |
Cicili is ~10% faster when the check is real, and array and vector agree with each
other — they run the same nth over the same layout, so they should.
Rust is quoted at its best there: it measured a stable 1085 ms cold and 1237–1346 ms once the machine had warmed up, and taking its lowest is the conservative direction. On a warm-for-warm comparison the gap is wider; it is not quoted that way.
No claim is made about why. Both nths answer a (<> Maybe ref a) — a tagged
struct carrying a pointer into the buffer, 16 bytes, tag tested separately from the
bound — which is more work per element than Rust's niche-encoded Option<&i32>, where
None is a null pointer and one branch serves for both. Cicili wins the row anyway, and
that is not explained by the data structure. lib/std/maybe.cicili does carry a
decl-refmaybe that niche-encodes exactly the way Rust does, but nothing in the tree uses
it, so it is not what these numbers measure.
sbcl --script cicili.lisp --release ./benchmark/std-vector-bench.cicili && ./benchmark/std_vec_bench
cd benchmark/rust-vector-bench && cargo build --release && ./target/release/api_benchTwo things to know before quoting any of it. Code layout is worth more than most of
these differences — the same Rust nth source measured 471–641 ms across builds that
differ in no relevant way, which is why -falign-loops=32 is in the release set: it took
the array benchmark's run-to-run spread from 5.8% to 2.3% and its median from 500 to
445 ms. And a checksum the compiler can precompute deletes the loop under it — the
construct and append rows read 0 ms until their checksums were changed to accumulate
the buffer address, which cannot be folded.
cicili.lisp entry point doc/DOC-C.md the C-core reference
*.lisp the compiler doc/DOC-CPP.md the C++ reference
builtins.cicili the builtin macros doc/FUNCTIONAL.md the functional layer
cpp.cicili the C++ macro layer doc/test.md the test suite
lib/cpp/torch libtorch bindings + DSL test/c, test/std the suite (sh test/run.sh)
lib/python/ CPython + numpy bindings example/ runnable examples
lib/std/ the standard library
lib/std/haskell/ the functional layer test/cpp the C++ suite
emacs/ cicili-mode
See LICENSE.