Skip to content

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.

An exponent needs a fractional part in front of it. 6.02e23 and 1.0E-6 are literals; 1e19 is not, and is reported as expected `;` — the lexer stops at the 1 and the e19 that follows is not part of the number. Write 1.0e19. The same rule applies to a d suffix: 1.25e3d is a Decimal literal and 1e19d is a parse error.

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: `Color` has no `Show`, so it cannot go in a `${..}` hole. Write
`derive(Show)` on it, or `impl Show for Color`

Show does not have to be imported to interpolate. The hole is the use, and the trait is never named in the source. The value’s type does have to be imported, though: a hole over a List<Int> in a module that never wrote List reports the same “has no Show” error even though List implements it.

A quote inside a hole is written unescaped. ${...} is scanned as source, not as string content, so the inner literal’s quotes stand as they are:

"len = ${String::byte_length("abc")}" // correct
"len = ${String::byte_length(\"abc\")}" // refused

The escaped form is refused by name:

error: a `${..}` hole is scanned as source, not as string text, so its quotes
stand as they are: write `"` here, not `\"`

A hole that fails to parse for some other reason gets the general message, error: this `${..}` does not contain an expression, with the hole underlined. Binding the value to a let on the line before is the way out of one that will not parse.

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 serialization 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 capability row 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.

These words are refused as identifiers and mean nothing yet:

where yield macro unsafe unstable

Using one as a name is an error naming the word, not a syntax error:

error: `yield` is reserved for a future version of Khora and cannot be used as
a name. Nothing uses it yet -- it is held back because Khora has no
editions, so a word claimed after 1.0 would break every program that
had named something with it

They are held back because Khora has no editions and no unstable marker, so a keyword introduced after 1.0 would break every program that had used the word as a name, with no mechanism to migrate one. Reserving a word now costs one name; reserving it afterwards is not possible at all.

A reservation is an option, not a promise. None of these words is scheduled, and the spellings are not fixed — if generators arrive written some other way, yield will have been held back for nothing. The list is short for that reason. They are also not keywords: an editor will not highlight them, because there is no construct to highlight.

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.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource