Developing Spices Guide

A spice is a Turmeric package that other projects depend on. This guide covers every step of authoring one: project structure, declaring exports, wrapping C libraries with :cmake-deps, testing, versioning, and publishing.

See Consuming Spices if you only need to add an existing spice to your project.


When to Create a Spice

Create a spice when you want to:

For code shared only within a single project, use the module system -- no separate package is needed.


Scaffolding the Project

tur new tur-mylib --lib
cd tur-mylib

This creates:

tur-mylib/
  build.tur        -- package manifest
  tur.lock         -- empty lock file (commit to VCS)
  src/
    lib.tur        -- stub exported function
  .gitignore
  README.md

By convention, name the package with a tur- prefix. Consumers drop the prefix as the import alias: tur-geom becomes geom, tur-math becomes math.


The build.tur Manifest

The manifest filename can be either build.tur (plain s-expression) or build.tur.sweet (sweet-exp syntax). Both are equivalent everywhere the toolchain consults the manifest -- walk-up discovery, workspace member resolution, transitive :spices deps, and the cwd-relative tur add / tur fetch paths all accept either. When both files exist in the same directory the plain build.tur takes precedence. tur init --sweet scaffolds the sweet variant.

A complete library manifest:

(defpackage tur-mylib
  :name        "tur-mylib"
  :version     "0.1.0"
  :description "A brief description of my library"
  :license     "MIT"
  :authors     ["Your Name <you@example.com>"]
  :repository  "https://github.com/you/tur-mylib"

  :exports #map{
    "mylib/core" ["some-fn" "another-fn"]
    "mylib/util" ["helper-fn"]
  })

Key manifest fields

Field Required Notes
:name Yes Must match [a-z][a-z0-9-]*
:version Yes Semver: MAJOR.MINOR.PATCH
:tur-version Recommended Which compiler versions this spice works with -- see Declaring a compiler version range
:description Recommended One-line summary
:license Recommended SPDX identifier (e.g. "MIT")
:authors Recommended "Name <email>" list
:repository Recommended URL to the canonical Git repo
:exports Yes (library) Map of module path to exported symbol names
:spices If needed Turmeric package dependencies
:cmake-deps If needed C/C++ library dependencies
:build-opts Rarely :c-flags / :link-libs / :link-flags, plus :c-sources / :c-includes for vendored C

Declaring a compiler version range (:tur-version)

:version is your version. :tur-version is which tur versions your spice is valid under -- a different question, and the one consumers hit:

(defpackage my-spice
  :version     "0.4.0"
  :tur-version ">=0.32.2"      ; needs the :sealed defopaque attribute
  ...)

Declare it whenever you adopt anything version-dependent: new syntax, a :experiments entry, or a manifest key. Without it, a consumer on an older compiler gets an error about your source -- a caret under a line that is perfectly correct, with nothing suggesting an upgrade:

error: defopaque: unexpected attribute -- expected :linear or :affine
  |
1 | (defopaque RGWorld :int :sealed)
  |                         ^^^^^^^

With a floor declared, they get the actual problem instead (TUR-E0621), naming the required range and the running version.

Syntax

Comma-separated conjuncts; all must hold. Each is a comparator or a caret:

Form Means
">=0.32.2" a floor -- the common case
">=0.32.2, <0.35.0" floor and ceiling
"^0.32.2" compatible-update range (see below)
"0.32.2" exactly that version (rarely what you want)

~, *, and || are not supported and are an error rather than a silent no-op, so a typo cannot quietly become a different constraint.

The caret's 0.x rule is the part that surprises people. ^X.Y.Z means "from X.Y.Z up to the next version that could break you" -- and for a pre-1.0 version that is the next minor, because 0.x minors are breaking by convention:

Since tur is pre-1.0, ^0.32.2 is a fairly tight constraint. Prefer a plain floor (">=0.32.2") unless you specifically want to exclude the next minor.

Floors are errors, ceilings are warnings

Situation Result
Range satisfied silent
Compiler below the floor TUR-E0621, hard error, non-zero exit
Compiler above the ceiling TUR-W0623, warning, build continues
Range malformed TUR-E0622, hard error

The asymmetry is deliberate. Below a floor, your code genuinely will not work. Above a ceiling, it merely has not been tested -- which is usually fine, and making it fatal would mean every compiler release breaks every spice until each author bumps a number. So declare a ceiling to record what you tested, not to prevent use.

Note on adoption

The key can only diagnose skew against compilers that already know about it (0.32.2+); older ones ignore unknown manifest keys silently. So declaring it helps the next consumer, not the one already on an old compiler -- which is a reason to add it early rather than when you first need it.

This is also distinct from tvm's .tur-version file, which pins which compiler to install in a directory. :tur-version states which compilers your source is valid under; a spice consumed as a dependency has no say over the former.


Declaring Exports

The :exports map controls what is visible to consumers. Only listed symbols are part of the public API; everything else is private.

:exports #map{
  "mylib/types" ["Coord" "Rect" "Color"]
  "mylib/draw"  ["draw-rect" "draw-circle" "draw-line"]
  "mylib/io"    ["read-file" "write-file"]
}

Each key ("mylib/types") becomes an importable path for consumers:

(import mylib/types :refer [Coord Rect])
(import mylib/draw  :refer [draw-rect])

Internal helpers that should not be exposed are simply omitted from :exports.


Source Layout

Follow this layout so that module paths and file paths align without extra configuration:

tur-mylib/
  build.tur
  tur.lock
  src/
    types.tur       -- exports "mylib/types"
    draw.tur        -- exports "mylib/draw"
    io.tur          -- exports "mylib/io"
    internal/
      helpers.tur   -- not exported; only imported by other src files
  tests/
    types_test.tur
    draw_test.tur

The module path "mylib/types" resolves to src/types.tur. A nested path "mylib/net/http" resolves to src/net/http.tur.


Multi-file Spices: defmodule + import

When a spice spans more than one source file, use defmodule with an explicit (export ...) list and (import <other-module> :refer [...]) to wire the files together. The module system handles cross-file symbol sharing correctly at every scale.

The correct approach

Each file declares its own defmodule, exports what it offers, and imports what it needs from siblings:

;; src/mylib/types.tur
(defmodule mylib/types
  (export Widget WidgetResult)
  (defstruct Widget [value :int])
  ...)
;; src/mylib/core.tur
(defmodule mylib/core
  (export make-widget widget-value)
  (import mylib/types :refer [Widget WidgetResult])

  (defn make-widget [v :int] :Widget ...)
  (defn widget-value [w :Widget] :int ...))

tur check, tur emit-c, and tur build all resolve intra-spice imports automatically through the auto-spice include path -- no -I flags needed. See Per-file Commands Inside a Spice.

Anti-pattern: cross-file type stubs

An older pattern copies function bodies across files so each file compiles in isolation without imports:

;; src/mylib/io.tur -- ANTI-PATTERN
;; Stub copy of make-widget from core.tur; real body is return NULL.
(defn make-widget [v :int] #fx{Unsafe} :Widget
  ```c
  return NULL;
  ```)

(defn read-widget [path :cstr] :Widget ...)  ;; real implementation

This pattern fails in three ways:

If you encounter the stub pattern in an existing spice, the fix is mechanical: add a (defmodule ...) + (export ...) header to each file and replace each stub block with (import <module> :refer [...]).


Depending on Other Spices

Add Turmeric spice dependencies the same way any project does:

tur add https://github.com/turmeric-lang/turmeric-spices \
  --ref math-v0.1.0 --subdir spices/math --name math

This produces:

:spices #map{
  "math" #map{:url    "https://github.com/turmeric-lang/turmeric-spices"
              :ref    "math-v0.1.0"
              :subdir "spices/math"}
}

Mark spices that are only needed for tests :optional true so consumers are not forced to fetch them:

:spices #map{
  "test" #map{:url    "https://github.com/turmeric-lang/turmeric-spices"
              :ref    "test-v0.1.0"
              :subdir "spices/test"
              :optional true}
}

Cross-spice Development in a Workspace

When your spices live in the same workspace (a parent directory with a build.tur that lists all members under :members), you can import one sibling from another without publishing a release or running tur fetch.

Option A: workspace-member auto-resolution (no manifest entry needed)

If both spices are already listed in the workspace build.tur:

;; turmeric-spices/build.tur
(defpackage turmeric-spices
  :members ["spices/watch" "spices/notebook" ...])

Then notebook can (import watch/watch ...) directly -- the resolver walks up to the workspace build.tur, finds that watch is a listed member, and adds its src/ to the search path automatically.

# No tur fetch or symlink, and no lock entry required:
cd spices/notebook
tur check src/notebook/cli.tur   # resolves watch/watch via workspace

The first time an undeclared sibling import resolves, tur prints a one-time advisory:

warning: import 'watch/watch' resolved via workspace sibling
         'spices/watch'; declare it in :spices for release builds.
         (set TUR_DEBUG_RESOLVER=1 for full resolver tracing)

To confirm a name is a workspace member before importing:

tur add --workspace watch   # exits 0, prints "no manifest entry needed"
tur add --workspace typo    # fails if 'typo' is not in :members

Option B: explicit :path dep (works outside workspaces too)

Add a :path entry pointing at the sibling spice directory:

tur add ../watch --path

This writes to build.tur:

:spices #map{
  "watch" #map{:path "../watch"}
}

The resolver immediately adds ../watch/src to the include path. No tur fetch step is required, and no lock entry is written for this dep. The path is resolved relative to the directory containing build.tur.

tur check src/notebook/cli.tur   # resolves watch/watch via :path

If the declared :path does not exist on disk (or contains no build.tur), tur fetch reports a hard error rather than silently ignoring the missing dep.

Cycles between local spices are legal. Two :path-local or workspace-sibling spices may declare each other in :spices; this is supported, not a diagnostic. The transitive include-path walk terminates on a visited set keyed by each package root's realpath, so a mutual cycle is traversed once and both spices' src/ still land on the include path. Do not "fix" this shape into an error.

tur fetch --dry-run for verification

To confirm how each dep will be classified before committing to a real fetch:

tur fetch --dry-run
# skip :path          watch
# skip workspace member  alpha
# fetch  URL          ansi  https://github.com/...  ansi-v0.1.4
# summary: 1 fetch, 2 skipped (local)

Local-source deps are never contacted or locked. Only URL deps appear in tur.lock.

Release: switch to a URL dep for external consumers

The workspace auto-resolution and :path entries are local-development shortcuts. For a spice that will be published and consumed outside the workspace, add a URL entry alongside (or instead of) the local one:

:spices #map{
  "watch" #map{:url    "https://github.com/turmeric-lang/turmeric-spices"
               :ref    "watch-v0.1.0"
               :subdir "spices/watch"}
}

External consumers run tur fetch once to clone the ref and populate tur.lock. Inside the workspace, a URL :spices entry whose name matches a workspace member is resolved locally instead -- no spurious fetch occurs for deps you are developing in the same workspace.


Wrapping a C Library with :cmake-deps

The :cmake-deps block tells tur build to fetch and compile a C library via CMake, then inject the resulting include dirs and link flags into the compilation step. You write only Turmeric; CMake is an implementation detail.

Declaring the dependency

:cmake-deps #map{
  "sqlite3" #map{:url     "https://github.com/sqlite/sqlite"
                 :ref     "version-3.47.2"
                 :options #map{:BUILD_SHARED_LIBS "OFF"}}
}

:options sets CMake cache variables (-DKEY=VALUE). Common patterns:

Declaring C symbols in Turmeric

Use include-c to pull in the C header and extern-c to declare symbols:

;; src/db.tur
(include-c "sqlite3.h")

(extern-c sqlite3_open   [:cstr :ptr] :int)
(extern-c sqlite3_close  [:ptr]       :int)
(extern-c sqlite3_exec   [:ptr :cstr :ptr :ptr :ptr] :int)
(extern-c sqlite3_errmsg [:ptr] :cstr)

(defn db-open [path :cstr] (result :ptr)
  (let [db-ptr (make-ptr)
        rc     (sqlite3_open path db-ptr)]
    (if (= rc 0)
      (ok (deref-ptr db-ptr))
      (err (cstr->str (sqlite3_errmsg (deref-ptr db-ptr)))))))

extern-c trusts the signature you give it -- double-check it against the actual C header. No -I or -L flags are needed in your source; tur build injects them from cmake/spice-deps-manifest.json.

Constants: #defines do not survive linking -- re-export them

A C library's #define ZMQ_REP 4 / SQLITE_OK 0 constants never reach a symbol table, so neither extern-c nor the dynamic FFI can resolve them -- consumers of your spice (and REPL users) would otherwise have to restate magic numbers. Wrap each constant the library's API needs as a plain definition, adjacent to the extern-c block it belongs to so drift against the upstream header stays reviewable in one place:

;; sqlite3.h result codes the API surface uses (keep next to the externs).
(defn SQLITE-OK   [] :int 0)
(defn SQLITE-ROW  [] :int 100)
(defn SQLITE-DONE [] :int 101)

Exported like any other defn, these are callable from consumers and from the REPL, and a header bump that renumbers something is a one-line, reviewable diff instead of a scavenger hunt through call sites.

Inline C for small wrappers

When a binding is simpler to write directly in C, use an inline-C block:

(defn db-last-insert-rowid [db :ptr] :int
  ```c
  return (int)sqlite3_last_insert_rowid((sqlite3*)db);
  ```)

The closing ``` and its enclosing ) must be on the same line. See the inline-C style rule for why.

:cmake-name and :targets overrides

When the CMake find_package name or target name differs from the key in :cmake-deps, supply overrides:

:cmake-deps #map{
  "sqlite" #map{:url        "https://github.com/sqlite/sqlite"
                :ref        "version-3.47.2"
                :cmake-name "SQLite3"
                :targets    ["SQLite::SQLite3"]}
}

When a dep declares :targets, tur asks CMake what each target actually is rather than guessing from its name. Two generator expressions do the work, and both are evaluated when cmake/spice-deps-manifest.json is generated:

The INTERFACE_LINK_LIBRARIES walk happens in CMake, at configure time, because its entries cannot be classified afterwards. A bare entry may be a library name (m) or a target name (raylib's property lists glfw) -- the same shape, opposite handling -- and only if(TARGET ...) can tell them apart. The walk:

Those tokens then become cc flags: a flag (-framework Cocoa, -Wl,...) or an absolute path goes through verbatim, and a bare name becomes -l<name>. One translation is applied -- an Apple framework arrives as an absolute path to the .framework directory, which cannot be passed as a link input (ld: file cannot be mmap()ed), so it is respelled -framework <name>.

A shared-library dependency also gets -Wl,-rpath pointing at its build directory, which makes the binary runnable where it was built. That rpath is not relocatable: a binary copied elsewhere still needs its shared dependency installed or bundled.

When the derived link line is wrong, or when there is no target to ask about, override it per dep:

:cmake-deps #map{
  ;; Header-only: contributes include dirs, links nothing.
  "raygui" #map{:url "https://github.com/raysan5/raygui" :ref "4.0"
                :link-libs []}

  ;; Link a specific name instead of the derived artifact.
  "libpq"  #map{:prefer-system true :cmake-name "PostgreSQL"
                :targets   ["PostgreSQL::PostgreSQL"]
                :link-libs ["pq"]}

  ;; Verbatim tokens, no prefix added.
  "audio"  #map{:url "https://example.invalid/audio" :ref "v1"
                :link-flags ["-framework AudioToolbox"]}
}

A project's own link needs the same escape hatch when it has no :cmake-deps entry to hang it on -- for instance inline-C that calls a system framework directly. That spelling is :build-opts :link-flags, the verbatim sibling of :build-opts :link-libs:

:build-opts #map{
  :link-libs  ["m"]                     ;; -lm
  :link-flags ["-framework Foundation"] ;; passed through as written
}

System-first resolution with :prefer-system

Heavy native libraries (mbedTLS, raylib, sqlite, libpq) are often already installed system-wide via Homebrew / apt / dnf. Building them from source on every clean tur fetch is slow. Add :prefer-system true to try CMake's find_package first and fall back to the source build only when no system copy is found:

:cmake-deps #map{
  "mbedtls" #map{:prefer-system true                ;; try find_package first
                 :cmake-name    "MbedTLS"           ;; name passed to find_package
                 :cmake-version "3.0"               ;; optional minimum version
                 :targets       ["MbedTLS::mbedtls"
                                 "MbedTLS::mbedx509"
                                 "MbedTLS::mbedcrypto"]
                 :url           "https://github.com/Mbed-TLS/mbedtls"  ;; fallback
                 :ref           "v3.6.2"
                 :options       #map{:ENABLE_PROGRAMS "OFF"
                                     :USE_STATIC_MBEDTLS_LIBRARY "ON"}}
}

Behaviour:

:cmake-deps #map{
  "libpq" #map{:prefer-system true                  ;; no :url: system-only
               :cmake-name    "PostgreSQL"
               :targets       ["PostgreSQL::PostgreSQL"]
               :options       #map{:PostgreSQL_ROOT "/opt/homebrew/opt/libpq"}}
}

:prefer-system is opt-in; deps without it keep their FetchContent-only behaviour unchanged.

Forcing the source build (--refetch)

To pin a build to the source copy and bypass any system package -- useful for reproducible CI artefacts -- pass --refetch (or set TUR_FETCH_FORCE_FETCH=1 in the environment):

tur fetch --refetch          # ignore system copies, always build from source

This disables the find_package short-circuit for every :prefer-system dep in the manifest that has a :url to build from. A system-only dep (no :url) still resolves through find_package, since there is no source copy to prefer.

Caveats. A binary linked against a Homebrew/apt shared library will fail at runtime on a machine without that library installed; pin the source build (or --refetch) for portable artefacts. See tur-fetch-system-first-plan.md for the design rationale and open questions.

Native dependencies shared across spices

tur collects :cmake-deps from the whole :spices closure into one CMake project. Three rules decide how they combine:

So a spice that needs a different build of a library another spice owns re-declares it with the same :url and :ref and only the options it changes:

;; nng-wss: depends on the nng spice and turns its TLS on.
:spices     #map{"nng" #map{:path "../nng"}}
:cmake-deps #map{
  "nng" #map{:url     "https://github.com/nanomsg/nng"
             :ref     "v1.12.4"
             :options #map{:NNG_ENABLE_TLS "ON"}}}

For the full :cmake-deps field reference, the generated cmake/CMakeLists.txt format, the spice-deps-manifest.json schema, and hash locking, see the CMake/CPM integration notes.


Vendoring C Sources (:c-sources / :c-includes)

:cmake-deps is the right tool for a real native library that already has a CMake (or system-package) presence. But some C is too small for that ceremony: a single-file header library (stb_image, miniaudio), a four-file FFT (KissFFT), a hand-tuned kernel. For those, vendor the .c straight into the spice and let the spice build compile and link it -- no CMake, no fetch step.

Add two keys under :build-opts:

(defpackage tur-signal
  :name "tur-signal"
  :build-opts #map{
    :c-includes ["c/kissfft"]            ;; -I dirs (manifest-relative)
    :c-sources  ["c/kissfft/kiss_fft.c"  ;; .c files compiled + linked
                 "c/kissfft/kiss_fftr.c"
                 "c/glue/fft_shim.c"]
  }
  :exports #map{ "signal/fft" [fft-forward fft-inverse] })

Rules

Layout: vendored C goes under c/, never src/

The manifest-driven build walks src/ looking for .tur modules; do not put .c files there. Keep vendored C in its own c/ tree:

tur-signal/
  build.tur
  src/
    signal/fft.tur        ;; .tur modules (walked by the build)
  c/
    kissfft/
      kiss_fft.c          ;; vendored sources (listed in :c-sources)
      kiss_fft.h          ;; vendored headers (dir listed in :c-includes)
    glue/
      fft_shim.c

When to reach for this vs :cmake-deps

Vendor a .c when the library is small, single-purpose, and has no native package-manager presence -- you would otherwise be copy-pasting a header into an inline-C block anyway. Reach for :cmake-deps when the library is large, has its own build system, or is commonly installed system-wide (where :prefer-system saves a source build). If a library needs CMake or autotools to configure itself, it is not a vendoring candidate.


Writing the Public API

Every exported symbol should have a ;;; docstring immediately above its definition. The standard format (from CLAUDE.md):

;;; db-open -- open an SQLite database file.
;;;
;;; Parameters:
;;;   path -- filesystem path to the database file (created if absent)
;;;
;;; Returns:
;;;   (ok db) on success, (err message) if the file cannot be opened
;;;
;;; Example:
;;;   (match (db-open "app.db")
;;;     (ok db) (println "opened")
;;;     (err m) (do (println "failed:") (println m)))
;;;
;;; Since: Phase P2
(defn db-open [path :cstr] (result :ptr)
  ...)

Exported symbols without docstrings will be omitted from tur run docs output.

Module docstring (optional)

A spice module can optionally include a module-level docstring at the very top of the file, before the first defn, defmacro, defstruct, definstance, or defopaque. Place a contiguous ;;; block followed by a ;; comment line (which acts as the separator):

;;; myspice/db -- SQLite database bindings.
;;;
;;; Thin wrapper around libsqlite3; provides open/close/query/exec with
;;; result-typed error handling.
;;;
;;; Since: Phase P2
;; ---- SQLite bindings ----
(extern-c sqlite3_open ...)

Without a module docstring the page renders with no description block -- the per-symbol cards still appear normally.

Style: avoid cons ... 0 chains in examples

In README quick-starts and docstring examples, do not show runtime list values as (cons x (cons y 0)) chains. The trailing 0 is the nil-of-list footgun -- new readers have no way to tell that 0 means "end of list," and the chain itself reads in reversed nesting order.

Use the list macro instead. It expands to the same tcons/tnil cells, so it's a drop-in for any API that today takes a :int cons list:

;; Avoid
(group-by f (cons "g" 0))
(plot (cons (axes) (cons (function f) 0)))

;; Prefer
(group-by f (list "g"))
(plot (list (axes) (function f)))

Pair-cons ((cons key value) with no trailing 0) is fine -- a two-element pair is a clear, idiomatic data shape. The footgun is the nil-terminated chain.

If your spice API can take a Vec instead of a cons list, prefer that and document it with (vec-of ...). The end goal is for cons to disappear from quick-start surfaces entirely.

Typed variadic rest parameters

A & rest :T parameter type-checks against T even when T is a user-defined type -- a defopaque newtype, struct, ADT, or type application. The rest element type is resolved to its full type and each argument is checked by identity at the call site, so you can give a variadic API a real handle type instead of an untyped :int:

(defopaque Route :int)

;; Each rest arg must be a Route; a raw :int or a different opaque is rejected.
(defn launch [& routes :Route] :ptr<void>
  ...)

Do not declare the rest as :int and cast handles back inside the body -- write the real type (a bare :int rest rejects opaque/struct/ADT values). For an interface that mixes distinct handle types (e.g. middlewares and routes), use two explicit :list<T> parameters rather than one untyped rest -- a single & rest is one homogeneous element type by design.

Typeclass names are global: tag them with your format or domain

A defclass is not scoped by its module's (export ...) list. It resolves globally, across every spice a program loads. Two spices that each declare an obviously-named class -- Encode, Render, Size, Parse -- cannot be used in the same program, and whichever one claims the bare name makes the other look like the special case.

Worse, the method names have to differ too, and for a harder reason than style. Two classes declaring a method of the same name compile with zero diagnostics and dispatch to whichever instance registered last, so reordering two unrelated definstance forms silently changes which implementation runs. There is no ambiguity error today; distinct method names are the only guard.

So: name a class for what it does and for whose it is.

(defclass EncodeJson [a] (encode-json [x] : cstr))    ;; tur-json
(defclass EncodeMp   [a] (encode-mp   [x] : Buf))     ;; tur-msgpack

The two serialization spices are the worked example. They ship the same surface -- format-tagged Encode* / Decode* / Decode*Checked classes, a derive-* family for defstruct / defdata / defopaque, and an accumulate-all-errors checked-decode layer whose vocabulary mirrors stdlib/schema.tur -- and they agree on shape, so a struct is a map/object keyed by field name in both and a sum is externally tagged {"Ctor": {...}} in both. Neither owns the unqualified spelling: tur-json gave it up in 0.4.0 precisely so that a program can derive both codecs for one struct.

Which one to reach for. Pick tur-json when the payload crosses a boundary a human reads or a browser consumes -- config, HTTP APIs, logs. Pick tur-msgpack when both ends are programs and you want the bytes smaller and the parse cheaper -- internal RPC, on-disk records, message queues. The decision surfaces in the types: json hands back a cstr you can print, msgpack hands back an owned Buf, because a binary fragment contains NUL bytes and cstr cannot hold one. tur-msgpack has no native dependency; tur-json builds yyjson.


Testing Your Spice

Add tur-test as an optional dependency and place test files in tests/:

tur add https://github.com/turmeric-lang/turmeric-spices \
  --ref test-v0.1.0 --subdir spices/test --name test

A test file using tur-test:

(import test/assert :refer [assert-eq assert-ok assert-err])
(import test/suite  :refer [describe it])
(import test/runner :refer [run-all])
(import mylib/core  :refer [some-fn])

(describe "some-fn"
  (it "returns the expected value"
    (assert-eq (some-fn 10) 42))
  (it "returns err for invalid input"
    (assert-err (some-fn nil))))

(run-all)

Run the test suite:

tur test

For the full testing API see test-runner-contract.md.


Versioning


Publishing

As a standalone repository

git tag v0.1.0
git push && git push --tags

Consumers then add your spice with:

tur add https://github.com/you/tur-mylib --ref v0.1.0

Contributing to turmeric-spices

The turmeric-spices monorepo accepts spices that meet the bar for the ecosystem. To contribute:

  1. Fork the monorepo and add your spice under spices/<name>/.
  2. Add it to the workspace root build.tur :members list.
  3. Include a README.md and at least one test file.
  4. Open a pull request.

After merging, tag the spice's first release:

git tag math-v0.1.0
git push --tags

Consumers use --subdir spices/<name> when adding:

tur add https://github.com/turmeric-lang/turmeric-spices \
  --ref myspice-v0.1.0 --subdir spices/myspice --name myspice

Spice registry (future)

Once pkg.turmeric-lang.org launches, tur publish will register the package and consumers will use tur add spice/<name> without a Git URL.


Per-file Commands Inside a Spice

tur build <dir> and tur run (project mode) know about the spice they live in because they start by reading build.tur. For tur build <dir> that means: descend into src/ (recursively, including nested src/<pkg>/ trees), skip the manifest itself, compile every module, resolve the include path from the project's own src/ plus each :spices dep's src/, and verify each declared :exports module has a backing source file -- failing loudly otherwise.

Generated .c/.h/.o and the final library/exe land under <spice-root>/build/{obj,bin,lib}/ by default. Override per-build with --build-dir <dir> / -B <dir>, with the TUR_BUILD_DIR env var, or durably with :build-dir "<path>" in build.tur (path is relative to the manifest dir). Precedence runs CLI flag > env > manifest > default. The build dir is auto-created with a .gitignore of *, so its contents never leak into VCS even if the dir itself gets tracked. (See manifest-driven-build-descent-plan.md.)

:engine "cc" | "jit" | "interp" selects the default EXECUTION engine for tur run, on the same ladder: --engine flag > TUR_ENGINE env > manifest > "cc". An unknown value is a hard error (TUR-E0311); a "jit" selection needs a -DTUR_JIT=ON build (the former jit experiment gate has graduated -- only the build-time gate remains). The engines differ in semantics, not just speed (#?(:tur ... :turi ...), inline-C carve-outs), so when the choice is load-bearing, pair it with a :tur-version floor: older binaries silently ignore unknown manifest keys and would run under cc. The per-file subcommands tur check, tur emit-c, tur emit-h, tur build <file>, and tur run <file> get the same module resolution automatically -- they walk up from the input file looking for a sibling build.tur and add that spice's src/ (and its :spices deps' src/) to the include path. The interpreter entry points do too: tur interpret (tur --interpret), tur debug, tur eval --file, and tur run --engine=interp, so a spice's tests/ interpret from the spice root with no flags.

This means editors, format-on-save hooks, LSP clients, and quick "compile this one file" loops work without per-spice configuration:

cd spices/frame
tur check src/frame/frame.tur     # resolves intra-spice imports
tur emit-c src/frame/schema.tur   # same
tur build src/frame/quickstart.tur -o /tmp/qs  # compiles one file, keeps the binary
tur run src/frame/quickstart.tur  # builds and executes

The walk-up is capped at 16 ancestor directories, so a stray build.tur far above your working tree won't accidentally win.

-I for extra directories

Explicit -I <dir> flags still work and take priority over auto-discovered paths -- useful for fixtures, vendored copies, or when you want to test against a different version of a dep:

tur check -I vendor/alternate src/main.tur

-I accepts both the spaced (-I path) and concatenated (-Ipath) forms. For tur interpret and tur debug the flags go before the file: everything after the file is the program's *args*, a -I included.

--no-auto-spice escape hatch

If you need to compile a file as if no spice exists around it (rare -- typically only useful for resolver-fixture tests or when an unrelated build.tur is in the ancestor chain), pass --no-auto-spice:

tur --no-auto-spice check tests/fixtures/resolver/input.tur

This restores the pre-auto-discovery behavior: only the input file's own directory, the stdlib, and any explicit -I paths are searched.

When auto-discovery does not apply


Supporting CMake Consumers

If you want C and C++ projects to consume your spice via CMake or CPM without knowing anything about Turmeric, run:

tur emit-cmake

This reads build.tur and generates CMakeLists.txt, <name>Config.cmake, and helper modules. Commit those files, tag the release, and a CPM consumer can add your library with:

CPMAddPackage(
  NAME    tur-mylib
  URL     https://github.com/you/tur-mylib/archive/refs/tags/v0.1.0.tar.gz
  VERSION 0.1.0
)
target_link_libraries(my_app PRIVATE tur-mylib::all)

For the complete step-by-step see Using a Turmeric library from CMake.


Emscripten / WASM Support

tur build --target wasm compiles via emcc (Emscripten must be installed), and :cmake-deps are configured through emcmake, so a cmake-deps spice gets a WASM build automatically when the underlying C library supports Emscripten. To make your spice Emscripten-compatible:


Global Spices as Libraries

tur install puts a spice in the per-user install area (~/.local/share/turmeric/, or $TUR_HOME / $XDG_DATA_HOME/turmeric) and records it in the registry, state.tur. What it then provides depends on the spice:

A spice with a :bin is runnable before anyone installs it: tur x <url> --ref <tag> [--subdir <dir>] -- <args> fetches, builds and runs it from a cache, and tur x ./my-spice -- <args> builds a checkout in place. See consuming-spices-guide.

Where an installed spice's dependencies live

An installed spice is set up exactly as a fetched project: tur install runs tur fetch in its root, so its whole :spices closure lands in <install root>/spices/ with a tur.lock, and its :cmake-deps (its own and its dependencies') are configured and built under <install root>/cmake/. :bin entries are then built with the include path project mode computes for that root. Nothing goes into the shared spices/ directory next to the installed spices -- that holds installed spices only. tur upgrade re-runs the same pipeline in the same directory.

state.tur records where each install lives (:dir, plus :src-dir when a --subdir puts the manifest below the clone), so tur list, tur uninstall and a :global dep find it even when the manifest's :name differs from the URL's last segment -- the normal case for a turmeric-spices monorepo install without --name. tur uninstall removes exactly that directory.

Two installed spices may not provide the same module

Outside a project every installed spice's src/ is searched (see Using spices without a project), so the installed set behaves like one namespace. A module path is the path of a .tur file under src/ without the extension (json/decode); a :bin entrypoint does not count, since nobody imports a tool's main. If a module the new spice provides already comes from another installed spice, tur install refuses and names both:

tur install: error: module 'json/decode' is already provided by installed spice 'json'

--force installs it anyway with a warning; the spice whose name sorts first then wins. Dependencies are not checked -- they are per-spice and versioned.

Using an installed spice from a project

A project does not see the installed set: a build should not depend on what happens to be installed on the machine. It opts in per dependency, by declaring it :global in its build.tur:

:spices #map{
  "notebook" #map{:global true}
}

That entry resolves through the install registry (state.tur), so the spice's src/ joins the project's module-resolution path and (import notebook/core) works. Four things follow from where it resolves:

Not built: the :global-policy knob that would decide whether a missing global install is auto-installed rather than reported, and version-range validation against the installed version (the registry records a :version, but nothing checks it against a requested range yet).

The original design sketch is in the global-spice-install plan; library installs and the project-less search path are installed-spice-libraries-plan.


Release Checklist


Diagnostics to Watch For

TUR-D0001 -- no leading colons inside (fn ...) types

Leading colons inside a (fn ...) type expression are deprecated and the compiler emits TUR-D0001 wherever they appear. Write the new form when declaring function-typed parameters, return types, or higher-kinded abstractions in your spice:

;; Wrong (TUR-D0001):
(defn map-fn [^fat g :(fn [:int] :int) n :int] :int (g n))

;; Right:
(defn map-fn [^fat g :(fn [int] int) n :int] :int (g n))

The structural name : type colon (the one separating a parameter name from its type) is unaffected -- the rule only forbids colons inside a (fn ...) type. If you are migrating an older spice forward, run tools/rewrite_fn_type_colons.py over your tree, or fix the hits by hand.

Name mangling and inline-C

If your spice exposes any inline-C bodies that reference sibling Turmeric defns by name, use the __TUR_CNAME_<source-name>__ splice rather than hand-spelling the mangled C identifier; the mangling scheme is reversible and injective (#275) but is still an internal detail. See name-mangling-guide.md for the encoding and c-integration-guide.md "Inline C blocks" for the splice form.


See Also