| name | nolang-syntax |
| description | Reference for Nolang programming language syntax. Use when working with `.no` files, writing Nolang code, or when the user asks about Nolang syntax, grammar, types, operators, or language features. |
Nolang Syntax Reference
Table of Contents
Introduction
Nolang is an experimental systems programming language that adopts a pass-by-reference model and a safe scope model to achieve absolute memory safety. No GC.
Core Features
- Developer-friendly: No pointers, no ownership, no lifetimes...
- Pass by reference: All function parameters are references; functions return results by modifying parameters
- Automatic memory management: Through the safe scope model, memory is automatically freed when leaving scope; no dangling pointers or memory leaks
- No GC: No memory leak issues, so GC is unnecessary
- Performance-first: Small strings require no heap allocation; variables can be allocated once and freed once
- Method overloading: Achieves high performance through monomorphization
- Interfaces: Supports interface function declarations, default function implementations, and multiple interface inheritance
- Generics: Supports type and numeric generics
- Match: Unique match design, simpler to use
Quick Start
// Hello, World!
// No main entry needed
print('Hello, Nolang!')
// Variable declaration
i64
// Function definition
add = (a i64, b i64) (result i64) {
result = a + b
}
// Standard library methods can be called directly
c = math.max(a, b)
// Struct
user {
name str
age i64
}
u = user {
name: 'Alice'
age: 30
}
// Method
user.greet = () {
print('Hello, ' - .name)
}
u.greet()
Installation & Usage
Install CLI
Download the executable for your platform from GitHub Releases, or install using the following method:
sudo mv nolang /usr/local/bin/no
Install VS Code Extension
Install the Nolang extension from VS Code Marketplace, which provides syntax highlighting, LSP diagnostics, go-to-definition, auto-completion, and more.
CLI Commands
| Command | Description |
|---|
no version | Print version info |
no init | Initialize repo in current directory |
no new <name> | Create new repo |
no fmt [-w] [-d] <file|dir> | Format source code |
no build [-o <file>] [-cc <s>] [-target <s>] [<file|dir>] | Build (outputs executable) |
no run [-cc <s>] [-target <s>] [<file|dir>] | Build and run main.no |
no test [-cc <s>] [-target <s>] [<file>] | Run tests |
no add <pkg> | Add dependency |
no remove <pkg> | Remove dependency |
no update <pkg> | Update dependency |
no update-all | Update all dependencies |
no list | List dependencies |
no sync | Sync dependencies |
no install [-u] [<pkg>@<version>] | Install binary |
no uninstall <name> | Remove binary |
no pub --token <token> [--registry <url>] | Publish to registry |
Create New Project
no new test1
cd test1
no run
Build & Run
no build
no build main.no
no build -o output
no build -cc zig
no build -target x86_64-linux-gnu
no run
no run -cc zig
no run -target aarch64-macos-gnu
Cross-compilation Targets
The -target parameter format is <arch>-<os>-<abi>, supporting the following targets:
| Target triple | Description |
|---|
x86_64-linux-gnu | Linux x86_64 |
aarch64-linux-gnu | Linux ARM64 |
x86_64-macos-gnu | macOS x86_64 |
aarch64-macos-gnu | macOS ARM64 |
x86_64-windows-gnu | Windows x86_64 |
Automatic platform detection: no build, no run, and no test automatically detect the current host platform and compile for the native target when -target is not specified. No manual target specification needed for daily development:
no run hello.no
no test
no build -target aarch64-linux-gnu
Compiler Selection
The -cc parameter specifies the C compiler backend:
clang (default) — requires LLVM installed
zig — requires Zig installed, suitable for cross-compilation
Entry Rules
- main.no — Program entry point
- lib.no — Library entry, exports functions (see Export System)
- All .no files under test/ directory — Contain test assertions
Testing
no test
no test my-test.no
no test -cc zig
no test -target x86_64-windows-gnu
Testing notes:
- Test files are placed in the test/ directory
- Each test file is built independently
- If any test fails, a non-zero exit code is returned
Install & Uninstall Binary
no install
no install -u
no install pkg-name@1.0
Installation process:
- Download package source (remote packages) or use current directory (local packages)
- Automatically execute build
- Copy binary to
~/no/bin/
- Create a symlink in
/usr/local/bin/
no uninstall pkg-name
Project Configuration
The mod.jsonc file in the project root directory describes project information:
{
"name": "my-project",
"version": "0.1.0",
"description": "A new Nolang project",
"keywords": [],
"author": "",
"email": "",
"organization": "",
"repository": "",
"homepage": "",
"license": "MIT",
"workspace": "",
"mirrors": [],
"dependencies": {
"fmt": "*",
},
"compiler": {
"version": "0.1.0",
},
"output": "./dist",
"ignore": [],
}
Dependency Management
no add pkg-name
no remove pkg-name
no update pkg-name
no update-all
no list
no sync
Mirror Configuration
Configure mirror addresses in the mirrors array of mod.jsonc to accelerate remote package downloads:
"mirrors": [
"https://mirror.example.com/"
]
Golden Rule: Do Not Modify Valid Code
Never modify valid, syntactically correct Nolang code — including identifiers, variable declarations, or any other language construct — even if you suspect a parser/compiler issue. If you encounter what appears to be a parsing or tooling error, file a bug report or inform the user; do not change the code.
Quick Reference
Data Types
Base types: byte, bool (lowercase only), char (character type / rune, double-quoted single character, e.g. "中"), str (string type, single-quoted 'hello', or raw string with backticks), i8, i16, i32, i64 (default numeric type, architecture-independent), u8, u16, u32, u64, usize (ffi only), f32, f64
Container types: obj (object), map (map), arr (fixed-length array [n]t), vec (variable-length array []t), slice (slice/view, no independent data structure, must be attached to arr/vec/str)
Special types: * (pointer, FFI #{c} declarations and standard library only), any (any type, standard library only)
Advanced types: bigint, err
Optional (nullable) types: prefix with ? — e.g. ?i64, ?str, ?[]str
Type Aliases & Union Types
Type aliases create a new name for an existing type. Use the equals syntax name = type, supporting single type aliases and multi-type unions.
// Union type: multiple types separated by |
int = i8 | i16 | i32 | i64 | u8 | u16 | u32 | u64
float = f32 | f64
num = int | float
// Single type alias
bytes = []byte
buf = [16]u8
Union types can reference other union types, forming a hierarchy. They can be used for function parameters and return values; the compiler automatically performs monomorphization, generating a separate function version for each member type.
// Parameter type is num union
max = (a ..num) (r num) {
r = a[0]
n = len(a)
i <- [1..n): {
a[i] > r -> r = a[i]
}
}
Detection rules — The equals syntax is recognized as a type alias (not a variable assignment) in the following cases:
name = type | type | ...: Union type (contains |)
name = []type: Slice type
name = [N]type: Array type
name = ?type: Optional type
name = known-type: Single type alias, where known-type is a built-in type name or a previously defined type alias name
Variables
// i64 (default), f64, byte, bool, str can omit type annotation
i = 1
f = 1.0
b = 0x00
name = 'nolang'
flag = true
// Char literal (double-quoted, single rune)
c = "中"
a = "A"
// Raw string (backtick-delimited, multi-line, no escape processing)
sql = `
SELECT id,name
FROM user
WHERE id > 100
`
// Regex literal (JS-style /pattern/flags)
re = /\d+/
re = /hello/gi
re = /[a-z]+/
re = /a\/b/
// Explicit type annotation
a u64 = 10
// If variable name matches type, type annotation can be omitted
i8 = 3
// Default zero value, variable definition does not need prior declaration
u16
// Arr
arr [3] = [1, 2, 3] // i64 array
typed [3]u16 = [1, 2, 3] // typed
a [?]u16 = [1, 2, 3] // auto-inferred length
// Vec
typed []u8 = [1, 2, 3]
// String concatenation uses '-'
greeting = 'hello, ' - name
Regex Literals
Nolang supports JavaScript-style regex literals /pattern/flags, which create a compiled regexp instance. The / is disambiguated from division by context-sensitive lexing (same as JavaScript):
- After expression-starting tokens (statement beginning,
= / ( / [ / { / , / : / ; / keywords like return / if) → / starts a regex
- After value-producing tokens (
IDENT, INT, STRING, ) / ] / } etc.) → / is division
// is always a line comment (highest priority)
- After
# (use) / @ (export) directives → / is a path separator
// Regex literal (expression-start position after '=')
re = /\d+/
re2 = /hello/gi
result = match-text(/[a-z]+/, text)
// Division (value-producing position after identifier/literal)
ratio = 100 / 4
x = a / b
Regex literals desugar at codegen into a call to the standard library regexp-compile function (defined in std/regexp.no):
// source
re = /\d+/
// desugars to
re = regexp-compile('\\d+')
Flags (optional, after closing /): g (global), i (case-insensitive), m (multiline), s (dot matches newline). Empty pattern // collides with line comments — use /(?:)/ for an empty match.
Comments
Nolang supports three single-line comment markers and one multi-line (block) comment marker:
// — traditional single-line marker (comments to end-of-line)
; — single-line marker (comments to end-of-line)
;; <content> — single-line marker (when ;; is followed by content on the same line, comments to end-of-line; same semantics as ;)
;;\n — multi-line (block) comment: when ;; is immediately followed by a newline (only whitespace allowed in between), it enters multi-line mode until another ;; followed by a newline/EOF is encountered
// this is a comment
; this is also a comment, same semantics
;; this is still a single-line comment (no newline after ;;)
x = 1 ; trailing comment, runs to end of line
x = 2 ;; inline single-line comment, same semantics
;;
this is a multi-line (block) comment
it can span multiple lines
until a standalone ;; is encountered
;;
y = 3
;;
the closing ;; must be followed by a newline or EOF
to be recognized as the ending delimiter
;;
One statement per line is still a hard rule — never use commas , to combine multiple statements on one line (semicolons are now comments, so ; can no longer join statements). This applies inside comments too.
; ❌ Wrong: commas combining multiple statements (inside a comment example)
; h0 = 1732584193, h1 = 4023233417
; ❌ Wrong: comma joining statements in real code
; out = from-i64(v), out = from-u64(v)
; ✅ Correct: each statement on its own line
; h0 = 1732584193
; h1 = 4023233417
Multi-line trigger rule: ;; must be followed by only whitespace (spaces/tabs) up to a newline or EOF to enter multi-line mode. If ;; is followed by any non-whitespace character on the same line, it is treated as a single-line comment (to end-of-line). The closing ;; must likewise be followed by a newline or EOF (only whitespace allowed in between). An unterminated multi-line comment runs to EOF.
Marker preservation: the formatter never converts between markers (; ↔ // ↔ ;;). It records the original marker (Comment.Marker) and emits it verbatim, so ; comments stay ;, // stay //, ;;\n ... ;; blocks stay intact (content, including internal newlines, preserved verbatim), and ;; single-line comments stay ;;. no fmt is idempotent for all forms.
Safety: ; / ;; inside string literals (e.g. 'text/plain; charset=utf-8', index-from(';', pos)) and inside // comments is consumed by the lexer's string/comment scanners and never treated as a comment marker.
Naming Rules
Variable names, function names, struct names, etc. can start with an underscore, followed by hyphens, letters, and digits; cannot start with a digit, cannot end with a hyphen, and cannot have consecutive hyphens.
Case rules (mandatory):
- Global constants/variables: MUST start with an uppercase letter (e.g.
NO-LANG, MAX-SIZE, HEX-CHARS). Private globals use underscore prefix followed by uppercase (e.g. _NO-LANG, _PRIVATE-CONST). This is a mandatory rule, not a convention. Lowercase top-level variables will be treated as locals by the compiler, causing undefined reference errors.
- Local variables, function parameters: lowercase (e.g.
hex-chars, data-len)
- Function names, struct names: lowercase (e.g.
sha1-block, db-mysql)
// ✅ Correct: global data uses uppercase
NO-LANG = 'nolang' // global constant, uppercase
MAX-SIZE = 1024 // global constant
HEX-CHARS = '0123456789abcdef'
// ✅ Private global: underscore prefix + uppercase
_NOLANG = 'nolang' // private global
_PRIVATE-CONST = 42 // private global constant
// ❌ Wrong: global variables must NOT use lowercase
// x = 10 // lowercase global — will cause errors
// foo-bar = 42 // lowercase global — will cause errors
// hello-world = 'Hello World' // lowercase global — will cause errors
// ✅ Local variables (inside functions) use lowercase
// example-fn = () {
// x = 10 // local variable, lowercase — correct
// foo-bar = 42 // local variable, lowercase — correct
// }
API Documentation Conventions
Function documentation comments should include full parameter names and types, return parameter names and types. The API summary at the top of a module should also use full signatures (including parameter names, types, return names, types), not abbreviated forms.
// ❌ Wrong: missing types, missing return names
// sha1(data) (hash)
// sha1-block(s, h0..h4)
// ✅ Correct: full param names, types, return names, types
// sha1(data []byte) (hash [20]byte) — full hash
// sha1-block(s []u32, h0 u32, h1 u32, h2 u32, h3 u32, h4 u32) — process single block
// Documentation comments above function definitions should follow the same convention:
// sha1: compute SHA-1 hash
// data []byte: input byte array
// returns hash [20]byte: 20-byte hash value
sha1 = (data []byte) (hash [20]byte) {
...
}
Prefer Standard Library
The Nolang standard library provides a rich set of common functionality, including string operations, byte conversions, hash computation, network communication, etc.
Rule: If the standard library already provides corresponding functionality, reimplementing it yourself is not recommended. Developers should carefully review the standard library documentation (see Standard Library Overview below) to avoid reinventing the wheel.
// ❌ Wrong: reimplementing str → []byte conversion
str-to-bytes = (s str) (out []byte) {
n = s.len
i = 0
{
out[i] = s[i]
i = i + 1
} (i < n)
}
// ✅ Correct: use standard library str.to-bytes() method
data []byte = s.to-bytes()
Common standard library replacements:
str.to-bytes() — string to byte array (replaces hand-written str-to-bytes)
[]byte.to-str() — byte array to string (replaces hand-written bytes-to-str)
[n]t.to-vec() — fixed array to slice ([20]byte → []byte)
[]byte.to-hex() / []byte.to-hex-lower() — byte array to hex string
str.to-i64() / str.to-f64() — string to number
int.to-str() / float.to-str() — number to string
std/hash/sha1, std/hash/sha256, std/hash/sha512 — hash computation
File Naming
.no filenames (including folder names) use hyphens - to join words, not underscores _.
This is consistent with the naming style of Nolang identifiers such as variable names, function names, and struct names.
✅ string-helper.no, hash-table.no, http-client.no
❌ string_helper.no, hash_table.no, http_client.no
Code Style
Trailing newline (EOF)
Every non-empty .no source file must end with exactly one trailing newline (i.e. one blank line at the end of the file).
- Files that do not end with a newline: a trailing newline is appended.
- Files that end with multiple blank lines: they are collapsed into a single trailing newline.
- Empty files (0 bytes) are left untouched.
Excluded directories: dist/, vscode-nolang/, node_modules/.
Rationale: a single, consistent EOF newline keeps git diff clean, avoids "no newline at end of file" warnings, and makes concatenation/tooling predictable.
The rule is enforced automatically by the toolchain — there is no separate
normalization script:
no fmt (src/cmd/no/main.go, the fmt subcommand) formats files in
place and calls fmt.FormatFile, which guarantees exactly one trailing
newline.
- LSP format-on-save /
textDocument/formatting (src/lsp/server.go,
formatNolangCode → fmt.FormatFile) appends/collapses the EOF newline
automatically when you save or format a .no file in the editor.
Implementation lives in src/fmt/formatter.go:
FormatFile(code) formats a complete file and calls ensureTrailingNewline,
which strips all trailing \r/\n (CRLF-safe, multi-blank-line-safe) and
appends a single \n. Empty or unparseable input is returned unchanged so
the formatter never mangles a file it cannot understand.
; / ;; are comment markers (implemented 2026-07-17 / 2026-07-18)
; is a line-comment marker — semantically identical to //, it comments
to end-of-line. The lexer turns ; into a COMMENT token (with Marker=";"),
the parser records Comment.Marker, and the formatter emits the original marker
verbatim.
;;\n ... ;; is a multi-line (block) comment — when ;; is immediately
followed by a newline (only whitespace allowed in between), it enters multi-line
mode. The closing ;; must likewise be followed by a newline or EOF. Everything
between (including newlines) is comment content. A single ; inside the content
does NOT close the block; only a ;; followed by newline/EOF does. If no closing
;; is found, the comment runs to EOF. If ;; is followed by non-whitespace on
the same line, it is a single-line comment (to end-of-line), semantically
identical to ;. The lexer emits one COMMENT token with Marker=";;block"
(multi-line) or Marker=";;" (single-line ;;); the formatter writes the
delimiters verbatim (idempotent). See Comments for the full rules.
Gotcha: cond -> X; Y no longer parses as assignment. With ; a comment, it
means "evaluate X (discard) then a trailing comment ; Y" — X is never
assigned. The correct form is cond -> X = Y (established stdlib pattern in
arr.no, uuid.no, path.no, err.no, assert.no). If you find
cond -> X; Y in source, replace ; with =; do not leave it as a comment.
; / ;; inside string literals (e.g. 'text/plain; charset=utf-8',
index-from(';', pos)) and inside // comments is safe — the lexer's string
and comment scanners consume it, so it never reaches the comment token.
Migration note: before ; became a comment, the repo's .no sources were
made ;-free at the grammar level (zip.no → []byte literals; ws.no 14×
cond -> X; Y → cond -> X = Y; test-tls-part1/2/3.no 15×
cond -> print('…'); return → cond -> { print('…') \n return }). With the
lexer now treating ; as a comment and ;; as a block comment, no real
grammatical ; remains, and the formatter preserves both ; and ;; … ;;
verbatim. Verify with no fmt <file>.
Format(code) is the pure fragment formatter (no trailing newline) used by
unit tests; prefer FormatFile whenever you write a real source file.
Functions
Functions pass results by modifying input parameters. Nolang functions have the following characteristics:
- Functions have no return value by default; all data interaction is through parameters only
- All function parameters are reference types; modifying parameters directly affects the caller's data
- Variables inside a function are automatically destroyed when the function exits
- Parameters with result annotation are writable output params
- Prefer
?t option over (val, ok bool) for functions that may fail or return empty
- Parameter default values: use
name type = expr syntax. Parameters with defaults can be omitted at the call site. Default parameters must be the last parameters.
System functions allow syntactic sugar return values for user convenience. Since the underlying mechanism still works through input parameters, no new variables are returned, making it internally safe.
add = (a i64, b i64) (result i64) {
result = a + b
}
// Default parameter value
parse-line = (s str, max-fields i64 = 1024) (fields []str) {
...
}
// Both calls are valid:
fields = csv.parse-line(line) // max-fields defaults to 1024
fields = csv.parse-line(line, 256) // max-fields = 256
// Variadic parameters
add3 = (a ..i64) {
}
// Function call
sum = add(1, 2) // sum == 3
// Anonymous function
(a i64) { print(a) }(10)
Option Style: Prefer ?t over (val, ok)
When a function may fail or return an empty value, prefer the ?t option type over the (val t, ok bool) dual-return pattern.
?t is a tagged enum with three states: ok (has value, implicitly bound), nil (empty), err (error). Normal values are implicitly bound. Use nil when the operation simply cannot find a value, and err(...) when the operation encounters an actual error.
// ❌ Wrong: dual-return pattern
stack.pop = () (val i64, ok bool) {
.n == 0 -> return
val = .data[.n]
ok = true
}
// ✅ Correct: option type (nil for empty, err for errors)
stack.pop = () (val ?i64) {
.n == 0 -> {
val = nil
return
}
val = .data[.n]
}
// ✅ Returning an error
file.read = () (data ?str) {
.fd < 0 -> {
data = err('file not open')
return
}
// ... read data
data = buf
}
Unwrap with match:
val = s.pop()
val: {
nil -> print('empty')
err -> print(it) // it = error message
-> print(it) // it = the value
}
Applicable scenarios:
pop / peek (container may be empty) → ?t (nil = empty)
read-line / read-byte (I/O may fail) → ?str / ?i64 (nil = EOF, err = error)
lookup / get (key may not exist) → ?t (nil = not found)
parse / from-str (input may be invalid) → ?t (nil = empty, err = invalid input)
accept / dial (connection may fail) → ?conn (nil = no connection, err = error)
nil vs err: use nil when the absence is a normal/expected outcome (empty stack, key not found, EOF); use err('msg') when the absence represents an actual error condition (I/O failure, invalid input, connection refused).
Exception: when a function needs to return multiple independent values (e.g. (name str, value str, ok bool)), the multi-return pattern is acceptable.
Methods on Union Types
Methods attached to a union type (e.g. int, float, num) use type.method = () (results) syntax.
The parser automatically adds a hidden self parameter with the receiver type, so you must not declare the receiver explicitly.
Definition:
// type aliases & union types — equals syntax
// name = type1 | type2 | ... — union of multiple types
// name = type — single type alias
int = i8 | i16 | i32 | i64 | u8 | u16 | u32 | u64
float = f32 | f64
num = int | float
// Single type alias
bytes = []byte
buf = [16]u8
// method definition — NO explicit self parameter, use `.` inside body
num.sign = () (r num) {
{
. > 0 -> r = 1
. < 0 -> r = -1
-> r = 0
}
}
int.to-str = () (out str) {
out = ''
n = .
// ... conversion logic using `n` (not `.` directly after first use)
out.len = len
}
Why method form is preferred here:
- The parser adds a hidden
self: <type> parameter, enabling GenericUnion detection and monomorphization
- Inside the body,
. is the receiver — cleaner than passing v explicitly
- The calling convention
to-str(receiver, out) still works identically via rewriteUnionCalls
Control Flow
Old syntax (deprecated, will be removed after version n): !! { } / ! { } / for { } / for cond { } / while cond { } / for i=0,i<n,i++ { } / for i <- [...] { } / for i in [...] { } / match x { } / if/elif/else { } can still be parsed but will output a deprecation warning. Please use the "new style" syntax in the table below.
| Purpose | New syntax | Old (deprecated) |
|---|
| Infinite loop | { } () | !! { } / ! { } / for { } |
| Conditional loop | { } (cond) | for cond { } / while cond { } |
| Counted loop | { } * n or i <- [0..n): { } | for i=0, i<n, i++ { } |
| Range iteration | i <- [a..b]: { } | for i <- [a..b] { } / for i in [...] |
| Conditional match | x: { ... } | match x { ... } |
| Branch selection | { cond -> body } | if/elif/else { } |
| Skip iteration | continue (temporarily retained) | ** (planned, not yet replaced) |
| Break loop | break (temporarily retained) | * (planned, not yet replaced) |
| Early return | return (temporarily retained) | ... (planned, not yet replaced) |
The new loop syntax puts the body block first, followed by the loop kind suffix:
{ body } () — infinite loop (empty parens)
{ body } (cond) — conditional loop (condition in parens, checked before each iteration)
{ body } * N — counted loop (body repeats N times)
This "body-first" ordering is intentional: it mirrors how you read the block, and the suffix
unambiguously declares the loop variant. The empty () reads as "loop forever"; (cond) reads
as "loop while condition holds".
// Infinite loop (new style)
{
// body
} ()
// Conditional loop (new style) — condition checked before each iteration
{
i = i + 1
} (i < 5)
// Five iterations
{ } * 5
// When N <= 0 the loop body is skipped (zero or negative count does not execute)
{ } * 0 // skipped
{ } * -3 // skipped
// Range for — interval syntax supports four bracket combinations
i <- [a..b]: { // closed interval: a ≤ i ≤ b
}
i <- (a..b]: { // left-open right-closed: a < i ≤ b
}
i <- [a..b): { // left-closed right-open: a ≤ i < b
}
i <- (a..b): { // open interval: a < i < b
}
i <- [5..0]: { // decreasing — runtime direction detection: start > end → decrement
}
i <- 'abc': { // iterate over each character in the string
}
// Runtime direction detection: when start > end, iteration automatically decrements (step -1).
// All four bracket combinations support decrement:
// [5..1] → 5 4 3 2 1 left-closed right-closed, descending
// (5..1] → 4 3 2 1 left-open right-closed, descending
// [5..1) → 5 4 3 2 left-closed right-open, descending
// (5..1) → 4 3 2 left-open right-open, descending
// (3..0] → 2 1 0 left-open right-closed, descending to zero
// When start <= end, iteration increments as usual (step +1).
// ❌ Explicitly rejected: interval bounds must be integers; nested expressions not supported
// i <- [1.5..5.5]: { } // compile error
// i <- [0..[1..5][0]]: { } // syntax error
// Single if (retained)
x == 1 -> do-something()
// Ternary (retained)
c = flag ? 1 : 2
max = sum > 10 ? sum : 10
Break / Skip / Early Return
i <- [0..10): {
* // break
** // continue
... // return/terminate
}
Match (new style x: { ... })
// Simple form, it is used to get the parameter
x: {
err -> log(it)
nil -> log('nil')
->
do-right-thing(it)
}
// Destructuring form
x: {
err(e) -> log(e)
nil -> log('nil')
val(v) ->
do-right-thing(v)
}
user: {
User{id=1} -> print("admin")
User{name=n} -> print("user: ", n)
-> print("anonymous")
}
score: {
[0..59] -> print("fail")
[60..89] -> print("good")
[90..=100] -> print("excellent")
-> print("invalid score")
}
num: {
1 || 3 || 5 || 7 -> print("small odd number")
2 || 4 || 6 -> print("small even number")
-> print("larger number")
}
// With return value, the last statement/value
result = x: {
1 -> 1
2 -> 2 + 1
-> a + b
}
// Match inside for-in body: executes one match per iteration
i <- [0..10): {
1 -> a = 1
2 -> b = 2
-> c = 0
}
// Multi-line arm body must use braces -> { ... }
x: {
nil -> {
log('nil')
do-cleanup()
return
}
err -> {
log(it)
do-cleanup()
return
}
ok -> print(it)
}
Multi-line arm body rule: When an arm body contains multiple statements, it must be enclosed in braces -> { ... }. Single-line body can be written directly after ->. If a multi-line body does not use braces, the it binding for option match will not be inserted correctly, causing a compile error.
Match semantics inside for-in: i <- (a..b]: { 1 -> ... 2 -> ... } executes the match body once for each iteration variable i (1 -> is equivalent to i == 1 ->, etc.). This is syntactic sugar for executing one match per iteration.
Match Style Guide
// ❌ Avoid: duplicate branch bodies
w = tls-c.send(req)
w: {
nil -> {
tls-c.close()
return
}
err -> {
tls-c.close()
return
}
ok -> n = it
}
// ✅ Shared logic in -> catch-all
w = tls-c.send(req)
w: {
ok -> n = it
-> {
tls-c.close()
return
}
}
// ✅ Or vice versa: name simple branches, complex logic in ->
val: {
nil -> return
err -> log(it)
-> {
n = it
total = total + n
process(n)
}
}
// Single statement — no braces
val: {
ok -> print(it)
-> print('empty or error')
}
// Multiple statements — must use braces
val: {
ok -> {
n = it
total = total + n
}
-> {
log('failed')
return
}
}
// it implicit binding
val: {
ok -> process(it) // it = unwrapped value
err -> log(it) // it = error message string
-> log('empty') // catch-all, handles nil here
}
// ✅ Combined option patterns: nil || err -> body
// Matches when the option is nil OR err, sharing the same body.
val: {
nil || err -> {
cleanup()
return
}
ok -> process(it)
}
// ✅ Also valid: any combination of nil, err, ok joined by ||
val: {
nil || err -> log('failed')
ok -> process(it)
}
If/Else (new style { cond -> body })
{
a == 1 -> {
a = 1
b = 2
}
a == 2 || a == 3 -> do-something()
->
c = 0
}
Async / Await (run / awy)
Nolang uses run and awy for async concurrency. Async function names must end with -async (no async keyword).
run — start an async thread, returns a task handle
awy — wait for the async thread to complete and get the result
// Async function definition (name ends with -async)
compute-async = (n i64) (r i64) {
r = n * 2
}
// Basic async call
h = run compute-async(21)
r = awy h // r = 42
// Concurrent tasks
h1 = run compute-async(10)
h2 = run compute-async(20)
r1 = awy h1 // r1 = 20
r2 = awy h2 // r2 = 40
// Inline await
r = awy run compute-async(5) // r = 10
Naming rule: async function names must end with -async (e.g. compute-async, fetch-data-async). Do not use the async keyword.
Multi-Assignment
Functions can return multiple values, received using multi-assignment at the call site:
swap = (a i64, b i64) (x i64, y i64) {
x = b
y = a
}
a, b = swap(5, 3)
// Also valid as a match arm body
val: {
ok -> a, b = parse-pair(it)
-> return
}
Structs & Methods
Struct definitions and literals must both use multi-line form, with each field on its own line, fields not separated by commas, and no trailing comma.
A struct can implement one or more interfaces by listing them after the struct name. When implementing interfaces from other modules, the interface name must include the module prefix (e.g. sql.db, not db). See Cross-Module Type References.
; Same-module interface: no prefix needed
user json {
name str
age i64
}
; Multiple interfaces
file enter, leave {
path str
}
; Cross-module interface: prefix required
; db, rows, stmt are defined in the sql module
db-mysql sql.db {
fd i64
}
u = user {
name: 'Alice'
age: 30
}
u.name = 'Bob'
u.age = 25
print(u.name)
user.greet = () {
print('Hello, ' - .name)
}
Enums
Enum definitions use the same syntax as structs, but with commas between values. Values auto-increment from 0.
// red=0, green=1, blue=2
color {
red,
green,
blue,
}
// This is a special enum, can have types, commas, and aliases
enum-name {
a t,
b u,
c v,
}
// Note this is a regular struct, multiple fields without commas
struct-name {
a t
b u
c v
}
Rule: enum values must always be referenced using qualified form enum-type.value, never as bare names. This prevents naming conflicts and ensures external packages cannot use values directly without qualification.
// ❌ Wrong: bare enum value
kind = null
yes = e.is(io)
// ✅ Correct: qualified form
kind = json-kind.null
yes = e.is(code.io)
Enum types can be used as struct field types, function parameter types, and return value types. Both inside and outside the module that defines the enum, enum values should be referenced using the enum-type.value form.
Method Conventions
Methods are defined on types, using . to reference the receiver. The receiver does not need to be explicitly declared as a parameter; it is referenced via . inside the method body.
Rules:
- Method names use the
type.method format; type must be a previously defined type
- Receiver is accessed via
. inside the method body
- Call with
receiver.method(args) syntax
- Return values go in the second set of parentheses
- Boolean returns must use
bool type, not i64
- Avoid reserved words as method names (e.g. use
matches not match)
Examples:
// str method
str.to-upper = () (out str) {
out.len = .len
i = 0
{
c = .[i]
{
c >= 97 && c <= 122 -> out[i] = c - 32
-> out[i] = c
}
i = i + 1
} (i < .len)
}
// char method
char.is-digit = () (result bool) {
result = false
. >= 48 && . <= 57 -> result = true
}
// struct method
user {
name str
age i64
}
user.greet = () {
print('Hello, ' - .name)
}
// Calling methods
s = 'hello'
u = s.to-upper() // receiver.method()
c char = 5
d = c.is-digit() // receiver.method()
u = user{
name: 'Alice'
age: 30
}
u.greet()
Slices (Views, Not New Types)
Slicing (arr[1..3], vec[1..3], str[1..3]) produces a view into the original data — it does not copy data or create a new independent type. The slice is a lightweight descriptor (pointer + length + capacity) that shares the original buffer:
- Modifications through a slice affect the original data, and vice versa
- The slice does not own the data; it becomes invalid when the original is released
- Methods of the original type are directly available — no "inheritance" mechanism needed
| Original type | Slice view type | Available methods |
|---|
arr ([n]t) | []t (vec) | All []t methods (len, push, pop, contains, reverse, clone, fill, to-arr, etc.) |
vec ([]t) | []t (vec) | Same as above |
str | str | All str methods (to-upper, to-lower, index, contains, slice, copy, fill, etc.) |
// arr slice → vec view, shares arr's memory
a [5]u8 = [0, 1, 2, 3, 4]
s = a[1..4] // s is []u8 view into a's buffer
n = s.len // vec.len
// vec slice → vec view, shares vec's memory
v = [10, 20, 30, 40, 50]
s = v[2..] // s is []i64 view
s.reverse(s.len) // vec.reverse
// str slice → str view, shares str's memory
s = 'Hello World'
sub = s[6..] // sub is 'World' view
upper = sub.to-upper() // str.to-upper
// Modifying through a slice affects the original
data = [10, 20, 30, 40, 50]
view = data[1..4] // view = [20, 30, 40]
view[0] = 99 // modifies data[1] too — shared memory
Indexing
// Get char from string (character, not byte)
str[i]
// Get element from arr, vec
arr[i]
vec[i]
// Get value from map
map[str]
Standard Library Struct Pattern
The standard library uses a consistent pattern for data structures and I/O abstractions: define a struct, then attach methods to it. The receiver is accessed via . inside the method body, and nested fields via self.field (or .field for single-level).
// Data structure: stack (LIFO)
stack {
data []i64
n i64
}
stack.push = (val i64) {
.data[.n] = val
.n = .n + 1
}
stack.pop = () (val ?i64) {
.n == 0 -> {
val = nil
return
}
.n = .n - 1
val = .data[.n]
}
// Usage
buf [128]i64 = [0:128]
s = stack {
data: buf
n: 0
}
s.push(42)
val = s.pop()
The same pattern applies to heap, deque, path, regexp, file, io-reader, io-writer, sse-client. See Standard Library Overview for the full API.
Networking Modules
The standard library includes comprehensive networking modules under std/net/:
std/net/http — HTTP/1.1 client (GET, POST, PUT, DELETE, PATCH), supports TLS
std/net/http2 — HTTP/2.0 client (RFC 7540, h2c prior knowledge mode)
std/net/http3 — HTTP/3.0 client (RFC 9114, over QUIC)
std/net/ws — WebSocket client and server (RFC 6455)
std/net/quic — QUIC protocol (RFC 9000)
std/net/tls — TLS 1.2/1.3 client connection (pure Nolang)
std/net/sse — Server-Sent Events client (W3C EventSource), supports TLS and auto-reconnect
std/net/client — High-level TCP client with reconnect support
std/net/server — HTTP server
std/net/dns — DNS resolution
std/net/url — URL parsing
std/net/cookie — HTTP Cookie handling
std/net/multipart — Multipart form data
std/net/hpack — HPACK header compression (for HTTP/2)
std/net/proxy — Proxy support
std/net/pool — Connection pool
std/net/unix — Unix domain sockets
std/net/ip — IPv4 address parsing and classification
// SSE client usage
client = sse.sse-connect('http://localhost:3000/events') // returns ?sse-client
client: {
nil -> print('connection failed')
-> {
{
ev = client.next-event() // returns ?sse-event
ev: {
nil -> * // EOF
err -> print(it) // error
-> print(ev.data) // event data
}
} ()
client.close()
}
}
Struct Field Method Calls
Method calls on struct fields via self.field (abbreviated .field) are fully supported. The type checker resolves the field type from the struct definition, so return types are correctly inferred:
// .recv-buf is a str field → .recv-buf.slice() returns str
data = .recv-buf.slice(0, .recv-buf-len) // correctly inferred as str
// .tls-c is a tls-conn field → .tls-c.send() works directly
written = .tls-c.send(req, req.len)
Interfaces
// Define interface
json {
to-json()
}
// Interface default implementation
json.to-json = () {
}
// Interface implementation
user json {
name str
age i64
}
// Override + call parent implementation
user.to-json = () {
// Parent implementation
..to-json()
}
user.other = () {
// Current implementation
.to-json()
// Parent implementation
..to-json()
}
Special Interfaces: enter / leave
Types that implement the enter / leave interfaces are automatically called when entering and leaving a scope:
file enter, leave {
path str
}
file.enter = () {
.open()
}
file.leave = () {
.close()
}
read-file = () {
// Auto f.enter()
f = file{
path: 'data.txt',
}
// Use f
// Auto f.leave()
read(f)
}
Generics
arr_to_vec = (arr [n]t) (out []t) {
i <- [0..n): {
out[i] = arr[i]
}
}
Type Casting
// Returns the type name string
a = typeof(x)
// `as` is only allowed for FFI pointer type casts (e.g. *byte, **byte, *i64)
// Integers are internally i64, no explicit cast needed
y = x as *byte
Integer Assignment Type Checking
The compiler type-checks integer assignments to prevent unsafe narrowing that could cause data loss.
Implicit Widening (safe, auto-allowed)
A narrower integer type's value can be auto-assigned to a wider type, since the target range fully contains the source range:
b byte = 200
i i64 = b ; ✓ byte range [0,255] ⊆ i64 range
u u32 = b ; ✓ byte range ⊆ u32 range
Integer Literal Assignment
Integer literals (default inferred as i64) can be assigned to any integer type whose range includes the literal value:
n u8 = 200 ; ✓ 200 ∈ [0,255]
m u8 = 300 ; ✗ 300 > 255, compile error
big u64 = 18446744073709551615 ; ✓ 2^64-1, u64 max
Unsafe Narrowing (compile error)
Assigning a wider-typed variable directly to a narrower type causes a compile error, as it may cause data loss. The error message includes an actionable fix hint suggesting how to narrow safely with bitwise operations:
d u64 = 42
h u32 = d ; ✗ cannot assign u64 value to u32 variable 'h'; hint: narrow safely with a bitwise mask (e.g. `& 4294967295`) or right shift (e.g. `>> 32`)
h u16 = d ; ✗ cannot assign u64 value to u16 variable 'h'; hint: narrow safely with a bitwise mask (e.g. `& 65535`) or right shift (e.g. `>> 48`)
h u8 = d ; ✗ cannot assign u64 value to u8 variable 'h'; hint: narrow safely with a bitwise mask (e.g. `& 255`) or right shift (e.g. `>> 56`)
x u32 = d + 1 ; ✗ addition result is still u64, unsafe
y u32 = foo() ; ✗ function call result type mismatch
Fix hint: The compiler auto-computes the exact mask value and shift amount for the target type. Apply the suggested mask or shift to narrow safely (see next section).
Signed target types: For i8/i16/i32/i64, the hint explains that bitwise narrowing is not safe (sign-bit truncation is ambiguous) and suggests an explicit range check instead.
Safe Bitwise Narrowing (auto-allowed)
When the right-hand side of an assignment is a bitwise expression (&, |, ^, <<, >>) and the target type is an unsigned integer (u8/u16/u32/u64/byte), the compiler allows implicit narrowing — because high-bit truncation is the standard semantics of bitwise operations and does not cause unexpected data loss:
d u64 = 42
; ✓ mask operation: result ≤ mask value, safely fits u32
h u32 = d & 67108863 ; mask = 2^26-1 < 2^32
h u32 = d & 4294967295 ; mask = 2^32-1, exactly u32 range
; ✓ shift operation: high bits are 0 after right shift
hi u32 = d >> 32 ; u64 >> 32 leaves 32 bits
; ✓ XOR / OR combinations
c u32 = a ^ b ; bitwise operation result
b byte = v & 255 ; mask to byte range
; ✓ composite bitwise (common in crypto/codec)
s u32 = (key[0] & 255) | ((key[1] & 255) << 8) | ((key[2] & 255) << 16) | ((key[3] & 255) << 24)
Why allowed? Bitwise operations (mask, shift, XOR, OR) semantically construct a bit pattern. Assigning to a narrower unsigned type truncates the high bits intentionally — the developer has already ensured the result's range via mask or shift, or deliberately discards high bits. This is a standard pattern in cryptography (e.g. ChaCha20, Poly1305, Blake2) and codec code.
Unsigned target types only. For signed integer targets (i8/i16/i32/i64), even with a bitwise RHS, an error is still reported because sign-bit truncation semantics are ambiguous:
d u64 = 42
h i32 = d & 4294967295 ; ✗ still errors: signed target not eligible
Top-level must be a bitwise op. Only when the expression's top-level operator is &/|/^/<</>> is it allowed. Addition, subtraction, function calls, direct variable references, etc. are not covered:
d u64 = 42
h u32 = d ; ✗ top-level is Identifier, not bitwise
h u32 = d + 1 ; ✗ top-level is +, not bitwise
Import System
New syntax: # path (recommended). The old use path keyword is deprecated but still supported. Always prefer # in new code.
// Std modules
# std/math.add
// Remote modules
# github.com/utils/math.add
// Local modules (must start with /)
# /utils/math.add
// Aliases
# std/math.add a
// ── Old syntax (deprecated, still works) ──
// use std/math.add
// use github.com/utils/math.add
// use /utils/math.add
// use std/math.add a
Module Prefix Rules
Nolang enforces a mandatory module namespace convention: when calling functions or constants defined in other modules (other .no files) from a .no file, you must use the ShortName. prefix. This avoids cross-module naming conflicts.
Standard library modules are automatically loaded by the compiler; no explicit import is needed (no # std/... annotation required), just use the ShortName. prefix to call them.
ShortName Definition
ShortName is the last segment of the module path, used as the prefix for cross-module calls.
| File path | FullPath | ShortName | Description |
|---|
std/math.no | math | math | Top-level file |
std/fs.no | fs | fs | Top-level file |
std/net/net.no | net/net | net | Last path segment |
std/net/client.no | net/client | client | Last path segment |
std/hash/sha256.no | hash/sha256 | sha256 | Last path segment |
std/archive/gzip.no | archive/gzip | gzip | Last path segment |
ShortName is the last segment of FullPath when split by slashes (e.g. hash/sha256 → sha256).
Prefix Required
When calling module-level functions or constants defined in other modules, you must use the ShortName. prefix.
// Module-level functions
sha256.sha256(data)
sha256.sha256-hex(data)
fs.open(path, opts)
gzip.gzip-decompress(data)
math.degrees(rad)
// Module constants
net.NET-BUF-SIZE
math.PI
Prefix Not Required
The following cases do not require a prefix:
1. printf / sprintf / print
These three functions are exempt from the prefix rule by convention. This is a special case for these three functions only, not because they are builtins — other builtin functions (such as open, close, read, write, etc.) still require the module prefix.
printf('hello %d', n) // ✅ no prefix
s = sprintf('x=%d', x) // ✅ no prefix
print('hello') // ✅ no prefix
fs.open(path, opts) // ✅ with prefix (builtins need it too)
2. Same-file definitions
Functions, constants, and methods defined in the same .no file are used directly without a prefix.
// In sha256.no:
sha256(data) // sha256 is defined in this file
HMAC-BLOCK-SIZE // constant defined in this file
3. Built-in type methods
Method calls on built-in types (str, i64, vec, arr, byte, char, bool, etc.) do not require a prefix. Methods are built into the type and resolved directly through the receiver type.
'hello'.starts-with('he') // str method
n.to-str() // int method
v.push(42) // vec method
a.contains(3) // arr method
c.is-digit() // char method
4. Struct instance methods
Calling methods on an already-created struct instance does not require a module prefix. Methods are resolved through the instance's type; the compiler automatically finds the corresponding struct.method definition.
f = fs.open(path, opts) // fs.open is a module-level function, needs prefix
f.read(buf, n) // file.read is a struct method, no prefix needed
f.close() // file.close is a struct method, no prefix needed
p = path{
p: '/tmp'
}
p.exists() // path.exists is a struct method, no prefix needed
Method calls vs Module function calls
Whether a method call requires a prefix depends on the method owner:
- Built-in type methods (
str.starts-with, i64.to-str, etc.) — no prefix needed
- Struct instance methods (
f.read, p.exists, etc.) — no prefix needed
- Module-level functions (
fs.open, sha256.sha256, etc.) — prefix required
In fs.fil(), fs is the module's ShortName, and fil is the module-level function name. The fs. prefix cannot be omitted because fs here is not a variable name but a module path.
Cross-Module Type References
When referencing types (structs, interfaces, enums) defined in other modules, you must use the ShortName. prefix. This applies to:
1. Struct interface implementation — when a struct implements interfaces from another module, the interface name must be prefixed:
// ❌ Wrong: db, rows, stmt are interfaces defined in the sql module
db-mysql db {
fd i64
}
// ✅ Correct: use sql.db, sql.rows, sql.stmt
db-mysql sql.db {
fd i64
}
rows-mysql sql.rows {
fd i64
}
stmt-mysql sql.stmt {
fd i64
}
2. Function parameter and return types — cross-module types in function signatures need the prefix:
// ✅ Correct: return type uses sql.result
db-mysql.exec = (sql str) (r sql.result) {
...
}
3. Struct field types — cross-module types as field types need the prefix:
// ✅ Correct: field type uses sql.connection
conn-mysql sql.db {
handle sql.connection
}
No prefix needed for:
- Same-module types (defined in the same
.no file)
- Built-in types (
str, i64, bool, byte, etc.)
- Built-in interfaces (
enter, leave)
// Same-file defined types, no prefix needed
result {
last-id i64
affected i64
}
// enter/leave are built-in interfaces, no prefix needed
// result is same-file struct, no prefix needed
db enter, leave {
close() (ok bool)
exec(sql str) (r result)
}
Complete Example
// Standard library modules are auto-loaded, no explicit import needed
// ─── No prefix needed ───
// Same-file functions
sha256(data)
// Built-in type methods
'hello'.starts-with('he')
n.to-str()
v.push(42)
// Struct instance methods
f = fs.open(path, opts)
f.read(buf, n)
f.close()
// printf/sprintf/print
printf('hello %d', n)
s = sprintf('x=%d', x)
// ─── Prefix required ───
// Module-level functions
sha256.sha256(data)
sha256.sha256-hex(data)
fs.open(path, opts)
gzip.gzip-decompress(data)
math.degrees(rad)
// Module constants
net.NET-BUF-SIZE
math.PI
// Cross-module type references (interface implementation, param types, return types, field types)
db-mysql sql.db {
fd i64
}
r sql.result = d.exec('CREATE TABLE ...')
Export System
Nolang uses the @ keyword to declare exports in the package root lib.no file. External packages can only access these exported symbols when importing via #.
Syntax
@ path.func [alias]
path — Module path (relative to package root, starts with /, without .no extension)
func — Name of the function/constant/enum to export
alias — Optional alias, the name used when importing externally
Rules
- Export statements can only be written in the package root
lib.no file
- One export item per line
- Export items can only be final symbols such as functions, constants, enums
- Structs, enums, and other types referenced by exported functions are auto-exported, no manual declaration needed
- If an exported function does not exist in the module, LSP will report an error
Example
// lib.no - package root export file
@ /src/utils.greet a
@ /src/utils.hello b
@ /src/math.pi
// src/utils.no
// Define exported functions
greet = (name str) {
print('Hello, ' - name)
}
hello = () {
print('Hi')
}
Importing Exported Symbols
External packages can only access exports declared in lib.no when importing via #:
// Import alias a (corresponds to package-name.utils.greet)
# package-name.utils.greet a
// Or use the function name directly
# package-name.utils.greet
LSP Support
- Go to definition: Click an exported function name or alias in
lib.no to jump to its definition in the corresponding module file
- Auto-completion: Automatically suggests available file paths and function names when typing
@ and paths
- Error diagnostics: Shows error diagnostics when an exported function does not exist in the module
Special Symbols & Operators
Special Symbols
# — import module
@ — export module
.. — parent (super)
. — self
! — false (planned, currently still uses false)
!! — true (planned, currently still uses true)
{ } () — infinite loop (new style; !! { } is deprecated)
{ } (cond) — conditional loop (new style; for cond { } is deprecated)
{ } * N — counted loop (body repeats N times; N ≤ 0 skips the body)
** — continue (skip current iteration) (planned, currently still uses continue)
* — break (exit loop) (planned, currently still uses break)
... — return/terminate (planned, currently still uses return)
<- — range iteration
-> — match arm / if-else branch (cond -> body)
: — match expression (x: { ... })
? — option type prefix (?i64, ?str) / ternary operator
run — start async thread
awy — await async thread completion
Arithmetic Operators
+ // addition
- // subtraction (also used for string concatenation)
* // multiplication (also used for string repetition)
/ // division
Comparison Operators
== // equal to
!= // not equal to
< // less than
> // greater than
<= // less than or equal to
>= // greater than or equal to
Logical Operators
&& // logical AND
|| // logical OR (also used for match branch combination, e.g. nil || err -> body)
! // logical NOT
Bitwise Operators
& // bitwise AND
| // bitwise OR
^ // bitwise XOR
~ // bitwise NOT
<< // left shift
>> // right shift
Assignment Operators
= // assignment
+= // add-assign
-= // subtract-assign
*= // multiply-assign
/= // divide-assign
%= // modulo-assign
&= // bitwise AND-assign
|= // bitwise OR-assign
^= // bitwise XOR-assign
<<= // left shift-assign
>>= // right shift-assign
Others
? // ternary operator (e.g. c = flag ? 1 : 2)
as // FFI pointer type conversion (e.g. y = x as *byte)
.. // slice range (e.g. arr[1..3], arr[1..], arr[..3])
FFI (#{c} annotation)
Declare external C functions through the #{c} annotation to implement FFI (Foreign Function Interface). #{c} is on its own line, marking the next line as an FFI declaration. #{c} is the FFI language key of the annotation system; it also supports #{cpp}, #{rust}, and other languages. The old syntax #c is still backward compatible.
Private declarations: Names starting with _ are private (not exported); C ABI symbols automatically strip the leading _ and convert hyphens to underscores.
No separate file needed: FFI declarations and regular code can be written in the same .no file.
Pointer type syntax: FFI uses C-style *T, **T, ***T to represent pointers, which must have a concrete type T. Regular code cannot use this syntax.
| Syntax | Meaning | LLVM IR | Usage |
|---|
*byte | pointer to byte | i8* | opaque pointer (e.g. db handle) |
**byte | double pointer | i8** | output parameter (e.g. sqlite3**) |
***byte | triple pointer | i8*** | rare triple indirection |
// sqlite.no — FFI bindings and safe wrappers in the same file
// Compiler automatically converts hyphens (-) to underscores (_) to match C ABI symbols
// Names starting with _ are private; C ABI symbol automatically strips leading _
// Basic type parameters
#{c}
c-strlen = (s str) (n i64)
// Pointer parameter (*byte = opaque pointer), private declaration
#{c}
_sqlite3-close = (db *byte) (rc i32)
// Double pointer (**byte = output parameter, value auto-stored back to variable after call), private declaration
#{c}
_sqlite3-open = (filename str, db **byte) (rc i32)
// Multiple pointer parameters, private declaration
#{c}
_sqlite3-exec = (db *byte, sql str, callback *byte, arg *byte, errmsg *byte) (rc i32)
// Safe wrapper in the same file
open = (dsn str) (d db-sqlite) {
handle i64 = 0
rc i32 = _sqlite3-open(dsn, handle)
rc != SQLITE-OK -> {
return
}
d.handle = handle
}
Rules:
#{c} is on its own line, marking the next line as an FFI declaration (old syntax #c is still backward compatible)
- FFI is declaration only, no function body
- Pointers must have a concrete type (e.g.
*byte); bare ptr is not allowed
*T → i8*, **T → i8**, ***T → i8***
- All pointers stored as
i64 on the Nolang side (via ptrtoint)
**T parameters are output params: C function writes pointer, Nolang auto-converts to i64 and stores back
- Hyphens in names are converted to underscores for C ABI symbols
str params are auto-converted to null-terminated i8*
- Names starting with
_ are private (not exported); C ABI symbol strips leading _
- FFI declarations and regular code can be in the same
.no file
Annotations (#{...} system)
#{...} is the general annotation system — a comma-separated list of key-value pairs. It supersedes the #c directive: #{c} is the new FFI syntax (old #c still works).
Supported value types:
| Syntax | Type | Example |
|---|
| Bare key | bool | #{debug} |
| Integer | int | #{max=100} |
| String | string | #{name='hello'} |
| Identifier | ident | #{mode=fast} |
| Array | array | #{derive=[Serialize, Deserialize]} |
| Range | range | #{range=[0..256)} |
Multiple key-value pairs are separated by commas:
#{derive=[Serialize, Deserialize], range=[0..256), max=100, debug}
Range syntax supports four bracket combinations:
[a..b] — closed on both ends
[a..b) — left-closed, right-open
(a..b) — open on both ends
(a..b] — left-open, right-closed
The FFI annotation #{c} is a special form of the annotation system. When an annotation contains an FFI language key (c, cpp, rust, etc.) and is followed by a function declaration, the compiler identifies it as an FFI binding:
// #{c} with additional annotations
#{c, debug}
_sqlite3-open = (filename str, db **byte) (rc i32)
Annotations attached to declarations
Non-FFI annotations are automatically attached to the declaration that follows. This is useful for tagging numeric types (like num) with range constraints:
// Variable declaration with range annotation
#{range=[0..256)}
x num = 42
// Struct definition with annotation
#{derive=[Serialize, Deserialize]}
point {
x i64
y i64
}
// Struct field with range annotation (for num and other numeric types)
person {
#{range=[0..150]}
age num
#{range=[0..256)}
score i64
name str
}
Platform annotations
Platform annotations are compile-time filters that include or exclude code based on the target platform. They use flattened keys that unambiguously specify both OS and architecture (e.g. #{mac-arm64}), and are attached to the declaration that follows. Non-matching code is excluded from the build entirely — no LLVM IR is generated, no type checking is performed.
Supported platform keys (6 flattened combinations):
| Key | Matches |
|---|
#{linux-amd64} | Linux on x86_64 |
#{linux-arm64} | Linux on ARM64 |
#{win-amd64} | Windows on x86_64 |
#{win-arm64} | Windows on ARM64 |
#{mac-amd64} | macOS on x86_64 (Intel) |
#{mac-arm64} | macOS on ARM64 (Apple Silicon) |
// Platform-specific print
#{mac-arm64}
print('running on macOS ARM64')
#{linux-amd64}
print('running on Linux x86_64')
#{win-amd64}
print('running on Windows x86_64')
// Platform-specific variable
#{mac-amd64}
#{mac-arm64}
sep = '/'
#{win-amd64}
#{win-arm64}
sep = '\\'
// Platform-specific function
#{mac-arm64}
#{mac-amd64}
greet = () {
print('hello from mac')
}
#{linux-amd64}
#{linux-arm64}
greet = () {
print('hello from linux')
}
greet()
Multiple keys on the same declaration are OR'd together — any match includes the code. No AND logic is needed because each key already specifies both OS and arch.
| Annotation | Meaning |
|---|
#{mac-arm64} | macOS ARM64 only |
#{mac-amd64, mac-arm64} | macOS on any arch |
#{linux-amd64, win-amd64} | Linux x86_64 or Windows x86_64 |
#{mac-arm64, linux-arm64} | macOS ARM64 or Linux ARM64 |
// Included on both macOS and Linux (all archs)
#{mac-amd64, mac-arm64, linux-amd64, linux-arm64}
shared = () {
print('unix-like')
}
// Only on Windows x86_64
#{win-amd64}
reg-key = () {
print('reading registry on win/x64')
}
// Only on macOS ARM64 (Apple Silicon)
#{mac-arm64}
neural = () {
print('Apple Neural Engine available')
}
Use os.get-arch() to get the current architecture at runtime, and platform annotations to include/exclude code at compile time.
The range annotation is particularly useful for num type (num = int | float) to mark valid value ranges. Range bounds can be integers or identifiers (e.g. constants):
#{range=[i8.MIN..i8.MAX]}
val i8 = 100
If an annotation is not followed by a declaration, it remains a standalone AnnotationStatement.
String Operations
Nolang strings (str) are a union type (short ≤127 bytes stored on stack / long stored on heap), supporting multiple operators and methods.
String Literals
Nolang supports three kinds of string/char literals:
-
Single-quoted strings ('...'): Standard string literal with escape processing (\n, \t, \\, \', \0, etc.). Type: str.
-
Double-quoted char ("x"): Single Unicode character (rune). Type: char (i32). Only one character allowed.
-
Raw strings (backtick-delimited): Multi-line, no escape processing. Type: str.
// Standard string
s = 'hello\nworld'
// Char literal (rune)
c = "中"
// Raw string — backtick-delimited, multi-line, no escapes
sql = `
SELECT id, name
FROM user
WHERE id > 100
`
Raw string format constraints (core design):
- Opening
` must be immediately followed by a source newline;
- Closing
` must be on its own line (only whitespace allowed before it);
- The backtick marker lines are not part of the string content;
- No escape processing: all
\, \n, \t, \', \" are preserved literally;
- Newlines and indentation are preserved exactly as in source;
- Cannot embed backtick character — use single-quoted string concatenation if needed.
String Operators
Concatenation (-)
// Literal concatenation
s = 'Hello' - ' ' - 'World'
// Concatenation with variable
greeting = 'Hello, ' - name
Repetition (*)
s = 'Hello' * 3
Indexing & Slicing
s = 'Hello World'
// Index to get char (character, not byte)
c = s[0] // c = 'H' code point
// Slice (view, shares underlying memory)
sub = s[6..] // 'World'
sub = s[6..11] // 'World'
sub = s[0..5) // 'Hello'
// Length
n = s.len // byte length
n = s.count() // code point count (Unicode character count)
String Methods
For the complete list of string methods, see Standard Library Overview — str module.
Auto Length Tracking
When assigning s[i] = v, LLVM codegen automatically updates the len field to max(len, idx+1), no need to manually set .len:
s = ''
s[0] = 72 // len automatically becomes 1
s[1] = 105 // len automatically becomes 2
// Manually setting .len is only for truncation (shortening)
s.len = 5
Standard Library Overview
The Nolang standard library (src/std/) contains 60+ modules, covering formatting, math, strings, data structures, encoding/decoding, encryption, compression, file operations, I/O abstractions, etc.
Usage: # std/xxx (core modules do not need to be imported).
The old-style use std/xxx still works but is deprecated; using the new-style # std/xxx syntax is recommended.
Note: All code examples follow the "one statement per line" rule — ; and // are both comment markers; commas , must not join multiple statements on one line.
Base Types
types — Type Definitions
Nolang type to LLVM mapping:
| Nolang | LLVM |
|---|
bool | i1 |
byte | i8 |
char | i32 |
i8/i16/i32/i64 | i8/i16/i32/i64 |
u8/u16/u32/u64 | i8/i16/i32/i64 |
f32 | float |
f64 | double |
str | union (short: [127]byte / long: {*byte, i64}) |
Composite types:
- Variable-length array
[]t: underlying { t*, i64 } (data, len)
- Fixed-length array
[n]t: LLVM fixed-size array
- String
str: union type (short ≤127 bytes stored on stack / long stored on heap), supports s[i], s[i..j], s + t
- Enum/Union:
option tagged enum (ok t / nil / err str)
- Struct: must be multi-line definition, fields without commas
- Map: underlying linked-hash-map
- Iterator:
for iter.next() {} (interface method next() (ok bool))
option — Option Type
option<t> tagged enum (tag=0=val, 1=nil, 2=err):
x ?t // Declare option<t>
x = 42 // Set to has-value
x = nil // Set to empty
x = err('msg') // Set to error
// match
x: {
val -> f(it)
nil ->
err -> g(it)
}
Style guide: When a function may fail or return an empty value, use ?t option instead of (val, ok bool). ?t has three states: ok (has value), nil (empty/normal absence), err (error). Normal values are implicitly bound. For example, pop() returns ?i64 (nil = empty), read-line() returns ?str (nil = EOF, err = error), lookup() returns ?str (nil = not found).
Core Library
fmt — Formatted Output
printf(fmt str, ...) // Formatted output, no trailing newline
print(...) // Print with newline
math — Math Functions
Constants: math.PI, math.E
Basic: math.abs, math.sqrt
Trigonometric: math.sin, math.cos, math.tan, math.asin, math.acos, math.atan, math.atan2, math.degrees, math.radians
Hyperbolic: math.sinh, math.cosh, math.tanh
Rounding: math.ceil, math.floor, math.round, math.trunc
Exponential/Logarithm: math.exp, math.log, math.log10, math.log2, math.pow, math.hypot, math.cbrt
Others: math.fmod, math.max, math.min
char — Character Operations
char is essentially i32 (Unicode code point), all operations are provided as methods:
c char = 'A'
c.is-digit() // Is digit (0-9) (method)
c.is-letter() // Is letter (a-z, A-Z) (method)
c.is-alpha() // Alias for is-letter (method)
c.is-alnum() // Is letter or digit (method)
c.is-space() // Is whitespace character (method)
c.is-upper() // Is uppercase letter (method)
c.is-lower() // Is lowercase letter (method)
c.to-upper() // Convert to uppercase (ASCII) (method)
c.to-lower() // Convert to lowercase (ASCII) (method)
c.to-bytes() // Unicode → UTF-8 bytes (method)
c.to-str() // Unicode → string (UTF-8, method)
str — String Operations
ok = a.eq(b, n) // Equality comparison (method)
dst = s.copy() // String copy (method)
s.fill(val byte) // Fill with byte value (method)
pos = s.index(sub) // Substring position
ok = s.contains(sub) // Contains
ok = s.starts-with(sub) // Prefix check
ok = s.ends-with(sub) // Suffix check
s.to-upper() // Convert to uppercase
s.to-lower() // Convert to lowercase
out = s.trim() // Trim leading/trailing whitespace
out = s.repeat(n) // Repeat
out = s.slice(start, end) // Slice
b = s.to-bytes() // Convert to []byte
s = b.to-str() // []byte to str (method)
v = s.to-i64() // String to i64 (returns ?i64)
v = s.to-i8() // String to i8 (returns ?i8)
v = s.to-i16() // String to i16 (returns ?i16)
v = s.to-i32() // String to i32 (returns ?i32)
v = s.to-u8() // String to u8 (returns ?u8)
v = s.to-u16() // String to u16 (returns ?u16)
v = s.to-u32() // String to u32 (returns ?u32)
v = s.to-u64() // String to u64 (returns ?u64)
v = s.to-byte() // String to byte (returns ?byte)
v = s.to-f64() // String to f64 (returns ?f64)
v = s.to-bool() // String "true"/"false" to bool (returns ?bool)
s = v.to-str() // i64 to string (method)
out = s.reverse() // Reverse
c = s.compare(b) // Lexicographic comparison
n = s.count() // Total code point count
val = s.replace-char(old, new) // Replace character (returns result string)
out = s.trim-char(c) // Trim specified character
ok = s.empty() // Is empty
s.clear() // Clear (len=0, in-place)
out = s.with-cap(cap) // Create new string with specified capacity (len=0)
out = s.with-len(len) // Create new string with specified length (len=cap)
out = s.with-cap-len(cap, len) // Create new string with specified capacity and length
parts = s.split(sep) // Split by separator (returns []str, method)
out = ss.join(sep) // Join []str with separator (method)
number — Numeric Operations
number.max(a, b) // Maximum
number.min(a, b) // Minimum
r = num.clamp(lo, hi) // Clamp to range (method)
r = number.abs(a) // Absolute value (num generic)
r = num.sign() // Sign (-1/0/1, method)
number.even(v) // Even/odd check
number.odd(v)
number.gcd(a, b) // Greatest common divisor
number.lcm(a, b) // Least common multiple
r = number.pow(a, n) // Integer power
number.i64-to-f64(v) // Numeric conversion
number.f64-to-i64(v)
s = int.to-str() // i64 to string (method)
q = number.div(a, b) // Integer division quotient
r = number.mod(a, b) // Modulo
number.swap(a, b) // Swap
yes = float.is-nan() // NaN check (method)
yes = float.is-inf() // Inf check (method)
// Range constants
i8.MIN / MAX // -128 / 127
i16.MIN / MAX // -32768 / 32767
i32.MIN / MAX // -2147483648 / 2147483647
i64.MIN / MAX // -2^63 / 2^63-1
u8.MIN / MAX // 0 / 255
u16.MIN / MAX // 0 / 65535
u32.MIN / MAX // 0 / 4294967295
u64.MIN / MAX // 0 / 2^64-1
byte — Byte Operations
out = i64.to-bytes-be() // i64 → big-endian [8]byte
out = i64.to-bytes-le() // i64 → little-endian [8]byte
v = []byte.to-i64-be() // big-endian []byte → i64 (1~8 bytes)
v = []byte.to-i64-le() // little-endian []byte → i64 (1~8 bytes)
s = []byte.to-str() // []byte to str (method)
s = []byte.to-hex() // []byte → uppercase hex string
s = []byte.to-hex-lower() // []byte → lowercase hex string
s = byte.to-str() // byte to str (method)
vec — Slice Operations
v = vec.vec-create(n, val) // Create slice of length n, filled with val
ok = []t.eq(a, b, n) // Equality comparison
n = []t.len() // Length
[]t.push(val) // Append (auto-grow)
[]t.clear() // Clear (len=0, cap/data unchanged)
v = []t.with-cap(cap) // Create new slice with specified capacity (len=0)
v = []t.with-len(len) // Create new slice with specified length (len=cap)
v = []t.with-cap-len(cap, len) // Create new slice with specified capacity and length
val, new-n = []t.pop() // Pop
found = []t.contains(n, val) // Contains (n is length)
[]t.reverse(n) // Reverse first n elements
[]t.clone(dst) // Copy to dst
[]t.fill(n, val) // Fill first n elements
arr = []t.to-arr() // Convert to array
[]t.sort-asc() // Ascending sort (method)
[]t.sort-desc() // Descending sort (method)
arr — Array Operations
out = [n]t.clone() // Copy
ok = [n]t.eq(b) // Equality comparison
[n]t.fill(val) // Fill
[n]t.reverse() // Reverse
ok = [n]t.contains(val) // Contains