Getting started
Tinoc is a systems programming language that transpiles to C11. Write .tnc source, emit portable C, and build it with any C11 toolchain.
Tinoc respects C and its usage. It is built on the philosophy that programming should be Meaningful, Accurate, Robust, Maximum Performance, and Simple — C11 unlocks features like _Generic dispatch in the tinoc.h runtime (saturating arithmetic) and gives the compiler room to grow (error unions, typed enums, generics) without fighting the target language.
v0.1.0 is out. Install the latest release binary into ~/.tinoc — no root needed — with the one-line installers on the install page.
Hello, world
main.tnc
#import std.io;
// Main function
fn main() void {
var name str = "Prathmesh";
const lang = "Tinoc";
io.println("{s} is creator of {s} Programming Language!", name, lang);
}
What the compiler emits
main.tnc transpiles to readable C11 with a small runtime header. str becomes a plain struct, and io.println becomes printf.
main.c — emitted
#include <stdio.h>
#include <tinoc.h>
int main() {
str name = {"Prathmesh", 9};
const str lang = {"Tinoc", 5};
printf("%s is creator of %s Programming Language!\n", name.data, lang.data);
}
The pipeline is tinoc build main.tnc, and main.c goes to your C compiler. There is no runtime, VM, or garbage collector.
Language support
What the compiler supports end-to-end in the current release:
| Feature | Status | Notes |
var / const (incl. static) | Shipped | Explicit or inferred types; const-mutability enforced |
fn functions | Shipped | Forward calls, argument/return type checking, missing-return checks |
struct + methods | Shipped | Fields, struct literals, instance/static methods, by-value copy/params/returns |
enum + methods | Shipped | Fieldless variants and tagged unions with payloads; exhaustive switch with pattern binding |
switch | Shipped | Enum variants (exhaustive, no _ needed), integer/char literals, _ default arm; pattern-binding arms |
| Pointers | Shipped | ^T, &x, x^, pointers to structs/enums/unions, self^.x → self->x |
| Control flow | Shipped | if / else if / else, while, for 0..10 |i|, break, continue |
| Literals & operators | Shipped | Integer (all bases, _ separators), float, string, char, bool; arithmetic/comparison/logical/bitwise; content-based str ==/!= |
| C interop | Shipped | #importc "header.h" as alias; (clang/gcc parsing) and extern "C" fn declarations |
Modules & #import | Shipped | module keyword, pub visibility, all import forms, import caching + cycle detection, single merged C output with mangled names |
| Generic functions / structs / aliases | Shipped | fn foo:T, struct Pair:T, alias Opt:T = ?T; — monomorphized per concrete type-argument set, across module boundaries |
union + methods | Shipped | C-style shared-memory fields, instance/static methods, generic unions |
| Arrays / slices | Shipped | [N]T, [_]T, []T, [N:x]T sentinel arrays, array literals, indexing, .len, for coll |x|, implicit array → slice |
| Optionals | Shipped | ?T, null, orelse, x?, == null / != null; plain values auto-wrap where a ?T is expected |
| Error unions | Planned | !T, catch — planned |
Standard library (std.io, …) | Planned | User modules are in; the std library ships when the language matures |
See the changelog for what landed in each version.
Variables
A variable can be mutated.
Syntax
var <identifier> <type>; // Decl only, with explicit type.
var <identifier> <type> = <expression>; // Decl + init, explicit type.
var <identifier> = <expression>; // Decl + init, inferred.
Example
main.tnc
fn main() void {
var name str = "Lucifer";
name = "Julius";
var number = 101; // inferred as i32
number += 10;
var isAdmin = false; // inferred as bool
isAdmin = true;
}
Constants
A constant cannot be mutated.
Syntax
const <identifier> <type>; // Decl only, with explicit type.
const <identifier> <type> = <expression>; // Decl + init, explicit type.
const <identifier> = <expression>; // Decl + init, inferred.
Example
main.tnc
fn main() void {
const lang str = "Tinoc";
const codename = "C^";
// lang = "Tinoc Is Not C"
// Won't work — compile error.
}
Integers
Integer literals
literals.tnc
const decimal_int = 98222;
const hex_int = 0xff;
const another_hex_int = 0xFF;
const octal_int = 0o755;
const binary_int = 0b11110000;
// underscores may sit between two digits as a visual separator
const one_billion = 1_000_000_000;
const binary_mask = 0b1_1111_1111;
const permissions = 0o7_5_5;
const big_address = 0xFF80_0000_0000_0000;
The compiler infers the width of each integer literal. It defaults to i32 and widens to i64 or i128 when the value requires it.
For values not known at compile time, add the type explicitly:
divide.tnc
fn divide(a i32, b i32) i32 {
return a / b;
}
Floats
Float literals
literals.tnc
const floating_point = 123.0E+77;
const another_float = 123.0;
const yet_another = 123.0e+77;
const hex_floating_point = 0x103.70p-5;
const another_hex_float = 0x103.70;
const yet_another_hex_float = 0x103.70P-5;
// underscores may sit between two digits as a visual separator
const lightspeed = 299_792_458.000_000;
const nanosecond = 0.000_000_001;
const more_hex = 0x1234_5678.9ABC_CDEFp-10;
Tinoc has no special syntax for NaN, infinity, or negative infinity. Use std.math for these values.
math.tnc
#import std.math;
const inf = math.inf();
const negative_inf = - math.inf();
const nan = math.nan();
Operators
Operators, their syntax, and their behavior:
| Name | Syntax | Notes |
| Addition | a + b · a += b | Integers & floats; can overflow |
| Wrapping Addition | a +% b · a +%= b | Wraps on overflow |
| Saturating Addition | a +| b · a +|= b | Clamps on overflow |
| Subtraction | a - b · a -= b | Integers & floats |
| Wrapping Subtraction | a -% b · a -%= b | Wraps on underflow |
| Saturating Subtraction | a -| b · a -|= b | Clamps on underflow |
| Negation | -a | Two's-complement negation |
| Wrapping Negation | -%a | Wraps on overflow |
| Multiplication | a * b · a *= b | Integers & floats |
| Wrapping Multiplication | a *% b · a *%= b | Wraps on overflow |
| Saturating Multiplication | a *| b · a *|= b | Clamps on overflow |
| Division | a / b · a /= b | Integers & floats |
| Remainder Division | a % b · a %= b | Modulo |
| Bit Shift Left | a << b · a <<= b | Shift left |
| Saturating Bit Shift Left | a <<| b · a <<|= b | Clamps shifted-out bits |
| Bit Shift Right | a >> b · a >>= b | Shift right |
| Bitwise And | a & b · a &= b | Bitwise AND |
| Bitwise Or | a | b · a |= b | Bitwise OR |
| Bitwise Xor | a ^ b · a ^= b | Bitwise XOR |
| Bitwise Not | ~a | Bitwise complement |
| Defaulting Optional Unwrap | a orelse b | Use b when a is null; result type is T |
| Optional Unwrap | a? | Read the payload — check a != null first |
| Defaulting Error Unwrap | a catch b · a catch |err| b | Handle errors with a default — planned |
| Logical And | a and b | Short-circuit AND |
| Logical Or | a or b | Short-circuit OR |
| Logical Not | !a | Negate a boolean |
| Equality | a == b | Equal |
| Null Check | a == null | Optional is empty |
| Inequality | a != b | Not equal |
| Non-Null Check | a != null | Optional has a value |
| Greater Than | a > b | Ordering |
| Greater or Equal | a >= b | Ordering |
| Less Than | a < b | Ordering |
| Lesser or Equal | a <= b | Ordering |
| Array Multiplication | a ** b | Array repetition |
| Pointer Dereference | a^ | Read through a pointer |
| Address Of | &a | Take a pointer |
| Error Set Merge | a || b | Merge error sets — planned |
Precedence
From highest to lowest binding:
x() x[] x.y x^ x? a!b x{} !x -x -%x ~x &x ?x * / % ** *% *| || + - ++ +% -% +| -|
<< >> <<| & ^ | orelse catch == != < > <= >= and or = *= *%= *|= /= %= += +%= +|= -= -%= -|= <<= <<|= >>= &= ^= |=
Functions
A function is a reusable block of code. Tinoc supports plain functions and functions with one or more generic parameters.
Syntax
fn <identifier>() <type> {…}
fn <identifier>(<param> <type>, …) <type> {…}
fn <identifier>:T() <type> {…}
fn <identifier>:T(<param> <type>, …) <type> {…}
fn <identifier>:(T, …)() <type> {…}
fn <identifier>:(T, …)(<param> <type>, …) <type> {…}
Calling
<identifier>();
<identifier>(value, …);
<identifier>:T();
// and the same for the other forms…
Example
main.tnc
#import std.io;
fn add(a i8, b i8) i8 {
return a + b;
}
fn greet() void {
io.println("Hi");
}
fn Identity:T(val T) T {
return val;
}
fn main() void {
const result = add(10, 25);
greet();
io.println("result = {d}", result);
io.println("{any}", Identity:str("Tinoc"));
}
Types
Tinoc's type system covers primitives, compounds, pointer, optional, and error types, plus heap-allocated collections.
Integer types
| Tinoc | C equivalent | Category |
u8 | uint8_t | Unsigned integer |
u16 | uint16_t | Unsigned integer |
u32 | uint32_t | Unsigned integer |
u64 | uint64_t | Unsigned integer |
u128 | __uint128_t (GCC/Clang) | Unsigned integer |
usize | size_t | Unsigned, platform-width |
i8 | int8_t | Signed integer |
i16 | int16_t | Signed integer |
i32 | int32_t | Signed integer |
i64 | int64_t | Signed integer |
i128 | __int128_t (GCC/Clang) | Signed integer |
isize | ptrdiff_t | Signed, platform-width |
Floating-point types
| Tinoc | C equivalent | Category |
f32 | float | Float |
f64 | double | Float |
f128 | __float128 (GCC/Clang) | Float |
Core primitives
| Tinoc | C equivalent | Category |
bool | _Bool / stdbool.h | Boolean |
char | uint32_t | Unicode codepoint |
void | void | Void |
String
str has no direct C primitive. It is a plain struct:
| Tinoc | C equivalent | Category |
str | struct { const char* data; size_t len; } | String (struct) |
Arrays & slices
| Tinoc | C equivalent | Category |
[N]T | T arr[N] | Fixed-size array |
[_]T | T arr[N] (size inferred) | Inferred-size array |
[]T | struct { T* ptr; size_t len; } | Slice (fat pointer) |
Pointer
| Tinoc | C equivalent | Category |
^T | T* | Pointer |
Optional
An optional is a nullable wrapper, not a raw pointer. The compiler emits one named typedef per optional type used (?i32 → tnc_opt_i32), so every declaration of the same optional type shares one C struct type:
| Tinoc | C equivalent | Category |
?T | struct { T value; bool has_value; } | Optional (nullable wrapper) |
Error unions
Planned Errors will be first-class values. A result carries the value or the error. The representation is documented here ahead of implementation:
| Tinoc | C equivalent | Category |
!T | struct { T value; int err; bool is_err; } | Inferred error union |
E!T | struct { T value; E err; bool is_err; } | Explicit error union |
Compounds
| Tinoc | C equivalent | Category |
struct | struct | Compound |
enum | enum + int | Compound |
union | union | Compound |
Heap collections (std.collections)
Generic library types with no single C equivalent; they use :T and :(K, V) syntax:
| Tinoc | C equivalent | Category |
hstr | None — heap-managed str | Heap string |
vec:T | None — dynamic array | Heap collection |
map:(K, V) | None — hash map | Heap collection |
set:T | None — hash set | Heap collection |
Optionals
An optional wraps a payload of type T with a has_value flag: a ?T is either empty (null) or holds a T.
Syntax
var maybe ?i32 = 42; // wrap a payload
var none ?i32 = null; // the empty optional
maybe orelse 0 // payload when some, else the fallback (result type is T)
maybe? // unwrap: the payload (only after a != null check)
maybe == null // presence checks
maybe != null
Example
optionals.tnc
fn index_of(hay []i32, needle i32) ?i32 {
var i i32 = 0;
while i < hay.len {
if hay[i] == needle {
return i; // plain i32 auto-wraps into ?i32
}
i += 1;
}
return null;
}
fn main() void {
var maybe ?i32 = 42;
var none ?i32 = null;
if none == null {
var v = maybe orelse 0; // 42 — v is an i32
var u = maybe?; // 42
}
var nums [4]i32 = [10, 20, 30, 40];
var s []i32 = nums;
var d = index_of(s, 99) orelse -1; // -1
}
- A plain
T value — or null — auto-wraps into a ?T wherever one is expected: initializers, call arguments, return values, and assignments. Untyped numeric literals adapt to the payload type (var x ?f64 = 5;).
orelse short-circuits: the fallback is only evaluated when the optional is null. Its result type is the payload type T.
maybe? reads the payload; unwrapping an empty optional reads the wrapped value field (garbage, not a crash) — always check maybe != null first.
- Comparing an optional with anything but
null is rejected — use == null / != null. orelse requires an optional on the left and ? unwrap requires an optional. Optionals of arrays or void are rejected (use ?[]T for a nullable collection view).
Error unions
Planned Error unions (!T, catch) are next on the roadmap. User modules are in today; the standard library ships when the language matures.
When they land, a result will carry either the value or the error, handled with a catch b or a catch |err| b. The planned C representation is a tagged struct:
result_T — planned representation
typedef struct {
T value;
E err; // error code/enum
bool is_err;
} result_T;
Track progress in the issue tracker.
Structs
A struct is a compound type with fields, methods, and static methods.
Syntax
struct <identifier> {
<field> <type>;
…
fn <identifier>(self ^<type-of-struct>, …) <type> {…}
static fn <identifier>() <type> {…}
static fn <identifier>(<param> <type>, …) <type> {…}
}
Example
main.tnc
#import std.io;
// simple struct with method
struct Point {
x f32;
y f32;
fn translate(self ^Point, dx f32, dy f32) void {
self^.x += dx;
self^.y += dy;
}
fn length(self ^Point) f32 {
return sqrt(self^.x * self^.x + self^.y * self^.y);
}
}
// generic struct with method
struct Pair:T {
first T;
second T;
fn swap(self ^Pair:T) void {
var tmp T = self^.first;
self^.first = self^.second;
self^.second = tmp;
}
}
// multi-param generic struct
struct Map:(K, V) {
key K;
value V;
}
fn main() void {
var p Point = Point { .x = 1.0, .y = 2.0 };
p.translate(0.5, 1.5);
var pair Pair:i32 = Pair:i32 { .first = 10, .second = 20 };
pair.swap();
io.println("{any}", p);
}
Enums
Enums are first-class and can carry data, methods, and static methods.
Example
main.tnc
#import std.{io, collection.*};
enum Direction {
North, East, South, West,
}
// enum with data and method
enum Shape {
Circle(f32), // radius
Rect(f32, f32), // width, height
Point,
fn area(self ^Shape) f32 {
// match on self variant
}
}
enum Literal {
String(hstr),
Integer(usize),
// …
// enums can also have methods and static methods, like structs
}
enum Something:T {
First,
Second(T),
}
fn main() void {
const direction1 = Direction.North;
io.println("{any}", direction1);
const literal1 = Literal.String(hstr.from("String Literal"));
io.println("{any}", literal1); // prints: String("String Literal")
var s Shape = Shape.Circle(5.0);
var a f32 = s.area();
}
- Fieldless variants compile to plain C enums; variants with payloads become tagged unions (tag + per-variant anonymous struct), so multi-field payloads never overlap.
switch over an enum is exhaustive — list every variant and no _ arm is needed; pattern binding (Shape.Rect(w, h) =>) binds payload fields directly.
- Enums support instance methods (
self ^Name), static methods, equality, and being passed/returned like any other type.
Unions
A union overlays multiple views over the same memory. Unions can have methods and generic parameters.
Syntax
union <Name> { … } // simple union with methods
union <Name>:<T> { … } // generic union with methods
union <Name>:(<T>, <U>, …) { … } // multi-param generic union
Example
main.tnc
// simple union with method
union Data {
as_int i32;
as_float f32;
as_bytes [4]u8;
fn zero(self ^Data) void {
self^.as_int = 0;
}
}
// generic union with method
union Either:T {
value T;
raw u64;
fn clear(self ^Either:T) void {
self^.raw = 0;
}
}
// multi-param generic union
union OneOf:(A, B) {
a A;
b B;
}
fn main() void {
var d Data;
d.as_int = 42;
d.zero();
// d.as_float now reads the same memory as f32
}
- C-style shared memory: writing
as_int and reading as_float reinterprets the same bytes — IEEE-754 punning works end-to-end.
- Unions reuse struct field syntax and support instance methods (
self ^Name) and static methods.
- Comparisons and arithmetic on unions are rejected with clear diagnostics.
Arrays & slices
An array is a fixed-size, contiguous sequence of elements of type T. Its length is part of the type and known at compile time.
Syntax
[<length>]<T> // fixed-size array, e.g. [5]u8
[_]<T> // size inferred from the initializer, e.g. [_]f32
[<length>:<sentinel>]<T> // sentinel-terminated array, e.g. [_:0]u8
[<elem>, <elem>, …] // array literal; element type is inferred
Example
arrays.tnc
// array literal with inferred element type
const message = ['h', 'e', 'l', 'l', 'o'];
// alternative initialization using a result location
const alt_message [5]u8 = ['h', 'e', 'l', 'l', 'o'];
// explicit element type, size inferred from the literal
var floats [_]f32 = [1.5, 2.5, 3.5];
// element access and assignment
if message[0] != 'h' { ... }
message[4] = 'x';
// .len is the element count (compile-time constant for arrays)
if message.len != 5 { ... }
// iterate over the elements
var total i32 = 0;
for message |ch| {
total += ch;
}
Slices
A slice []T is a fat pointer — a { ptr: ^T, len: usize } pair that views a contiguous region of memory, typically an array.
slices.tnc
var nums [5]i32 = [1, 2, 3, 4, 5];
var s []i32 = nums; // array converts implicitly to a slice view
if s.len != 5 { ... } // runtime length
s[2] = 30; // writes through to the backing array
fn total(s []i32) i32 { ... } // functions take slices, not arrays
- Arrays convert implicitly to slices at call sites and in assignments; the slice aliases the array's storage.
- Functions cannot take or return array values — the compiler rejects them and points you to slices:
array parameter a is not supported ([3]i32) — use a slice ([]i32) instead.
- Assigning a whole array is rejected for the same reason; copy element-wise instead. Iterate with the collection form:
for s |v| { ... }.
- Slice types are emitted as named typedefs (
tnc_slice_i32) so prototypes and definitions share one C type.
Multidimensional arrays
Nested [N]T types build row-major matrices: [4][5]f32 is 4 rows of 5 f32. A row (mat[i]) is itself an array, so iterate row-by-row:
matrix.tnc
const mat4x5 [4][5]f32 = [
[1.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0, 9.9],
];
if mat4x5[2][3] != 0.0 { ... }
var total f32 = 0.0;
for 0..mat4x5.len |i| {
for mat4x5[i] |cell| {
total += cell;
}
}
.len on a multidimensional array is the number of rows.
- The compiler rejects iterating a multidimensional array directly (its element is itself an array) — iterate a row instead.
Sentinel-terminated arrays
The syntax [N:x]T describes an array with a sentinel element of value x at the index corresponding to length N. [N:x]T stores N + 1 elements (the sentinel sits at index N), matching C string conventions.
sentinel.tnc
#import std.testing.expectEqual;
test "0-terminated sentinel array" {
const array [_:0]u8 = [1, 2, 3, 4];
try expectEqual([4:0]u8, @TypeOf(array));
try expectEqual(4, array.len);
try expectEqual(0, array[4]);
}
Destructuring arrays is still being designed. Progress is tracked in the issue tracker.
Switch
switch matches on integer or enum values. There are no parentheses and no fallthrough.
Syntax
switch <expression> {
<value> => { … }
<value> => { … }
_ => { … } // default case
}
Example
main.tnc
enum TokenKind {
Int,
Ident,
Plus,
Eof,
}
fn main() void {
var tok TokenKind = TokenKind.Plus;
switch tok {
TokenKind.Int => { /* handle int */ }
TokenKind.Ident => { /* handle ident */ }
TokenKind.Plus => { /* handle plus */ }
_ => { /* default */ }
}
var x i32 = 2;
switch x {
1 => { /* one */ }
2 => { /* two */ }
_ => { /* anything else */ }
}
}
- No parentheses around the expression.
_ is the default case.
- No fallthrough; each arm is independent.
- Braces are required per arm.
- Enum switches are exhaustive: list every variant and no
_ arm is needed. Tagged-union payloads bind with patterns — Shape.Rect(w, h) => { ... }.
If / Else
Conditional branching with no parentheses around the condition.
Syntax
if <condition> { … } // simple if
if <condition> { … } else { … } // if / else
if <condition> { … } else if <condition> { … } else { … } // chain
Example
main.tnc
fn main() void {
var x i32 = 10;
if x > 0 {
// positive
}
if x > 0 {
// positive
} else {
// zero or negative
}
if x > 0 {
// positive
} else if x == 0 {
// zero
} else {
// negative
}
}
- No parentheses around the condition.
- Braces are required; there is no single-line braceless form.
For
Iterate over ranges and collections with a capture binding.
Syntax
for <start>..<end> |<i>| { … } // range, capturing index
for <collection> |<item>| { … } // collection, capturing item
Example
main.tnc
fn main() void {
// range loop — 0 to 9
for 0..10 |i| {
// i goes 0, 1, 2, … 9
}
// range with a step is done with while, if needed
var i i32 = 0;
while i < 10 {
i += 2;
}
// iterate over a slice
var nums [5]i32 = [1, 2, 3, 4, 5];
for nums |n| {
// n is each element
}
}
- No parentheses around the range or collection.
|i| captures the loop variable. It is a binding, not a closure.
- Range
0..10 is exclusive on the right: it iterates 0 through 9.
break exits; continue skips to the next iteration.
While
Condition-based loops. while true covers both the infinite loop and do-while patterns.
Syntax
while <condition> { … } // loop while condition is true
while true { … } // infinite loop
Example
main.tnc
fn main() void {
var i i32 = 0;
// normal while
while i < 10 {
i += 1;
}
// infinite loop — break to exit
while true {
if i == 20 {
break;
}
i += 1;
}
// do-while equivalent — run the body first, check at the end
while true {
i += 1;
if i >= 5 {
break;
}
}
}
- No parentheses around the condition.
- Braces are required.
while true replaces both infinite loops and do-while patterns from C.
break exits; continue skips to the next iteration.
Modules
#import resolves modules semantically, replacing C #include: modules are parsed and type-checked once, only pub exports are visible to importers, and every loaded module compiles into a single merged C translation unit — no text insertion, no header guards.
The module keyword
module name; names the file's module. A file without a declaration gets its module name from its file stem (vec.tnc → module vec), and convention files mod.tnc / index.tnc take their containing directory's name (shapes/mod.tnc → module shapes) — directories become modules automatically.
math.tnc
module math; // this file is the module `math`; importers say: #import math;
pub const PI f64 = 3.14159;
const TAU f64 = 6.28318; // private: only visible inside this module
pub fn square(x f64) f64 {
return x * x;
}
module name { ... } groups declarations into an in-file namespace — members are reachable as name.member from the rest of the file. Blocks nest (module a { module b { ... } } → a.b.member) and accept dotted names (module a.b { ... }).
physics.tnc
module physics {
pub const g f64 = 9.81;
pub fn energy(m f64, v f64) f64 {
return 0.5 * m * v * v;
}
}
fn main() void {
// physics.energy / physics.g usable here
}
Import forms
#import module; // namespace import -> module.symbol
#import module.sub; // nested path / submodule
#import module.*; // wildcard: every pub symbol, usable bare
#import module.symbol; // single symbol, usable directly
#import module.{a, b as c}; // selected symbols, per-symbol aliases
#import module as alias; // rename the namespace
#import "rel/path.tnc" as alias; // file import (module name from file)
Whether a bare dotted tail (math.PI) is a submodule or a single symbol is decided by the module loader: it tries a module file first (math/PI.tnc, math/PI/mod.tnc), then a pub symbol of the prefix module (math). The {...} and .* forms are unambiguous. module.symbol as name renames a single-symbol import; #import module as m; renames the namespace.
Example
shapes/vec.tnc — the submodule shapes.vec
pub struct Vec2 {
x f32;
y f32;
}
pub fn dot(a Vec2, b Vec2) f32 {
return a.x * b.x + a.y * b.y;
}
main.tnc
#import math;
#import math.PI; // single symbol -> PI
#import math.E as EULER; // single symbol, renamed
#import shapes.vec; // namespace -> vec.Vec2 / vec.dot
#import shapes.vec.{Vec2, dot}; // selected symbols, usable directly
#import "shapes/vec.tnc" as vf; // file import under an alias
#import shapes.*; // wildcard -> circumference(...)
Visibility
pub marks a function, struct, enum, union, const, var, or alias as exported from its module; everything else is private and rejected when imported — symbol x is private to module math for a private member, module math has no public symbol x for a missing one. In-file module name { ... } blocks expose every member (they are the same file's namespace).
Modules & generics
Generic declarations (fn name:T, struct Name:T, alias Name:T = ...;, and the multi-param :(K, V) forms) cross module boundaries like any other pub symbol:
#import math; // qualified use: math.identity:i32(42)
#import math.identity; // bare use: identity:i32(42) or inferred identity(7)
#import math.identity as id; // renamed: id:str("hi")
#import math.{Pair}; // bare struct template: Pair:f64 { .first = 1.0, ... }
#import box.Opt; // bare alias template: Opt:i32
#import math.*; // wildcard binds every pub generic bare
A bare instantiation and a qualified call of the same generic produce one shared mangled C instance (identity:i32(42) and math.identity:i32(42) emit a single tnc_math_identity_i32), so an imported generic is never duplicated. Each concrete instantiation is monomorphized once per type-argument set, and generic bodies are checked in the defining module's scope — module-private helpers and consts resolve exactly as the template author wrote them.
Module resolution rules
- Imports resolve relative to the importing file's directory:
#import a.b.c; looks for a/b/c.tnc, then a/b/c/mod.tnc, then a/b/c/c.tnc.
- Modules load recursively (imports of imports) with cycle detection (
import cycle detected: a -> b -> a) and are cached by absolute path — diamond imports share one instance.
- Re-importing the same module under the same local name is idempotent; binding a different module under a taken name is an error.
- Two files claiming the same module name collide (
module math is already defined by ...), since their items would mangle to the same C symbols.
- The standard library is not available yet:
#import std.io; is rejected with standard library modules are not yet available (std.io) — user modules only for now.
- Every module's code is merged into one C translation unit; module items get mangled C names (
math.abs → tnc_math_abs) so same-named items across modules never collide.
Preprocessor
Directives are marked with a # prefix and run at compile time.
| Directive | Purpose |
#import | Compile-time module resolution. Pass the main file (e.g. main.tnc) and the compiler loads, checks, and merges every transitively imported module. |
#importc | C header import. #importc "stdio.h" "math.h" as c; parses real C headers (clang's JSON AST, gcc's -aux-info fallback) and exposes their functions, extern variables, enum constants, typedefs, and simple macros under the alias with full type checking. See C interop. |
extern "C" fn | Hand-declared C function declarations (no header parsing). See C interop. |
#run | Compile-time execution as an expression or block. Useful for metaprogramming. |
#partial | Tells the compiler about a partial implementation of a switch on an enum. |
Other useful preprocessor directives may be added in future versions.
C interop
Tinoc talks to C two ways: #importc parses real headers, extern "C" fn declares functions by hand. Both give you type-checked calls into libc and any other C library.
#importc — import C headers
#importc "stdio.h"; // alias defaults to the file stem: stdio
#importc "stdio.h" "math.h" as c; // multiple headers, explicit alias
#importc "mylib.h" as mylib; // local header next to the source
The compiler parses each header and registers every function, extern variable, enum constant, typedef, and object-like macro under the alias:
cio.tnc
#importc "stdio.h" as cio;
fn main() void {
cio.printf("%.1f\n", 3.14); // full argument type/count checking
var code i32 = cio.EOF; // -1 (macro constant)
}
Codegen emits one #include per header into the merged output, so the declarations resolve at C compile time. Type safety is enforced at the Tinoc level first — unknown members (undefined: cio.doesNotExist) and wrong argument counts/types are caught before C ever runs.
extern "C" fn — hand-declared C functions
extern "C" fn printf(fmt *const char, ...) i32;
extern "C" fn strlen(s *const char) usize;
extern "C" fn my_puts.puts(s *const char) i32; // call my_puts, C symbol puts
Declarations must end with ; (no body); variadic declarations need at least one named parameter before .... str arguments are unwrapped to their underlying char* automatically, so printf("%s\n", lang) passes the string's data pointer.