Strings in Turmeric -- cstr vs str vs String

Turmeric has three string-shaped types. They are not interchangeable, and the difference that matters is ownership: who is responsible for the bytes, and how long they stay valid.

The three tiers

Type Owns bytes? Has length? Typeclasses Reach for it when ...
cstr no (borrowed const char*) no (NUL-terminated) Eq, Show string literals, FFI boundaries, static text
str no (borrowed pointer+len view) yes Eq a zero-copy sub-view over a buffer you already own
String yes (owned, immutable, refcounted) yes Eq, Ord, Show, Hash, Clone, MapKey a string that must outlive its source

cstr -- borrowed literal / FFI

cstr is a bare const char*: no stored length, no ownership. It is the type of every "..." literal and the right type at an FFI boundary. Eq[cstr] is a content strcmp (and Show[cstr] copies the bytes into a fresh owned String), so at the scalar level a cstr already behaves like a value -- but it borrows.

Note that the = operator has no cstr overload: (= a b) on two cstr values is a TUR-E0006 operator-lookup error, not a pointer compare. Content equality is (eq? a b) through the auto-loaded Eq[cstr] instance, or (cstr-eq? a b) from stdlib/cstr.tur for the direct call. The moment you store a cstr somewhere that outlives its buffer, or use a computed cstr as a Map/Set key, you have a latent dangling pointer.

str -- borrowed view

str (stdlib/str.tur) is a borrowed pointer+length view: a zero-copy window onto bytes some other buffer owns. Useful for substring-without-copy over a buffer whose lifetime you already control. Still borrowed, still not a safe owning key.

String -- owned, immutable, refcounted

String (stdlib/string.tur) owns its bytes. It is a refcounted immutable heap payload ({ rc; len; bytes[len+1] }, NUL-terminated), so:

The decision, in one line

Use cstr for a literal or an FFI call. Use String the moment the string must outlive the thing that produced it -- a returned value, a stored field, or a collection key. Use str only for a genuine zero-copy sub-view.

Literals stay cstr (that is exactly right for a literal). Do not do a big-bang cstr -> String sweep: most cstr sites are borrowed literals where cstr is correct and String would just add a copy.

Why String keys don't dangle

(load "stdlib/string.tur")

(defn make-key [] : String                 ;; bytes on the heap, not a literal
  (let [b (builder/new)]
    (do (builder/push-cstr! b "al")
        (builder/push-cstr! b "pha")
        (builder/finish b))))

(defn demo [] : int
  (let [k (make-key)
        m (map-assoc (:: (map-new) (Map String int)) k 42)]
    (do
      (string/release k)                    ;; drop the SOURCE key
      (let [probe (string/from-cstr "alpha")] ;; independent, equal key
        (do
          (println (map-get m probe))       ;; => 42  (map owns its own copy)
          (string/release probe)
          (map-free m)                      ;; frees the owned key box once
          0)))))

The same program with a computed cstr key would read freed memory after the source was dropped. MapKey[String] boxes the bytes into a key the map owns and frees exactly once (the WKC2 owned-key path), which is the whole point of the type.

API summary

Construct / convert:

Refcount:

Query:

Search (none of these allocates; a position is an (Option int), (none) when there is no match, never -1):

Each has a slice/ twin that searches a StringSlice for a String needle, within the slice's own bounds. stdlib/cstr.tur has cstr-starts-with?, cstr-ends-with?, cstr-index-of, cstr-index-of-char and cstr-span-while, so a literal or a line from fs/read-lines can be scanned without a copy.

Split, join and lines:

Transform (each returns a fresh immutable String and borrows its arguments):

Incremental construction:

Typeclasses: Eq, Ord (lexicographic), Show (the bytes), Hash (content), Clone (retain), MapKey (owned key). Every op has an interpreter native override, so String behaves identically under --interpret / the REPL and when compiled.

Owned-String literals -- #s"..."

Bare "..." stays a cstr (borrowed) -- the default literal typing is deliberately unchanged. When you want an owned String literal, write #s"...". It is always available: no #lang token, no #use-reader-macros, nothing to declare.

(load "stdlib/string.tur")     ;; the String code (eval-time)

... #s"hello" ...              ;; => (string/from-cstr "hello"), an owned String

That (load) is still required, and the reason is fundamental rather than an oversight. The two things live in different phases. #s"..." is expanded while the file is being read, into a call to string/from-cstr; the function that call names does not exist until (load ...) runs at eval time. So the load cannot enable the syntax -- the syntax is already gone by then -- and the syntax cannot supply the code. You need the one line, and only the one line: the reader installs the dispatch for every file.

#s"..." works identically compiled and under --interpret, and an owned-String literal is safe as a Map/Set key. See tests/fixtures/string-reader-macro.

The dispatch is delimiter-keyed, so #s"..." and the #s(...) set literal are different forms and can appear in the same file.

Two directives used to turn this on, and both are now unnecessary: #lang turmeric stringed (the layer axis, decommissioned -- the token warns with TUR-W0064 for one minor line, then becomes an error) and #use-reader-macros "stdlib/string-reader.tur" (that file is now a comment-only no-op, deleted at age-out). Delete either one where you find it; what they enabled is now unconditional.

Indent-aware multi-line literals -- #dedent"""..."""

For text -- usually code -- that should be indented to match the surrounding source without that indent landing in the string. Always available, no load and no #use-reader-macros; it reads as an ordinary string literal, so the result is a static cstr.

(println #dedent"""
    int main(void) {
        printf("hi\n");
        return 0;
    }
    """)

The body is verbatim: no escapes, so \n, {, } and single " need nothing. Rules (Swift's):

tests/fixtures/dedent-reader covers each rule and tests/fixtures/errors/dedent-* the diagnostics. It works under #lang sweet-exp too (tests/fixtures/dedent-reader-sweet).

Zero-copy slicing -- StringSlice (opt-in)

string/substring copies. When you want ranged access without copying -- walk a range, compare sub-ranges, split, tokenize -- use StringSlice (stdlib/string-slice.tur): a bounds-checked, refcounted view { parent String; offset; len }.

It is safe in a way a raw pointer+len view over a cstr is not: a StringSlice retains its parent String, and String is immutable, so the viewed bytes can neither be freed nor mutated underneath the slice.

(load "stdlib/string-slice.tur")

(let [s (string/from-cstr "hello world")
      w (string/slice s 6 5)]        ;; "world" -- no copy
  (string/release s)                 ;; parent kept alive by the slice
  (slice/byte-at w 0)                ;; 119 ('w'); bounds-checked, -1 out of range
  (slice/sub w 0 3)                  ;; "wor" -- O(1), views the same parent
  (slice/to-string w))              ;; materialize an owned String only when needed

Surface: string/slice / string/slice-cstr (construct), slice/sub (O(1) sub-view), slice/len / slice/empty? / slice/byte-at / slice/byte-is? / slice/char-at / slice/compare / slice/eq? / slice/hash (query), the search functions (slice/index-of, slice/span-while and the rest, as above), slice/split-char, slice/trim / slice/trim-start / slice/trim-end (O(1) sub-views), slice/parse-int, slice/to-string / slice/to-cstr (materialize), slice/retain / slice/release (share/drop). Typeclasses Eq, Ord, Show, Hash. Compiled and --interpret behave identically.

Unicode -- stdlib/unicode.tur (opt-in)

A String is bytes, and everything above treats it that way: len counts bytes, byte-at reads one, and the char.tur predicates (space?, punct?, alpha?) know only ASCII. When text past ASCII matters, (load "stdlib/unicode.tur") adds the Unicode side. A Char there holds a whole code point, not a byte.

For Markdown's &name; references, stdlib/markdown/entities.tur adds markdown/entity-lookup: the name without & and ; ("copy") gives (some s), a fresh String of the character or characters it stands for. The table is the HTML Standard's 2125 names that end in ;, generated by tools/gen-html-entities.py and pinned by a digest.

The tables are the ones #lang r7rs's (scheme char) uses, generated by tools/gen-r7rs-unicode.py into stdlib/r7rs/unicode.tur from the Unicode Character Database that tools/fetch-ucd.sh pins (Unicode 16.0.0). The interpreter registers natives over the same generated C, so compiled code, the JIT and --interpret agree.

Lifetimes -- the rules

Memory management & common pitfalls

The one question that resolves most string bugs: who owns these bytes, and who frees them? Each type answers it differently.

Value Who owns the bytes Your obligation
"literal" (cstr) static / the compiler none -- never free it
a cstr returned by a stdlib fn ("caller frees the result": str-concat, int->str, cstr-sub, path/*, digest/*-hex, json/encode, slice/to-cstr, ...) you (fresh heap buffer) free it, or hand it to string/adopt-cstr
a cstr from an accessor (httpd-req-*, json/get-string, sym->str, string/to-cstr) the underlying structure borrow only -- don't free, don't outlive the owner
String refcount balance each from-*/concat/retain with a string/release (or hand it to a container)
StringSlice refcount + retained parent balance each string/slice/slice/sub/slice/retain with a slice/release

Copy vs consume vs borrow -- the three cstr->String bridges

Owned builders: wrap vs build

stdlib/str-build-string ships owned-String siblings for the two foundational cstr builders -- str-concat-string (over str-concat) and cstr-sub-string (over cstr-sub) -- each a one-line string/adopt-cstr wrapper. There is a real fork in how to reach for them:

Mixed fresh/static returns -- choose per branch

Some accessors/formatters return a fresh malloc on one branch and a static string literal on another -- e.g. httpd-req-cookie (malloc on a hit, static "" when the cookie is absent) or bound->str (malloc for Inclusive/Exclusive, static "unbounded" for Unbounded). A blind string/adopt-cstr over the whole return is undefined behavior the moment the static branch fires: adopt frees its argument, and freeing a string literal is UB. The rule:

The pitfalls

The short rules

See also