Turmeric Syntax Guide

This guide is the front door to Turmeric's surface syntax. It teaches you to read and write Turmeric source in both dialects:

  1. The default S-expression dialect used by .tur files. SRFI-105 curly-infix is enabled here too -- {a + b} reads as (+ a b) in every dialect, no #lang directive required.
  2. The sweet-expression dialect activated by #lang turmeric/sweet (legacy alias: #lang sweet-exp) or a .tur.sweet extension (indentation
  3. neoteric + $ + curly-infix).

The #lang line also carries an optional set of additive layers after the base dialect (e.g. #lang turmeric stringed); see Part 2.5.

It does not re-explain the semantics of every special form -- the deep-dive guides own that. Instead it shows you the shape of the language and points you at the right reference for the details.

Throughout, code is shown in paired blocks: a turmeric block followed by its sweet-exp equivalent. Use the toggle above each pair to switch between them.


Part 1 -- S-expression syntax

The default dialect is a Lisp. Every program is a tree of forms: atoms (numbers, strings, symbols, keywords) and lists that group them.

Lexical conventions

Identifiers are symbols like square, vec-push!, option-some?, or make-adder. Conventionally, a trailing ! marks mutation (vec-push!) and a trailing ? marks a predicate (option-none?). Operators like +, -, *, <=, and = are ordinary identifiers used in prefix position.

Keywords start with a colon: :int, :else, :name. They are self-evaluating and most often appear as type annotations, cond fallbacks, and map keys.

Numbers include integers (42, -5), floats (3.14, 0.1), and the radix forms documented in the reader reference -- hex (0xFF), binary (0b1010), and octal (0o755).

Strings are double-quoted with C-style escapes ("line\n", "tab\tend", "quote\"inside").

ASCII only. Source must be ASCII -- the reader hangs on non-ASCII bytes such as UTF-8 em dashes. Always write -- (double hyphen), never an em dash.

For the exhaustive catalogue of literal forms (radix integers, character escapes, keyword rules), see the Reader Forms Reference.

Comments

(println "hi")  ; line comment -- to end of line

#|
  Block comment. These nest:
  #| inner |#
|#
(println "ok")

Doc comments use the triple-semicolon marker ;;; and sit immediately above a defn, defmacro, defstruct, or definstance. The docstring format (its required Parameters:/Returns:/Example:/Since: sections) is specified in the project's docstring standard. See the Reader Forms Reference for the full comment catalogue including the planned datum comment #;.

The form tree

Turmeric has four bracketed containers, each legal in specific positions:

Form Syntax Where it is legal
List (f x y) everywhere -- the universal form
Vector [a b c] binding position (let/defn params); expression position
Map literal #map{:k v} expression position
Set literal #set{a b} expression position

In binding position [...] is a binding spec (parameter list or let bindings). In expression position, [...] lowers to (vec-of ...). See the Data Literals Guide for the literal collection semantics.

Function application

A list evaluates as a call: the head is the function, the rest are arguments. Zero-argument calls are just an empty-argument list.

(+ 1 2)            ; => 3
(square 9)         ; => 81
(vec-new)          ; zero-arg call

Special forms you will see first

These are the forms a newcomer meets immediately. This guide only shows their shape; follow the links for semantics.

def and defn -- bind a value or a function. Parameter and return types are annotated with :type:

(defn square [x : int] : int (* x x))

(square 9)    ; => 81

let -- local bindings scoped to its body:

(let [x 10 y 20] (+ x y))   ; => 30

if -- an expression; both branches required:

(defn abs [n : int] : int
  (if (< n 0) (- 0 n) n))

cond -- ordered multi-way branch with an :else fallback:

(defn sign [n : int] : int
  (cond (> n 0) 1
        (< n 0) -1
        :else   0))

when -- one-armed conditional for side effects:

(when (< x 0) (println "negative"))

fn -- an anonymous function capturing its lexical scope:

(let [add5 (fn [x : int] : int (+ x 5))]
  (add5 10))    ; => 15

for -- bind a counter over a half-open range:

(for i 0 5 (println i))

Other forms you will meet -- do (sequence side effects), while (loop while a condition holds), import/export (module wiring), defstruct (record types), and defmacro (syntax extension). Each has a dedicated guide:

Type-annotation syntax

Types appear after a name with a leading colon. In a parameter list each parameter is followed by its type; the return type follows the closing ] of the parameter vector:

(defn add [a : int b : int] : int (+ a b))

Compound types use parenthesised constructors -- (vec T), (-> a b), forall, and friends. A variadic tail is written & rest :T. The full compound-annotation grammar is in the Type Annotations Guide; variadic rules are in the function-arity section of the project conventions.

Naming a type -- defalias vs deftype

Two forms bind a type name, and they do different things.

defalias is the transparent alias. The name and its target are the same type everywhere, so the alias never shows up in unification -- it is a readability tool, not a new type. The target can be any type expression:

(defalias Sample    :int)                            ; primitive
(defalias IntList   (Cons int))                      ; type application
(defalias Point     P)                               ; struct / ADT name
(defalias Backtrack (fn [] int))                     ; function type
(defalias NonZero   #refine{ q : int | (not= q 0) }) ; refinement

Because it is transparent, (defn f [b : Backtrack] : int (b)) applies b exactly as (fn [] int) would, and a Point is accepted anywhere a P is.

deftype is the recursive type binder. It wraps its body in a recursive type, which is what Fix/Free need, and takes its parameters as a bracket vector:

(deftype Fix [^f] (f (Fix f)))

That wrapping makes deftype the wrong tool for naming a non-recursive type: the bound name is a distinct nominal type, so use sites fail to unify with the body. Reach for defalias there. The one exception is a bare refinement body, which deftype binds transparently for stdlib/refine.tur's benefit -- defalias handles that case too, and is the clearer spelling in new code.

Neither form takes alias type parameters: (defalias Name [a] ...) is an error, and a deftype's parameters belong to the recursive type, not to an alias. For a named parametric shape, use defstruct/defdata.

Two more forms are adjacent but distinct: defopaque makes a nominal newtype (deliberately not interchangeable with its representation), and defstruct declares a record.

Indentation conventions

Turmeric source follows Clojure-style indentation.

Regular calls align arguments under the first argument:

(some-long-fn arg1
              arg2
              arg3)

Special forms and macros (defn, fn, let, if, when, do, cond, for, while) use a fixed 2-space body indent regardless of column:

(defn greet [name :cstr] :void
  (println name))

Binding vectors align pairs under one another, one pair per line. Never split a name from its value across lines:

(let [x   1
      y   2
      foo (+ x y)]
  foo)

The canonical formatter enforces these rules automatically -- see Canonical formatting below.

Inline-C blocks

Turmeric can embed C in a fenced ```c ... ``` block inside a defn body. The closing triple-backtick must share its line with the enclosing ) -- written ```) -- so that Markdown renderers do not mistake it for the end of a surrounding documentation code fence:

(defn file-size [f] :int
  ```c
  FILE* file = (FILE*)f;
  return (int)ftell(file);
  ```)

See the C Integration Guide for FFI details and the Reader Forms Reference for the fence grammar.


Part 2 -- Sweet-expression syntax

Sweet-expressions are an alternate surface syntax that removes most parentheses without changing the underlying form tree. Every sweet-exp program reads to the same AST as its s-expression equivalent -- the toggle widgets in this guide are verified to be parse-equal.

Opting in

Activate sweet-exp one of two ways:

  • A #lang sweet-exp directive on the first line of a .tur file.
  • A .tur.sweet file extension.

A complete runnable snippet opens with the directive:

(defn main [] :int 0)

Inline fragments in prose omit the directive (as the paired examples in this guide do). If the directive is absent and the file is not .tur.sweet, the reader stays in plain s-expression mode and treats indentation as insignificant.

Both activations work the same way whether the file is the one you compile or one pulled in by (load "..."): a loaded file's dialect is read from its own first line and its own extension, independently of whatever dialect the loading file is written in. When both are present the extension picks the base dialect and the directive is a redundant hint; layers on the #lang line apply either way.

The three tools

Sweet-exp gives you three independent tools. Use whichever reduces noise for a given expression; they compose freely.

1. Indentation (t-expr) -- a leading-whitespace block replaces the outer (...) of a form. Use it for top-level forms and multi-line bodies:

(defn factorial [n : int] : int
  (if (<= n 1) 1 (* n (factorial (- n 1)))))

2. Neoteric f(x y) -- replaces (f x y) for inline calls. The opening paren must touch the function name with no space. Operators work too -- +(x y) is (+ x y):

(println (vec-len v))

3. Rest-of-line $ -- replaces the outer (...) when a line's only argument is itself a single nested call. Prefer it over neoteric when the outer call takes exactly one argument:

(println (vec-get squares i))

$ wraps everything to its right in one pair of parens -- but only when the rest of the line is a bare token sequence that needs the wrap. When the rest is already one complete delimited expression -- a neoteric call, a parenthesised form, a curly-infix group, a data literal -- the wrap is suppressed, so $ composes with the other two tools instead of double-applying them:

Rest-of-line shape Written Reads as
bare token sequence println $ vec-get squares i (println (vec-get squares i))
neoteric call println $ vec-get(squares i) (println (vec-get squares i))
parenthesised form println $ (vec-get squares i) (println (vec-get squares i))
curly-infix group println $ {a + b} (println (+ a b))
chained $ println $ normalize $ vec3(x y z) (println (normalize (vec3 x y z)))

A bare atom after $ is still wrapped, per SRFI-110 -- f $ g reads as (f (g)), a zero-argument call, not (f g). Pass a function value with plain juxtaposition (f(g) or (f g)) rather than $.

Curly-infix {a op b}

Arithmetic and comparison read more naturally in infix. {a + b} lowers to (+ a b); nesting makes precedence visual:

(let [hyp (sqrt (+ (* a a) (* b b)))] hyp)

Data literals inside sweet-exp

The #map{...}, #set{...}, and [...] literals work transparently inside sweet-exp -- the reader dispatch sits below the sweet-exp layer, so neoteric and curly-infix compose inside a literal. See the Data Literals Guide for the full semantics.

What still uses traditional parens

A few forms stay clearer in s-expression syntax even in a sweet-exp file:

Mixing styles

You can mix s-expressions and sweet-exp tools within one sweet-exp file -- the two share a form tree, so a traditional (...) list is always legal inside an indented block, and neoteric/$/curly-infix may appear anywhere an expression is expected. The guidance above (when to fall back to parens) is a style recommendation, not a parser restriction. The canonical formatter normalises plain .tur files but does not rewrite a sweet-exp file into s-expressions or vice versa -- the dialect is a property of the source you choose to write.

Side-by-side: a complete function

Here is make-adder -- a function returning a closure -- shown in both dialects. (Lifted from the quickstart so it stays honest.)

(defn make-adder [n : int] (fn [x : int] : int (+ x n)))

(let [add3 (make-adder 3)
      add7 (make-adder 7)]
  (println (add3 10))    ; 13
  (println (add7 10)))   ; 17

Part 2.5 -- #lang base dialects and layers

A #lang line is more than a dialect switch. Its full shape is:

#lang <base>[/<dialect>] <layer>*

The whole line is read before the first form, so everything that changes how the file reads or checks is declared up front and is guaranteed file-scoped.

Base dialect (mutually exclusive)

The first, possibly slash-namespaced, token picks exactly one base reader:

Base Reader
turmeric plain s-expression (the default; curly-infix is always on)
turmeric/curly-infix curly-infix emphasis (same as the default)
turmeric/neoteric curly-infix + neoteric f(x)
turmeric/sweet full sweet-expressions (indentation + neoteric + $)

turmeric/sweet is the preferred spelling for the sweet-exp base. The older #lang sweet-exp is still accepted as a legacy alias, so #lang sweet-exp and #lang turmeric/sweet are equivalent -- migrate to the slash-namespaced form when convenient. A .tur.sweet extension selects the sweet base without any directive.

Bases do not compose (sweet-exp is a whole indentation pass; curly/neoteric are flags on the same reader), which is exactly why they share the one slash-named slot.

Layers (an additive, order-independent set)

The space-separated tokens after the base are layers: a set, not a pipeline. Order does not matter, and each layer is either a reader layer (it flips on a #-dispatch) or a semantic layer (it flips on an elaboration/checker gate). Layers are a small, curated set -- an arbitrary one-off macro bundle still belongs in a #use-reader-macros file, not here.

The reader layer available today is stringed, which turns on the #s"..." owned-String literal with no #use-reader-macros directive:

#lang turmeric stringed
(load "stdlib/string.tur")
(defn main [] : int
  (let [g #s"hello"]        ; owned String, not a borrowed cstr
    (string/len g)))

Because layers ride alongside the base, they compose with any dialect -- #lang turmeric/sweet stringed gives sweet-exp and #s"...":

#lang turmeric/sweet stringed
$ load "stdlib/string.tur"
defn main [] : int
  string/len(#s"hello")

There is no semantic layer today. Static discharge of #refine{...} predicates is unconditional (the former refined layer graduated), so there is nothing for the token to turn on. A file that still carries #lang turmeric refined keeps compiling -- the token is accepted and ignored with a one-time TUR-W0064 -- but it can be dropped. See refinement-types-guide.md.

When a semantic layer does exist, it is never a second enable path: it points at an existing EXPERIMENTS[] row, so #lang turmeric <name> is exactly --enable=<name> scoped to one file, and the experiment's lifecycle warning and expires_at govern both spellings. If a project manifest states its own :experiments list and leaves the backing experiment out, such a file is a hard error rather than a silent downgrade -- compiling it under different semantics than it asked for would be worse than refusing.

A #lang layer is a hard requirement of the file: an unrecognised layer token is a compile error (TUR-E0330), never silently ignored. A graduated token is the one exception, and deliberately so -- deleting the row on graduation would otherwise break every file that opted in, which is the wrong population to break. Run tur lang-layers (add --json for the machine-readable form) to list every registered layer, its kind, and a one-line summary.


Part 3 -- Reference appendix

Form cheat sheet

One row per construct: the s-expr form, its sweet-exp form, and a one-line gloss.

Construct S-expression Sweet-expression Gloss
Call (f x y) f(x y) function application
Operator (+ x y) {x + y} or +(x y) infix arithmetic / prefix op
One-arg call (f (g x)) f $ g(x) rest-of-line argument
Define fn (defn f [x :int] :int ...) defn f [x :int] :int + indent named function
Anon fn (fn [x :int] :int ...) fn([x :int] :int ...) closure
Local binding (let [x 1] ...) let [x 1] + indent scoped names
Conditional (if c a b) if c + indent two-armed expression
Multi-branch (cond p1 e1 :else e) cond + indented pairs ordered dispatch
Side-effect guard (when c ...) when c + indent one-armed conditional
Counted loop (for i 0 n ...) for i 0 n ... range iteration
Sequence (do a b) do + indent evaluate in order
Vector literal [a b c] [a b c] growable array
Map literal #map{:k v} #map{:k v} HAMT map
Set literal #set{a b} #set{a b} set

Where to go deeper

Topic In-depth guide
Every reader form / literal grammar reader-forms-guide.md
Data literal semantics data-literals-guide.md
Local binding forms binding-forms-guide.md
Compound type annotations type-annotations-guide.md
Structs structs-guide.md
Modules / import / export module-system-guide.md
C interop / inline-C c-integration-guide.md
Canonical formatting formatter-guide.md
End-to-end prose tour quickstart.md

Canonical formatting

tur format rewrites a file into canonical layout -- applying the indentation rules above so you never have to hand-align. Use --check in CI to fail on drift:

tur format myfile.tur          # rewrite in place (via redirect)
tur format --check myfile.tur  # exit non-zero if not canonical

A before/after gives the flavour -- ragged input on the left, canonical output on the right:

;; before
(defn add [a :int b :int] :int
    (+ a
  b))

;; after `tur format`
(defn add [a :int b :int] :int
  (+ a b))

See the Formatter Guide for the complete rule set and editor integration.

Common mistakes

A short list of pitfalls newcomers hit:

  1. Inline-C fence on its own line. The closing ``` must share a line with ) (written ```)); a lone ``` breaks Markdown rendering.
  2. Splitting a binding pair across lines. Keep each name value (or name :type value) on one line in a let/loop vector.
  3. A space before a neoteric paren. f (x) is not neoteric application -- it reads as f followed by (x). Write f(x).
  4. Non-ASCII bytes. An em dash or smart quote pasted from a doc will hang the reader. Use -- and straight quotes only.
  5. Expecting #lang sweet-exp to enable neoteric in a plain file. Without the directive (or a .tur.sweet extension) the reader stays in s-expression mode and indentation is insignificant.
  6. Mixing up [...] positions. [...] is a value (lowers to vec-of) in expression position and a binding spec (parameter list / let bindings) in binding position -- the context determines which.
  7. Naming a definition after a special form. (defn return ...), (defn match ...), (defmacro open ...) are accepted, but a bare call site dispatches to the form, never to your definition. See Reserved names below -- the compiler flags this as TUR-W0042 at the definition.
  8. An expression at defmodule top level. There is no module-level side-effect position; see What may appear inside a defmodule.
  9. A definition as the last form of a function body. Legal anywhere else in the body, but not last -- see Definitions inside a function body.

Definitions inside a function body

A defn (or any def* form) may appear inside a function body. It is lifted to file scope and stays callable by name from the enclosing function:

(defn outer [] : int
  (defn inner [x : int] : int (+ x 1))
  (inner 5))                  ; => 6

It may not be the last form, unless the function owes no value. A definition produces nothing, so a function declared : int whose body ends with one has nothing to return -- that is TUR-E0713:

(defn outer [] : int
  (let [x (helper)] x)
  (defn other [] : int 7))    ; error [TUR-E0713]

In practice this is almost always a missing close paren: outer was meant to end after x, and everything following it got swallowed into its body. An editor will happily reindent around the slip, so the diagnostic names it. Until 2026-08-29 this was silent -- the function returned 0, which is a successful exit status, a false, and a "nothing found" all at once.

A : nil / : void function may end with a definition, since it owes no value.

What may appear inside a defmodule

A (defmodule ...) body is a list of definitions, not statements. A non-definition form as a direct child is rejected with TUR-E0711:

(defmodule dt
  (defn shout [] : int (println "hi") 1)
  (shout)                     ; error [TUR-E0711] -- never evaluated
  (defn main [] : int 0))

There is no module-level side-effect position, so put the call in main (or a function main calls). This was silent until 2026-08-29: the form was fully elaborated -- type errors inside it reported like live code -- and then dropped from codegen, which cost 12 spices their entire test suites without a single warning.

Legal as a direct child:

Form Note
defn defmacro defmacro*
defstruct defdata defopaque deftype defalias
defclass definstance defeffect
def a module-level global; its initializer does run
extern-c
import export export-from must come first, before any definition
a bare ```c block supplies file-scope C declarations
defer runs at process exit, not at module load

The last two look like expressions and are deliberate. defer is the only non-definition form that executes.

The rule is applied to the form after macro expansion, so a macro that expands to a definition is fine wherever the definition would be:

(defmacro make-adder [name n]
  `(defn ,name [x : int] : int (+ x ,n)))

(make-adder add5 5)           ; OK -- expands to a defn

Reserved names

A call head is matched against the special forms by name, before any binding, macro, or typeclass-method lookup. Naming a defn or defmacro after one of them is accepted and the binding is created, but every bare (name ...) call site elaborates as the form, so the definition is unreachable by its bare name. The compiler emits TUR-W0042 at the definition; run tur explain TUR-W0042 for the full write-up.

return is the one that bites most often -- it is the conventional name for a monadic unit, and (return x) is always the early-return form. Use pure.

Reserved in call-head position:

Group Names
Binding / control def define let let* letrec if do unsafe set! while case defer return match quote gensym ? -> ->>
Definition forms defn fn (and its Greek-letter lambda alias) extern-c defmacro defmodule import export load defstruct make-struct defopaque defdata defgadt defclass definstance defkind defrec deftype defalias defdynamic defeffect defprotocol
Generators gen yield gen-next gen-done?
References / rc / weak ref deref drop! ref? weak weak? upgrade lref/new rc/of rc/clone rc/drop rc->ptr rc/strong-count rc/from-ref ref/from-rc
Continuations reset shift shift0 call/cc call/cc* escape cloneable-reset cloneable-shift serial-reset serial-shift cont?
Effects binding perform handle handle-shallow try-with with-handler resume discontinue compose-handlers
Types / casts as type-of cast is? coerce & &mut forall exists type-app :: pack open
Sessions make-protocol make-session
Panic panic panic-with catch-unwind catch-panic-of panic-payload-type panic-payload-value panic-payload-file panic-payload-line panic-payload-downcast
Unsafe / FFI ptr-deref ptr-write ptr-add ptr-sub ptr-null? ptr-of unsafe-cast reinterpret transmute array-get-unchecked array-set-unchecked raw-malloc raw-free raw-realloc raw-memcpy raw-memset c-call dlopen dlsym dlclose
STM / GC stm retry gc! gc-enable! gc-disable! gc-auto! gc-collections gc-objects-freed gc-live-blocks gc-candidate-high-water

A leading . is reserved too: (.method obj ...) is method-call syntax, so a definition named .foo is never reachable as a call head.

Not reserved -- these are deliberately shadowable. A user definition of handler, with, default-of, or a session op (send recv close offer send-to recv-from choose-left choose-right recv-timeout) wins over the form, and the map surface (map-new assoc dissoc get has? count merge) falls back to ordinary call resolution. The arity-gated forms (async await select atomically check or-else thread-spawn and the tvar-* ops) intercept only one call shape, so a definition at a different arity is genuinely callable -- no warning is emitted for those, and the shadowing is worth avoiding anyway.

Inside a defmodule, a shadowing definition remains reachable through its qualified name (mymod/return) -- a qualified head symbol never matches a special form -- but the bare name stays shadowed, so renaming is still better.