Numlang is an esoteric stack-based language with an in-progress C++20 compiler that emits C. The historical Python compiler is retained as a reference implementation while C++ feature parity is rebuilt.
The character set is 0-9 ^ & * + - / . | ; % # ~ \" !.
🧶 Also available on Tangled
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -jThe binary is build/numlangc. The current C++ implementation supports a tested core subset: numeric, string and character literals; stack operations; arithmetic; comparisons; selected math and bitwise opcodes; numbered variables; and basic console I/O. String output preserves embedded NUL bytes. Integer bitwise opcodes validate 32-bit input ranges and use explicit signed-shift behavior instead of relying on undefined or implementation-defined signed casts/shifts. It rejects recognized but unsupported structural opcodes rather than treating them as ordinary numbers. The full language reference below describes the intended language and retained Python reference compiler, not complete C++ parity. See the C++ compiler status.
# Compile to a C file
numlangc hello.num -o hello.c
# Compile, run with gcc, and execute immediately
numlangc hello.num --run
# Use a different C compiler
numlangc hello.num --run --cc clang
# Increase the runtime stack depth (default 1000)
numlangc hello.num --run --stack-size 4096
C++ port status: this reference describes the full intended language. Only the core subset described above is implemented by the current C++ executable; advanced control flow, declarations, aggregates, FFI, arrays, and other features remain deferred in the C++ port. The historical Python compiler remains available as a reference implementation.
- All values are 64-bit IEEE 754 doubles.
- The runtime has a stack (depth configurable, default 1000) and 100 named variables
vars[0]–vars[99], all initialised to0.
# text — ignored to end of line.
| Syntax | Effect |
|---|---|
N (integer literal) |
Push the integer N, unless N is a special opcode (see tables below) |
N.M (float literal) |
Push the floating-point value N.M — never treated as an opcode |
|n or |nn |
Push the value of variable n (0–99) |
'c' (char constant) |
Push the C int value of the character or escape, e.g. 'A' is 65, '\n' is 10 |
0x1F, 0b101, 017 |
Push hexadecimal, binary and octal integers (a leading 0 means octal) |
10u, 0xFFULL |
Suffixed integers push their value (u, l, ll and combinations are accepted) |
Tip: If you need to push the raw number 10 (which is also the
LTopcode), use the float form10.0, or the hex form0xA.
:N converts the top of the stack to a C type and pushes the result. Integer results wrap modulo the width of the type, as C does for unsigned and out-of-range conversions, and truncate toward zero.
| N | C type | N | C type |
|---|---|---|---|
| 0 | signed char |
7 | unsigned long |
| 1 | unsigned char |
8 | long long |
| 2 | short |
9 | unsigned long long |
| 3 | unsigned short |
10 | float (rounds to single precision) |
| 4 | int |
11 | double |
| 5 | unsigned int |
12 | _Bool (0 or 1) |
| 6 | long |
Values are still held as doubles, so integer results are exact only up to 2^53. Arithmetic is not implicitly converted: write :N after an operation to get the narrow-type result C would give.
| Code | Name | Effect |
|---|---|---|
16 |
DUP | Duplicate top of stack |
17 |
SWAP | Swap top two elements |
18 |
DROP | Discard top of stack |
| Symbol | Effect |
|---|---|
+ |
pop b, pop a → push a + b |
- |
pop b, pop a → push a − b |
* |
pop b, pop a → push a × b |
/ |
pop b, pop a → push a ÷ b (runtime error if b == 0) |
% |
pop b, pop a → push fmod(a, b) (runtime error if b == 0) |
38 |
IMOD — integer remainder: (int)a % (int)b |
| Code | Meaning |
|---|---|
10 |
a < b |
11 |
a > b |
12 |
a == b |
13 |
a != b |
14 |
a <= b |
15 |
a >= b |
| Syntax | Effect |
|---|---|
! |
NOT — pop x; push 1 if x == 0, else push 0 |
19 |
OR — pop b, pop a; push 1 if either non-zero, else 0 |
39 |
AND — pop b, pop a; push 1 if both non-zero, else 0 |
| Code | Name | Effect |
|---|---|---|
40 |
BAND | (int)a & (int)b |
41 |
BOR | (int)a | (int)b |
42 |
BXOR | (int)a ^ (int)b |
43 |
BNOT | ~(int)a (unary) |
44 |
BSHL | (int)a << (int)b |
45 |
BSHR | arithmetic signed 32-bit right shift |
The C++ compiler rejects non-finite or out-of-range operands for these integer operations, rejects shift counts outside 0–31, defines left-shift wraparound as a 32-bit bit pattern, and diagnoses INT32_MIN % -1 rather than invoking signed-overflow undefined behavior.
| Code | Name | Effect |
|---|---|---|
21 |
ABS | pop x → push |x| |
22 |
SQRT | pop x → push √x (runtime error if x < 0) |
23 |
FLOOR | pop x → push ⌊x⌋ |
24 |
CEIL | pop x → push ⌈x⌉ |
25 |
NEG | pop x → push −x |
26 |
MIN2 | pop b, pop a → push min(a, b) |
27 |
MAX2 | pop b, pop a → push max(a, b) |
29 |
POW | pop exp, pop base → push base^exp |
31 |
LOG | pop x → push ln(x) (runtime error if x ≤ 0) |
32 |
LOG10 | pop x → push log₁₀(x) (runtime error if x ≤ 0) |
33 |
SIN | pop x (radians) → push sin(x) |
34 |
COS | pop x (radians) → push cos(x) |
35 |
TAN | pop x (radians) → push tan(x) |
46 |
ASIN | pop x → push arcsin(x) in radians (domain error if x ∉ [−1, 1]) |
47 |
ACOS | pop x → push arccos(x) in radians (domain error if x ∉ [−1, 1]) |
48 |
ATAN | pop x → push arctan(x) in radians |
49 |
ATAN2 | pop x, pop y → push atan2(y, x) in radians |
36 |
ROUND | pop x → push nearest integer (halfway rounds away from zero) |
37 |
TRUNC | pop x → push integer part, truncated toward zero |
| Code | Name | Effect |
|---|---|---|
60 |
RAND | push a random double in [0, 1) |
61 |
TIME | push current Unix timestamp as a double |
62 |
EXIT | pop exit code; terminate program immediately |
63 |
DEPTH | push the current stack depth as a double |
64 |
PICK | pop n; push a copy of the element n positions below the (new) top (0 = top) |
Operands of IDIV and array indices are truncated to C int, so these ops follow C integer semantics.
| Code | Name | Effect |
|---|---|---|
51 |
ARR_NEW | pop size n, pop id; allocate array id with n zeroed elements (ids 0–15) |
52 |
ARR_SET | pop value, pop index, pop id; store value at arr[id][index] |
53 |
ARR_GET | pop index, pop id; push arr[id][index] |
55 |
IDIV | pop b, pop a; push a / b truncated toward zero (exit 1 on division by zero) |
Out-of-range array access exits with status 1.
@0 "math.h" "hypot" 2 ; # declare: number, header, C symbol, arity (0-3)
3 4 $0 | # call: pops the arguments, pushes the result
Declarations (@) appear at the top level. Calls ($) take the arguments from the stack in order. Foreign functions take and return C double values, and the header is included in the generated C. Link libraries such as libm are added automatically. A header ending in .c is treated as a user C source: it is compiled and linked from the directory of the .num file, and the generated code declares it with double parameters.
<condition>
20
<then_body>
[28
<else_body>]
;
Pops the condition from the stack. If non-zero, executes then_body; otherwise executes the optional else_body.
The then_body and else_body may each contain arbitrarily many operations, including nested control flow.
The integer
28acts as the else-separator only when it appears at the top level inside anIFblock. Anywhere else — including nested inside aWHILEorREPEATbody —28is a plain numeric push.
<initial_condition>
30
<body>
<next_condition>
;
Pops the condition before each iteration. Runs the body while the condition is non-zero.
The body is responsible for leaving the next condition on the stack.
<count>
50
<body>
;
Pops count, then executes body exactly count times.
Before each iteration, the 0-based iteration index is pushed onto the stack for the body to use. Use 18 (DROP) to discard it if you don't need it.
(
<body>
<flag>
)
Runs body once, then pops the flag it left and repeats while the flag is non-zero.
Unlike WHILE, the first iteration always runs and the body itself produces the flag. The parentheses delimit the body.
< # break: leave the innermost WHILE, REPEAT or DO-WHILE
> # continue: jump to the next iteration of the innermost loop
BREAK is rejected outside a loop; CONTINUE is rejected outside a loop.
CONTINUE jumps to the loop's test, so in a WHILE or DO-WHILE the next condition must already be on the stack. Values left on the stack by the body are not cleaned up, as in any other early exit from a block.
<value> <index> &
Pops the index (0–99), then the value, and stores vars[index] = value.
|n # push vars[n], single digit (0–9)
|nn # push vars[nn], two digits (10–99)
Example — store π in var 10 and retrieve it:
3.14159 10 &
|10 |
Numlang has named objects with C declarations. Storage is one array of cells (one double each). Cell 0 is the null pointer; the legacy |n variables occupy cells 1–100. A scalar or pointer takes one cell, and arrays and structs take consecutive cells. Automatic objects live in a frame created on function entry.
int x = 2.0; # declaration with initialiser (use float literals)
int a[3] = {1.0, 2.0}; # braced initialiser, missing elements are zero
int m[2][2] = {{1.0, 2.0}, {3.0, 4.0}};
struct P { int x; int y; }; # struct and union tags; members use . and ->
struct P p = {.y = 5.0}; # designated initialiser
union U { int i; double d; };
enum E { A, B = 5 }; # enumeration constants are ints
typedef double Real;
static int calls = 0.0; # static storage, initialised once at startup
Assignment and lvalues:
x = x 1.0 + ; # name = postfix expression ;
a[1] = 4.0; # index
p.x = 3.0; # member
q->x = 4.0; # member through pointer (-> is a token)
Pointers hold the cell address of their target:
\x # address of x (push)
int *px = \x; # pointer declaration
px[0] = 7.0; # p[0] is the dereference; * is the multiply opcode, so there is no prefix *
\.0 # function reference: a pointer to function 0
int (*fp)(); # function pointer; fp(); calls the function it holds
int (*tab[2])() = {\.0, \.1}; # array of function pointers; tab[i]() calls one
int *p2 = arr; # an array name decays to the address of its first element
Switch and goto:
switch 2.0 { case 1: 10.0 | < case 2: 20.0 | < default: 30.0 | }
goto skip; # forward or backward jump to a label in the same function
label skip;
< leaves a switch (or loop) and > continues a loop. Without <, control falls through to the next case group.
sizeof(type) and sizeof(name) give byte sizes using the LP64 layout (natural alignment, struct padding). Those values are used only by sizeof; addresses still count cells.
Qualifiers:
- Assigning to a
constobject is a compile error. - Initialising or assigning a pointer that discards
constis a compile error when the right-hand side is one address, load or function reference. volatileis parsed and checked, but generates no special code.restrictis accepted only on pointer declarators; it has no effect on generated code.
Limits:
- No type punning through unions. Reading a member other than the one last written gives the stored double, not reinterpreted bits.
- Pointer arithmetic is not supported; use
p[i]to index. - Pointers to arrays (
int (*p)[3]) are not supported; a multi-dimensional array name does not decay to anint *. - String literals cannot initialise arrays.
- Structs are copied only by whole-struct assignment, and a struct cannot be pushed as a value.
- File-scope and
staticinitialisers must be constant: literals, arithmetic, casts, sizeof, function references, addresses of statics, and the decayed address of a static array. They may not read memory or call functions. - Labels are scoped to their function.
Numlang has no infix assignment or operator-with-side-effect syntax, so these C expressions are spelled as stack sequences:
| C expression | Numlang | Result left on stack |
|---|---|---|
c ? a : b |
<c> 20 <a> 28 <b> ; |
one value, from the taken branch only |
x += n / x -= n |
|x <n> + x & / |x <n> - x & |
nothing |
++x |
|x 1.0 + 16 x & |
the new value |
x++ |
|x 16 1.0 + x & |
the old value |
--x / x-- |
same as ++ / ++ with - |
new / old value |
(X, Y) |
<X> 18 <Y> |
the value of Y (the value of X is dropped) |
Use the float form (1.0) for constants that collide with opcode numbers, as elsewhere. & pops the index and then the value, so <value> <index> & stores into a variable.
| Symbol | Effect |
|---|---|
| |
Pop and print top of stack as a number (followed by newline) |
~ |
Pop and print top of stack as an ASCII character (no newline) |
^ |
Read a double from stdin and push it |
"..." |
Print a string literal (see below) |
"..." prints each character in the string immediately — the multi-character
sibling of ~. It desugars to a putchar() call per character and leaves
the stack unchanged.
Escape sequences follow the full C syntax set:
| Escape | Value | Meaning |
|---|---|---|
\n |
10 | newline |
\t |
9 | horizontal tab |
\r |
13 | carriage return |
\\ |
92 | backslash |
\" |
34 | double quote |
\' |
39 | single quote |
\a |
7 | alert / bell |
\b |
8 | backspace |
\f |
12 | form feed |
\v |
11 | vertical tab |
\xHH |
0–255 | hex escape (1–2 hex digits) |
\NNN |
0–255 | octal escape (1–3 octal digits, first digit 0–7) |
/N <body> ; # define function N
.N # call function N
Function numbers are plain integers (0, 1, 2, …). Functions may be defined in any order; forward calls are supported. Recursion is supported.
Warning: The
/character is both the division operator and the function definition prefix. When/is followed by an integer literal, it is parsed as a function definition. For example,4 / 0 &will define function 0 instead of dividing 4 by 0. Use4.0 / 0 &or restructure your code to avoid this ambiguity.
-
Opcode collisions: Integer literals that match opcode numbers are interpreted as opcodes, not values. Use float syntax (e.g.,
10.0instead of10) to push these numbers. Affected numbers: 10–15 (comparisons), 16–18 (stack), 19 (OR), 20 (IF), 21–27 (math), 28 (ELSE), 29 (POW), 30 (WHILE), 31–35 (trig), 36–39 (round/logical), 40–45 (bitwise), 46–49 (inverse trig), 50 (REPEAT), 51–53 and 55 (arrays and IDIV), 60–64 (misc). -
Division vs functions:
/followed by an integer defines a function. Use float divisors or pre-compute values to avoid this. -
String literals: All characters in strings are output as raw byte values via
putchar(). Non-ASCII characters may cause encoding issues on some systems. -
Dereference and multiply:
*is the multiply opcode. Dereference a pointer withp[0]. -
Minus and arrow:
->is lexed as one token, so-followed by>with no space is a member access, not subtraction followed by>.
| Code(s) | Name(s) | Category |
|---|---|---|
16 |
DUP | Stack |
17 |
SWAP | Stack |
18 |
DROP | Stack |
10 |
LT | Compare |
11 |
GT | Compare |
12 |
EQ | Compare |
13 |
NE | Compare |
14 |
LE | Compare |
15 |
GE | Compare |
! |
NOT | Logical |
19 |
OR | Logical |
39 |
AND | Logical |
40 |
BAND | Bitwise |
41 |
BOR | Bitwise |
42 |
BXOR | Bitwise |
43 |
BNOT | Bitwise |
44 |
BSHL | Bitwise |
45 |
BSHR | Bitwise |
21 |
ABS | Math |
22 |
SQRT | Math |
23 |
FLOOR | Math |
24 |
CEIL | Math |
25 |
NEG | Math |
26 |
MIN2 | Math |
27 |
MAX2 | Math |
29 |
POW | Math |
31 |
LOG | Math |
32 |
LOG10 | Math |
33 |
SIN | Math |
34 |
COS | Math |
35 |
TAN | Math |
46 |
ASIN | Math |
47 |
ACOS | Math |
48 |
ATAN | Math |
49 |
ATAN2 | Math |
36 |
ROUND | Math |
37 |
TRUNC | Math |
38 |
IMOD | Math |
20 |
IF (block) | Control |
28 |
ELSE separator | Control |
30 |
WHILE | Control |
50 |
REPEAT | Control |
60 |
RAND | Misc |
61 |
TIME | Misc |
62 |
EXIT | Misc |
63 |
DEPTH | Misc |
64 |
PICK | Misc |
51 |
ARR_NEW | Arrays |
52 |
ARR_SET | Arrays |
53 |
ARR_GET | Arrays |
55 |
IDIV | Integer |
"Hello, World!\n"
99 0 & # vars[0] = 99
|0 | # print vars[0]
3.14159 0 & # push π, store in vars[0]
2.71828 1 & # push e, store in vars[1]
|0 | # print π
|1 31 | # print ln(e) ≈ 1
2 10 29 | # print 2^10 = 1024
7 5 11 # push (7 > 5) = 1
20
"7 is greater than 5\n"
28
"7 is not greater than 5\n"
;
|0 15 38 0 12 # (n % 15) == 0
20
"FizzBuzz\n"
28
|0 3 38 0 12 # (n % 3) == 0
20
"Fizz\n"
28
"Buzz\n" # simplified; real fizzbuzz also checks %5
;
;
5 0 & # vars[0] = 5
|0 0 11 # initial condition: vars[0] > 0
30
|0 | # print vars[0]
|0 1 - 0 & # vars[0] -= 1
|0 0 11 # next condition
;
5 50
| # print iteration index
;
255 15 40 | # 255 AND 15 = 15 (lower nibble)
1 8 44 | # 1 << 8 = 256
/0
"Hello from function 0!\n"
;
.0
# Function 0: push 2^vars[0]
# Uses iterative doubling via WHILE
/0
1 1 & # vars[1] = result = 1
|0 0 11 # condition: vars[0] > 0
30
|1 2 * 1 & # result *= 2
|0 1 - 0 & # vars[0] -= 1
|0 0 11
;
|1 |
;
10 0 & # vars[0] = exponent
.0 # prints 1024
numlang/
lexer.py — tokeniser (float literals, |nn variables)
parser.py — recursive-descent parser → AST
ast.py — AST node types
ctypes_model.py — C type model: cell and LP64 byte layouts
sema.py — semantic analysis
codegen_c.py — C code generator
main.py — CLI entry point
examples/
hello.num
hello_world.num
compare.num
if.num — block IF / IF-ELSE / nested IF
while.num
repeat.num — REPEAT loop with iteration index
function.num
fizzbuzz.num — uses nested IF-ELSE
factorial.num
floats.num — float literals and math built-ins
trig.num — sin, cos, tan, asin, acos, atan, atan2
stack_introspect.num — DEPTH and PICK
bitwise.num — BAND, BOR, BXOR, BNOT, BSHL, BSHR
logical.num — NOT, OR, AND
math_ops.num
complex.num — statistics calculator + Collatz sequence
stack.num
strings.num
input.num
modulo.num
If you find this project useful, consider supporting its development: