Skip to content
ewanc26Public

About

an esoteric stack-based language that compiles to C

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Numlang compiler

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


Installation

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j

The 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.


Usage

# 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


Language reference

Data model

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 to 0.

Comments

# text — ignored to end of line.


Pushing values

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 LT opcode), use the float form 10.0, or the hex form 0xA.

Explicit conversions

: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.


Stack manipulation

Code Name Effect
16 DUP Duplicate top of stack
17 SWAP Swap top two elements
18 DROP Discard top of stack

Arithmetic

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

Comparison (push 1.0 = true, 0.0 = false)

Code Meaning
10 a < b
11 a > b
12 a == b
13 a != b
14 a <= b
15 a >= b

Logical operators

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

Bitwise operators (operands truncated to signed 32-bit values when in range)

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.


Math built-ins

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

Misc operations

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)

Integer division and fixed-size arrays

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.


Foreign C functions

@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.

Control flow

IF / IF-ELSE (block form)

<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 28 acts as the else-separator only when it appears at the top level inside an IF block. Anywhere else — including nested inside a WHILE or REPEAT body — 28 is a plain numeric push.

WHILE

<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.

REPEAT

<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.

DO-WHILE

(
    <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 and CONTINUE

<        # 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.


Variables

<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 |

Memory model

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 const object is a compile error.
  • Initialising or assigning a pointer that discards const is a compile error when the right-hand side is one address, load or function reference.
  • volatile is parsed and checked, but generates no special code.
  • restrict is 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 an int *.
  • 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 static initialisers 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.

Expression idioms

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.

I/O

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)

String literals

"..." 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)

Functions

/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. Use 4.0 / 0 & or restructure your code to avoid this ambiguity.


Gotchas and tips

  1. Opcode collisions: Integer literals that match opcode numbers are interpreted as opcodes, not values. Use float syntax (e.g., 10.0 instead of 10) 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).

  2. Division vs functions: / followed by an integer defines a function. Use float divisors or pre-compute values to avoid this.

  3. String literals: All characters in strings are output as raw byte values via putchar(). Non-ASCII characters may cause encoding issues on some systems.

  4. Dereference and multiply: * is the multiply opcode. Dereference a pointer with p[0].

  5. Minus and arrow: -> is lexed as one token, so - followed by > with no space is a member access, not subtraction followed by >.


Full opcode reference

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

Examples

Hello, World!

"Hello, World!\n"

Assign and print

99 0 &      # vars[0] = 99
|0 |        # print vars[0]

Float literals

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

Block-form IF with ELSE

7 5 11          # push (7 > 5) = 1
20
    "7 is greater than 5\n"
28
    "7 is not greater than 5\n"
;

Nested IF-ELSE (FizzBuzz logic)

|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
    ;
;

WHILE countdown

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
;

REPEAT loop (print 0 to 4)

5 50
    |               # print iteration index
;

Bitwise masking

255 15 40 |         # 255 AND 15 = 15 (lower nibble)
1 8 44  |           # 1 << 8 = 256

Functions

/0
    "Hello from function 0!\n"
;

.0

Recursion — power of 2

# 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

Project layout

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

Support

If you find this project useful, consider supporting its development:

Ko-fi GitHub Sponsors

Star History

Star History Chart

About

an esoteric stack-based language that compiles to C

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages