Skip to content
You are reading v0.1, which is not the current release. Go to v0.4.

Lexical structure

Khora source is UTF-8 text. Whitespace and comments separate tokens where needed; the syntax tree retains trivia so formatting and documentation tools can preserve source faithfully.

Ordinary identifiers begin with an ASCII letter or _ and continue with ASCII letters, digits, or _:

user
user_id
HTTP2
_internal

Hard keywords cannot be used as identifiers.

One Unicode scalar value between apostrophes:

'a'
'é'
'\n'
'\u{1F600}'

The escapes are the string ones: \n, \r, \t, \0, \\, \', \", and \u{...} for a code point in hexadecimal.

Exactly one character. '' is not a literal and neither is 'ab' — a String is what holds any number of characters.

The apostrophe also begins a row variable, and the two are told apart by the closing quote: 'a' is a character and 'a is a row variable. A generic list such as <'a, 'b> is two row variables, not one character containing a comma.

A row variable begins with ' followed by an identifier:

'ef
'raises
'capabilities

The spelling after ' has no built-in semantic meaning.

0
42
1_000_000

Underscores may be used as visual separators.

0.5
3.14159
6.02e23
1.0E-6

A fractional literal without the decimal suffix is an IEEE Float.

Append d directly to the number for an exact Decimal literal:

0d
0.01d
19.99d
1.25e3d

d is Khora’s literal suffix; no whitespace may appear between the number and suffix. The module needs import std::decimal::{Decimal}; for the literal to have a type. A fractional literal without the suffix is a Float:

let approximate = 0.1;
let exact = 0.1d;

The distinction is visible in the source so a reader can tell whether an API does approximate binary arithmetic or exact decimal arithmetic. Decimals is the rest of it.

true
false
"Khora"
"first\nsecond"
"say \"hello\""

Strings contain Unicode text. The escapes are \n, \r, \t, \0, \\, \', \", and \u{...} for a code point in hexadecimal:

"caf\u{e9}" // café
"snowman \u{2603}" // snowman ☃

\u{...} takes one to six hexadecimal digits. A code point that is not a character — a surrogate, or anything above 10FFFF — is refused at compile time rather than producing a string that is not UTF-8.

${...} evaluates an expression inside a string:

"user ${user.id}"
"count = ${count}"
"the point is ${point} and the list is ${items}"

Interpolation is expression syntax, not a separate formatting language.

A hole that already holds a String is used as it stands. Anything else is shown through Show, so a value whose type has no Show impl is a compile error naming the type:

error: `Colour` has no `Show`, so it cannot go in a `${..}` hole. Write
`derive(Show)` on it, or `impl Show for Colour`

Show does not have to be imported to interpolate. The hole is the use, and the trait is never named in the source.

Interpolation is for text a person will read. Where another program consumes the output, reach for a structured encoder such as std::json instead — a value that renders one way for a human is not a serialisation format.

Backticks delimit multiline strings:

let query = `
select id, name
from users
where active = true
`;

${...} interpolation and backslash escapes work in backtick strings too. A literal backtick is escaped with ```.

When a multiline backtick literal opens on its own line, common source indentation is stripped so the surrounding code can remain indented without changing the text’s intended layout.

Three things are removed, and the last two are the ones that surprise people:

  • the common indentation, measured over the whole body and taken off each line, so the deepest-indented line keeps whatever it has beyond the shallowest;
  • the newline immediately after the opening backtick;
  • the newline and indentation before the closing backtick.

So this literal is thirty bytes and three lines, with no leading or trailing newline:

fn block() -> String {
`
line one
indented
line three
`
}

It is "line one\n indented\nline three". indented keeps two spaces because the common prefix was two, not four. A literal that should end with a newline needs a blank line before the closing backtick, and one that should start with a newline needs a blank line after the opening one.

None of it applies when the content starts on the opening line, which is the form to reach for when the text must be taken exactly as written:

let raw = `first
second`;
// ordinary comment

API documentation uses three slashes:

/// Returns the user for `id`.
pub fn load_user(id: Id) -> User;

Module documentation uses //!:

//! User-domain types and operations.
module app::users;

khora doc reads /// and //! comments as Markdown documentation.

/* a block comment */

Block comments may nest:

/* outer
/* inner */
outer again
*/

These words are reserved:

module import type trait impl fn match let mut pub as
if else forall const effect with raises raise catch
while loop break continue for return true false

These words are keywords only in their grammatical position and remain usable as ordinary identifiers elsewhere:

handler in context test bench derive extern

Examples of their keyword positions:

handler for Clock { now: fn () => fixed_instant }
for item in items { process(item); }
context Production { clock: live_clock }
test "works" { assert(true); }
bench "parse" { parse(fixture); }
derive(Eq, Show)
type Point = { x: Int, y: Int };
extern fn strlen(ptr: Ptr) -> U64;

in is contextual in the for pattern in expression form.

Common multi-character tokens include:

:: compile-time path separator
-> function/type arrow
=> match arm or lambda separator
|> pipeline
||> flow lambda
== != <= >=
&& ||

Single-character punctuation includes ; , . : | = + - * / % ! < > ( ) { } [ ] _.

See Expressions for operator precedence and Declarations for the positions in which the declaration-oriented keywords appear.

Khora 0.1.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource